mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2026-02-19 08:16:37 +00:00
11
.github/dependabot.yml
vendored
11
.github/dependabot.yml
vendored
@@ -21,9 +21,12 @@ updates:
|
||||
shlink:
|
||||
patterns:
|
||||
- '@shlinkio/*'
|
||||
types:
|
||||
react:
|
||||
patterns:
|
||||
- '@types/*'
|
||||
- 'react'
|
||||
- 'react-dom'
|
||||
- '@types/react'
|
||||
- '@types/react-dom'
|
||||
testing:
|
||||
patterns:
|
||||
- '@testing-library/*'
|
||||
@@ -38,6 +41,10 @@ updates:
|
||||
workbox:
|
||||
patterns:
|
||||
- 'workbox*'
|
||||
tailwindcss:
|
||||
patterns:
|
||||
- 'tailwindcss'
|
||||
- '@tailwindcss/*'
|
||||
ignore:
|
||||
# Bootstrap can introduce visual breaking changes on styles
|
||||
# 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:
|
||||
node-version: 22.x
|
||||
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:
|
||||
deploy:
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
- name: Use node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22.x
|
||||
node-version: 22.10
|
||||
- name: Build
|
||||
run: |
|
||||
npm ci && \
|
||||
|
||||
4
.github/workflows/publish-release.yml
vendored
4
.github/workflows/publish-release.yml
vendored
@@ -7,14 +7,14 @@ on:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
- name: Use node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22.x
|
||||
node-version: 22.10
|
||||
- name: Generate release assets
|
||||
run: npm ci && VERSION=${GITHUB_REF#refs/tags/v} npm run build:dist
|
||||
- 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).
|
||||
|
||||
## [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
|
||||
### 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.
|
||||
@@ -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.
|
||||
|
||||
### 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
|
||||
|
||||
@@ -45,11 +45,8 @@ shlink-web-client
|
||||
|
||||
> 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:js`: Checks coding styles are fulfilled 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 lint`: Checks coding styles are fulfilled in JS/TS files.
|
||||
* `./indocker node --run lint:fix`: Fixes coding styles in JS/TS files.
|
||||
* `./indocker node --run test`: Runs unit tests with Jest.
|
||||
|
||||
## 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
|
||||
ARG VERSION="latest"
|
||||
ENV VERSION=${VERSION}
|
||||
|
||||
@@ -1,24 +1,9 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
import axe from 'axe-core';
|
||||
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
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
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:
|
||||
container_name: shlink_web_client_node
|
||||
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"
|
||||
volumes:
|
||||
- ./:/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",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"lint": "node --run lint:css && node --run lint:js",
|
||||
"lint:css": "stylelint src/*.scss src/**/*.scss",
|
||||
"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",
|
||||
"lint": "eslint src test config/test",
|
||||
"lint:fix": "node --run lint -- --fix",
|
||||
"types": "tsc",
|
||||
"start": "vite serve --host=0.0.0.0",
|
||||
"preview": "vite preview --host=0.0.0.0",
|
||||
@@ -24,18 +20,18 @@
|
||||
"test:verbose": "node --run test -- --verbose"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^6.7.1",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.7.1",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.7.1",
|
||||
"@fortawesome/free-regular-svg-icons": "^6.7.1",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.7.1",
|
||||
"@fortawesome/fontawesome-free": "^6.7.2",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.7.2",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.7.2",
|
||||
"@fortawesome/free-regular-svg-icons": "^6.7.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.7.2",
|
||||
"@fortawesome/react-fontawesome": "^0.2.2",
|
||||
"@json2csv/plainjs": "^7.0.6",
|
||||
"@reduxjs/toolkit": "^2.4.0",
|
||||
"@reduxjs/toolkit": "^2.7.0",
|
||||
"@shlinkio/data-manipulation": "^1.0.3",
|
||||
"@shlinkio/shlink-frontend-kit": "^0.6.0",
|
||||
"@shlinkio/shlink-js-sdk": "^1.3.0",
|
||||
"@shlinkio/shlink-web-component": "^0.11.0",
|
||||
"@shlinkio/shlink-frontend-kit": "^0.8.12",
|
||||
"@shlinkio/shlink-js-sdk": "^2.1.0",
|
||||
"@shlinkio/shlink-web-component": "^0.13.3",
|
||||
"bootstrap": "5.2.3",
|
||||
"bottlejs": "^2.0.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -44,9 +40,9 @@
|
||||
"date-fns": "^4.1.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-external-link": "^2.3.1",
|
||||
"react-redux": "^9.1.2",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"react-external-link": "^2.5.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router": "^7.5.1",
|
||||
"reactstrap": "^9.2.3",
|
||||
"redux-localstorage-simple": "^2.5.1",
|
||||
"workbox-core": "^7.3.0",
|
||||
@@ -56,35 +52,37 @@
|
||||
"workbox-strategies": "^7.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@shlinkio/eslint-config-js-coding-standard": "~3.2.1",
|
||||
"@shlinkio/stylelint-config-css-coding-standard": "~1.1.1",
|
||||
"@stylistic/eslint-plugin": "^2.11.0",
|
||||
"@shlinkio/eslint-config-js-coding-standard": "~3.5.0",
|
||||
"@stylistic/eslint-plugin": "^4.2.0",
|
||||
"@tailwindcss/vite": "^4.1.4",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.0.1",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@total-typescript/shoehorn": "^0.1.2",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@vitest/coverage-v8": "^2.1.6",
|
||||
"@vitejs/plugin-react": "^4.4.0",
|
||||
"@vitest/browser": "^3.1.1",
|
||||
"@vitest/coverage-v8": "^3.1.1",
|
||||
"adm-zip": "^0.5.16",
|
||||
"axe-core": "^4.10.2",
|
||||
"chalk": "^5.3.0",
|
||||
"eslint": "^9.16.0",
|
||||
"axe-core": "^4.10.3",
|
||||
"chalk": "^5.4.1",
|
||||
"eslint": "^9.25.0",
|
||||
"eslint-plugin-import": "^2.31.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-react": "^7.37.2",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-compiler": "^19.0.0-beta-ebf51a3-20250411",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-simple-import-sort": "^12.1.1",
|
||||
"history": "^5.3.0",
|
||||
"jsdom": "^25.0.1",
|
||||
"sass": "^1.81.0",
|
||||
"stylelint": "^15.11.0",
|
||||
"typescript": "^5.7.2",
|
||||
"typescript-eslint": "^8.16.0",
|
||||
"vite": "^6.0.1",
|
||||
"vite-plugin-pwa": "^0.21.1",
|
||||
"vitest": "^2.0.2"
|
||||
"playwright": "^1.52.0",
|
||||
"sass": "^1.86.3",
|
||||
"tailwindcss": "^4.1.3",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint": "^8.30.1",
|
||||
"vite": "^6.3.2",
|
||||
"vite-plugin-pwa": "^1.0.0",
|
||||
"vitest": "^3.0.5"
|
||||
},
|
||||
"browserslist": [
|
||||
">0.2%",
|
||||
|
||||
@@ -10,7 +10,8 @@ setup_single_shlink_server() {
|
||||
[ -n "$SHLINK_SERVER_URL" ] || return 0
|
||||
[ -n "$SHLINK_SERVER_API_KEY" ] || return 0
|
||||
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
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { GetState } from '../../container/types';
|
||||
import type { ServerWithId } 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 =>
|
||||
typeof getStateOrSelectedServer === 'function';
|
||||
@@ -18,14 +18,22 @@ const getSelectedServerFromState = (getState: 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)
|
||||
: 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 });
|
||||
apiClients[serverKey] = apiClient;
|
||||
if (existingApiClient) {
|
||||
return existingApiClient;
|
||||
}
|
||||
|
||||
const apiClient = new ShlinkApiClient(
|
||||
httpClient,
|
||||
{ apiKey, baseUrl },
|
||||
{ requestCredentials: forwardCredentials ? 'include' : undefined },
|
||||
);
|
||||
apiClients.set(serverKey, 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 type { FC } 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 { NotFound } from '../common/NotFound';
|
||||
import type { FCWithDeps } from '../container/utils';
|
||||
import { componentFactory, useDependencies } from '../container/utils';
|
||||
import type { ServersMap } from '../servers/data';
|
||||
import { forceUpdate } from '../utils/helpers/sw';
|
||||
import './App.scss';
|
||||
|
||||
type AppProps = {
|
||||
fetchServers: () => void;
|
||||
@@ -62,28 +61,38 @@ const App: FCWithDeps<AppProps, AppDeps> = (
|
||||
}, [settings.ui?.theme]);
|
||||
|
||||
return (
|
||||
<div className="container-fluid app-container">
|
||||
<div className="tw:px-3 tw:h-full">
|
||||
<MainHeader />
|
||||
|
||||
<div className="app">
|
||||
<div className={clsx('shlink-wrapper', { 'd-flex align-items-center pt-3': isHome })}>
|
||||
<div className="tw:h-full tw:pt-(--header-height)">
|
||||
<div
|
||||
data-testid="shlink-wrapper"
|
||||
className={clsx(
|
||||
'tw:min-h-full tw:pb-[calc(var(--footer-height)+var(--footer-margin))] tw:-mb-[calc(var(--footer-height)+var(--footer-margin))]',
|
||||
{ 'tw:flex tw:items-center tw:pt-4': isHome },
|
||||
)}
|
||||
>
|
||||
<Routes>
|
||||
<Route index element={<Home />} />
|
||||
<Route path="/settings/*" element={<Settings />} />
|
||||
<Route path="/settings">
|
||||
{['', '*'].map((path) => <Route key={path} path={path} element={<Settings />} />)}
|
||||
</Route>
|
||||
<Route path="/manage-servers" element={<ManageServers />} />
|
||||
<Route path="/server/create" element={<CreateServer />} />
|
||||
<Route path="/server/:serverId/edit" element={<EditServer />} />
|
||||
<Route path="/server/:serverId/*" element={<ShlinkWebComponentContainer />} />
|
||||
<Route path="/server/:serverId">
|
||||
{['', '*'].map((path) => <Route key={path} path={path} element={<ShlinkWebComponentContainer />} />)}
|
||||
</Route>
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</div>
|
||||
|
||||
<div className="shlink-footer">
|
||||
<div className="tw:h-(--footer-height) tw:mt-(--footer-margin) tw:md:px-4">
|
||||
<ShlinkVersionsContainer />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AppUpdateBanner isOpen={appUpdated} toggle={resetAppUpdate} forceUpdate={forceUpdate} />
|
||||
<AppUpdateBanner isOpen={appUpdated} onClose={resetAppUpdate} forceUpdate={forceUpdate} />
|
||||
</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 { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { SimpleCard, useToggle } from '@shlinkio/shlink-frontend-kit';
|
||||
import type { MouseEventHandler } from 'react';
|
||||
import { forwardRef, useCallback } from 'react';
|
||||
import { Alert, Button } from 'reactstrap';
|
||||
import './AppUpdateBanner.scss';
|
||||
import { useToggle } from '@shlinkio/shlink-frontend-kit';
|
||||
import { Button, Card, CloseButton } from '@shlinkio/shlink-frontend-kit/tailwind';
|
||||
import { clsx } from 'clsx';
|
||||
import type { FC } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
interface AppUpdateBannerProps {
|
||||
isOpen: boolean;
|
||||
toggle: MouseEventHandler<any>;
|
||||
onClose: () => 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 update = useCallback(() => {
|
||||
setUpdating();
|
||||
forceUpdate();
|
||||
}, [forceUpdate, setUpdating]);
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert className="app-update-banner" isOpen={isOpen} toggle={toggle} tag={SimpleCard} color="secondary" innerRef={ref}>
|
||||
<h4 className="mb-4">This app has just been updated!</h4>
|
||||
<p className="mb-0">
|
||||
<Card
|
||||
role="alert"
|
||||
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.
|
||||
<Button role="button" disabled={isUpdating} className="ms-2" color="secondary" size="sm" onClick={update}>
|
||||
{!isUpdating && <>Restart now <FontAwesomeIcon icon={reloadIcon} className="ms-1" /></>}
|
||||
<Button disabled={isUpdating} variant="secondary" solid onClick={update}>
|
||||
{!isUpdating && <>Restart now <FontAwesomeIcon icon={reloadIcon} /></>}
|
||||
{isUpdating && <>Restarting...</>}
|
||||
</Button>
|
||||
</p>
|
||||
</Alert>
|
||||
</Card.Body>
|
||||
</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 { Component } from 'react';
|
||||
import { Button } from 'reactstrap';
|
||||
import { ErrorLayout } from './ErrorLayout';
|
||||
|
||||
type ErrorHandlerProps = PropsWithChildren<{
|
||||
location?: typeof window.location;
|
||||
@@ -33,14 +33,11 @@ export class ErrorHandler extends Component<ErrorHandlerProps, ErrorHandlerState
|
||||
|
||||
if (hasError) {
|
||||
return (
|
||||
<div className="home">
|
||||
<SimpleCard className="p-4">
|
||||
<h1>Oops! This is awkward :S</h1>
|
||||
<p>It seems that something went wrong. Try refreshing the page or just click this button.</p>
|
||||
<br />
|
||||
<Button outline color="primary" onClick={() => location.reload()}>Take me back</Button>
|
||||
</SimpleCard>
|
||||
</div>
|
||||
<ErrorLayout title="Oops! This is awkward :S">
|
||||
<p>It seems that something went wrong. Try refreshing the page or just click this button.</p>
|
||||
<br />
|
||||
<Button size="lg" onClick={() => location.reload()}>Take me back</Button>
|
||||
</ErrorLayout>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
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 { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { Button, Card } from '@shlinkio/shlink-frontend-kit/tailwind';
|
||||
import { clsx } from 'clsx';
|
||||
import { useEffect } from 'react';
|
||||
import { ExternalLink } from 'react-external-link';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { Card } from 'reactstrap';
|
||||
import { useNavigate } from 'react-router';
|
||||
import type { ServersMap } from '../servers/data';
|
||||
import { ServersListGroup } from '../servers/ServersListGroup';
|
||||
import { ShlinkLogo } from './img/ShlinkLogo';
|
||||
import './Home.scss';
|
||||
|
||||
interface HomeProps {
|
||||
export type HomeProps = {
|
||||
servers: ServersMap;
|
||||
}
|
||||
};
|
||||
|
||||
export const Home = ({ servers }: HomeProps) => {
|
||||
const navigate = useNavigate();
|
||||
@@ -28,42 +27,42 @@ export const Home = ({ servers }: HomeProps) => {
|
||||
}, [serversList, navigate]);
|
||||
|
||||
return (
|
||||
<div className="w-100">
|
||||
<Card className="mx-auto" style={{ maxWidth: '720px' }}>
|
||||
<div className="d-flex flex-column flex-md-row">
|
||||
<div className="p-4 d-none d-md-flex align-items-center" style={{ width: '40%' }}>
|
||||
<div className="w-100">
|
||||
<div className="tw:w-full">
|
||||
<Card className="tw:mx-auto tw:max-w-[720px] tw:overflow-hidden">
|
||||
<div className="tw:flex tw:flex-col tw:md:flex-row">
|
||||
<div className="tw:p-6 tw:hidden tw:md:flex tw:items-center tw:w-[40%]">
|
||||
<div className="tw:w-full">
|
||||
<ShlinkLogo />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="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
|
||||
className={clsx('home__title p-4 text-center m-0', { 'border-bottom': !hasServers })}
|
||||
style={{ borderColor: 'var(--border-color) !important' }}
|
||||
className={clsx(
|
||||
'tw:p-4 tw:text-center tw:border-lm-border tw:dark:border-dm-border',
|
||||
{ 'tw:border-b': !hasServers },
|
||||
)}
|
||||
>
|
||||
Welcome!
|
||||
</h1>
|
||||
<ServersListGroup embedded servers={serversList}>
|
||||
{!hasServers && (
|
||||
<div className="p-4 text-center d-flex flex-column gap-5">
|
||||
<p className="mb-0">This application will help you manage your Shlink servers.</p>
|
||||
<p className="mb-0">
|
||||
<Link to="/server/create" className="btn btn-outline-primary btn-lg me-2">
|
||||
<FontAwesomeIcon icon={faPlus}/> <span className="ms-1">Add a server</span>
|
||||
</Link>
|
||||
</p>
|
||||
<p className="mb-0">
|
||||
<ExternalLink href="https://shlink.io/documentation">
|
||||
<small>
|
||||
<span className="me-2">Learn more about Shlink</span>
|
||||
<FontAwesomeIcon icon={faExternalLinkAlt}/>
|
||||
</small>
|
||||
</ExternalLink>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</ServersListGroup>
|
||||
{hasServers ? <ServersListGroup servers={serversList} /> : (
|
||||
<div className="tw:p-6 tw:text-center tw:flex tw:flex-col tw:gap-12 tw:text-xl">
|
||||
<p>This application will help you manage your Shlink servers.</p>
|
||||
<p>
|
||||
<Button to="/server/create" size="lg" inline>
|
||||
<FontAwesomeIcon icon={faPlus} /> Add a server
|
||||
</Button>
|
||||
</p>
|
||||
<p>
|
||||
<ExternalLink href="https://shlink.io/documentation">
|
||||
<small>
|
||||
<span className="tw:mr-2">Learn more about Shlink</span>
|
||||
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
||||
</small>
|
||||
</ExternalLink>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</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 type { FC } 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 type { FCWithDeps } from '../container/utils';
|
||||
import { componentFactory, useDependencies } from '../container/utils';
|
||||
import { ShlinkLogo } from './img/ShlinkLogo';
|
||||
import './MainHeader.scss';
|
||||
|
||||
type MainHeaderDeps = {
|
||||
ServersDropdown: FC;
|
||||
@@ -25,20 +24,22 @@ const MainHeader: FCWithDeps<unknown, MainHeaderDeps> = () => {
|
||||
useEffect(collapse, [location, collapse]);
|
||||
|
||||
const settingsPath = '/settings';
|
||||
const toggleClass = clsx('main-header__toggle-icon', { 'main-header__toggle-icon--opened': isNotCollapsed });
|
||||
|
||||
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="/">
|
||||
<ShlinkLogo className="main-header__brand-logo" color="white" /> Shlink
|
||||
<ShlinkLogo className="tw:inline tw:w-7 tw:mr-1" color="white" /> Shlink
|
||||
</NavbarBrand>
|
||||
|
||||
<NavbarToggler onClick={toggleCollapse}>
|
||||
<FontAwesomeIcon icon={arrowIcon} className={toggleClass} />
|
||||
<FontAwesomeIcon
|
||||
icon={arrowIcon}
|
||||
className={clsx('tw:transition-transform tw:duration-300', { 'tw:rotate-180': isNotCollapsed })}
|
||||
/>
|
||||
</NavbarToggler>
|
||||
|
||||
<Collapse navbar isOpen={isNotCollapsed}>
|
||||
<Nav navbar className="ms-auto">
|
||||
<Nav navbar className="tw:ml-auto">
|
||||
<NavItem>
|
||||
<NavLink tag={Link} to={settingsPath} active={pathname.startsWith(settingsPath)}>
|
||||
<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 type { FC, PropsWithChildren } from 'react';
|
||||
import './NoMenuLayout.scss';
|
||||
|
||||
export type NoMenuLayoutProps = PropsWithChildren & {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
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 { Link } from 'react-router-dom';
|
||||
import { ErrorLayout } from './ErrorLayout';
|
||||
|
||||
type NotFoundProps = PropsWithChildren<{ to?: string }>;
|
||||
|
||||
export const NotFound: FC<NotFoundProps> = ({ to = '/', children = 'Home' }) => (
|
||||
<div className="home">
|
||||
<SimpleCard className="p-4">
|
||||
<h2>Oops! We could not find requested route.</h2>
|
||||
<p>
|
||||
Use your browser's back button to navigate to the page you have previously come from, or just press this
|
||||
button.
|
||||
</p>
|
||||
<br />
|
||||
<Link to={to} className="btn btn-outline-primary btn-lg">{children}</Link>
|
||||
</SimpleCard>
|
||||
</div>
|
||||
<ErrorLayout title="Oops! We could not find requested route.">
|
||||
<p>
|
||||
Use your browser's back button to navigate to the page you have previously come from, or just press this
|
||||
button.
|
||||
</p>
|
||||
<br />
|
||||
<Button inline to={to} size="lg">{children}</Button>
|
||||
</ErrorLayout>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { FC, PropsWithChildren } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useLocation } from 'react-router';
|
||||
|
||||
export const ScrollToTop: FC<PropsWithChildren> = ({ children }) => {
|
||||
const location = useLocation();
|
||||
|
||||
@@ -12,7 +12,7 @@ export interface ShlinkVersionsProps {
|
||||
}
|
||||
|
||||
const VersionLink = ({ project, version }: { project: 'shlink' | 'shlink-web-client'; version: string }) => (
|
||||
<ExternalLink href={`https://github.com/shlinkio/${project}/releases/${version}`} className="text-muted">
|
||||
<ExternalLink href={`https://github.com/shlinkio/${project}/releases/${version}`} className="tw:text-gray-500">
|
||||
<b>{version}</b>
|
||||
</ExternalLink>
|
||||
);
|
||||
@@ -21,7 +21,7 @@ export const ShlinkVersions = ({ selectedServer, clientVersion = SHLINK_WEB_CLIE
|
||||
const normalizedClientVersion = normalizeVersion(clientVersion);
|
||||
|
||||
return (
|
||||
<small className="text-muted">
|
||||
<small className="tw:text-gray-500">
|
||||
{isReachableServer(selectedServer) && (
|
||||
<>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 { isReachableServer } from '../servers/data';
|
||||
import { ShlinkVersions } from './ShlinkVersions';
|
||||
import './ShlinkVersionsContainer.scss';
|
||||
|
||||
export type ShlinkVersionsContainerProps = {
|
||||
selectedServer: SelectedServer;
|
||||
@@ -10,9 +9,7 @@ export type ShlinkVersionsContainerProps = {
|
||||
|
||||
export const ShlinkVersionsContainer = ({ selectedServer }: ShlinkVersionsContainerProps) => (
|
||||
<div
|
||||
className={clsx('text-center', {
|
||||
'shlink-versions-container--with-sidebar': isReachableServer(selectedServer),
|
||||
})}
|
||||
className={clsx('tw:text-center', { 'tw:md:ml-(--aside-menu-width)': isReachableServer(selectedServer) })}
|
||||
>
|
||||
<ShlinkVersions selectedServer={selectedServer} />
|
||||
</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 type Bottle from 'bottlejs';
|
||||
import type { ConnectDecorator } from '../../container/types';
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import type { IContainer } from 'bottlejs';
|
||||
import type { FC } from 'react';
|
||||
import { useRef } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
export type FCWithDeps<Props, Deps> = FC<Props> & Partial<Deps>;
|
||||
|
||||
export function useDependencies<Deps>(obj: Deps): Omit<Required<Deps>, keyof FC> {
|
||||
const depsRef = useRef(obj as Omit<Required<Deps>, keyof FC>);
|
||||
return depsRef.current;
|
||||
return useMemo(() => obj as Omit<Required<Deps>, keyof FC>, [obj]);
|
||||
}
|
||||
|
||||
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
|
||||
@import '../node_modules/bootstrap/scss/bootstrap.scss';
|
||||
@import '../node_modules/@shlinkio/shlink-frontend-kit/dist/index'; // After bootstrap. Includes CSS overwrites
|
||||
@import '../node_modules/@shlinkio/shlink-web-component/dist/index';
|
||||
@use '../node_modules/@shlinkio/shlink-frontend-kit/dist/base'; // Before bootstrap stylesheet
|
||||
@use '../node_modules/bootstrap/scss/bootstrap.scss' with (
|
||||
$primary: base.$mainColor // Override bootstrap's primary color
|
||||
);
|
||||
@use '../node_modules/@shlinkio/shlink-frontend-kit/dist/index'; // After bootstrap. Includes CSS overrides
|
||||
@use '../node_modules/@shlinkio/shlink-web-component/dist/index' as c-index;
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { Provider } from 'react-redux';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { BrowserRouter } from 'react-router';
|
||||
import pack from '../package.json';
|
||||
import { container } from './container';
|
||||
import { setUpStore } from './container/store';
|
||||
import { register as registerServiceWorker } from './serviceWorkerRegistration';
|
||||
import './tailwind.css';
|
||||
import './index.scss';
|
||||
|
||||
const store = setUpStore(container);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
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 { useCallback, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from 'reactstrap';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { NoMenuLayout } from '../common/NoMenuLayout';
|
||||
import type { FCWithDeps } from '../container/utils';
|
||||
import { componentFactory, useDependencies } from '../container/utils';
|
||||
@@ -26,11 +27,11 @@ type CreateServerDeps = {
|
||||
useTimeoutToggle: TimeoutToggle;
|
||||
};
|
||||
|
||||
const ImportResult = ({ type }: { type: 'error' | 'success' }) => (
|
||||
<div className="mt-3">
|
||||
<Result type={type}>
|
||||
{type === 'success' && 'Servers properly imported. You can now select one from the list :)'}
|
||||
{type === 'error' && 'The servers could not be imported. Make sure the format is correct.'}
|
||||
const ImportResult = ({ variant }: Pick<ResultProps, 'variant'>) => (
|
||||
<div className="tw:mt-4">
|
||||
<Result variant={variant}>
|
||||
{variant === 'success' && 'Servers properly imported. You can now select one from the list :)'}
|
||||
{variant === 'error' && 'The servers could not be imported. Make sure the format is correct.'}
|
||||
</Result>
|
||||
</div>
|
||||
);
|
||||
@@ -40,7 +41,9 @@ const CreateServer: FCWithDeps<CreateServerProps, CreateServerDeps> = ({ servers
|
||||
const navigate = useNavigate();
|
||||
const goBack = useGoBack();
|
||||
const hasServers = !!Object.keys(servers).length;
|
||||
// eslint-disable-next-line react-compiler/react-compiler
|
||||
const [serversImported, setServersImported] = useTimeoutToggle(false, SHOW_IMPORT_MSG_TIME);
|
||||
// eslint-disable-next-line react-compiler/react-compiler
|
||||
const [errorImporting, setErrorImporting] = useTimeoutToggle(false, SHOW_IMPORT_MSG_TIME);
|
||||
const [isConfirmModalOpen, toggleConfirmModal] = useToggle();
|
||||
const [serverData, setServerData] = useState<ServerData>();
|
||||
@@ -66,22 +69,22 @@ const CreateServer: FCWithDeps<CreateServerProps, CreateServerDeps> = ({ servers
|
||||
|
||||
return (
|
||||
<NoMenuLayout>
|
||||
<ServerForm title={<h5 className="mb-0">Add new server</h5>} onSubmit={onSubmit}>
|
||||
<ServerForm title="Add new server" onSubmit={onSubmit}>
|
||||
{!hasServers && (
|
||||
<ImportServersBtn tooltipPlacement="top" onImport={setServersImported} onImportError={setErrorImporting} />
|
||||
<ImportServersBtn tooltipPlacement="top" onImport={setServersImported} onError={setErrorImporting} />
|
||||
)}
|
||||
{hasServers && <Button outline onClick={goBack}>Cancel</Button>}
|
||||
<Button outline color="primary" className="ms-2">Create server</Button>
|
||||
{hasServers && <Button variant="secondary" onClick={goBack}>Cancel</Button>}
|
||||
<Button type="submit">Create server</Button>
|
||||
</ServerForm>
|
||||
|
||||
{serversImported && <ImportResult type="success" />}
|
||||
{errorImporting && <ImportResult type="error" />}
|
||||
{serversImported && <ImportResult variant="success" />}
|
||||
{errorImporting && <ImportResult variant="error" />}
|
||||
|
||||
<DuplicatedServersModal
|
||||
isOpen={isConfirmModalOpen}
|
||||
open={isConfirmModalOpen}
|
||||
duplicatedServers={serverData ? [serverData] : []}
|
||||
onDiscard={goBack}
|
||||
onSave={() => serverData && saveNewServer(serverData)}
|
||||
onClose={goBack}
|
||||
onConfirm={() => serverData && saveNewServer(serverData)}
|
||||
/>
|
||||
</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 { clsx } from 'clsx';
|
||||
import type { FC, PropsWithChildren } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import type { FCWithDeps } from '../container/utils';
|
||||
import { componentFactory, useDependencies } from '../container/utils';
|
||||
import type { ServerWithId } from './data';
|
||||
@@ -10,28 +9,29 @@ import type { DeleteServerModalProps } from './DeleteServerModal';
|
||||
|
||||
export type DeleteServerButtonProps = PropsWithChildren<{
|
||||
server: ServerWithId;
|
||||
className?: string;
|
||||
textClassName?: string;
|
||||
}>;
|
||||
|
||||
type DeleteServerButtonDeps = {
|
||||
DeleteServerModal: FC<DeleteServerModalProps>;
|
||||
};
|
||||
|
||||
const DeleteServerButton: FCWithDeps<DeleteServerButtonProps, DeleteServerButtonDeps> = (
|
||||
{ server, className, children, textClassName },
|
||||
) => {
|
||||
const DeleteServerButton: FCWithDeps<DeleteServerButtonProps, DeleteServerButtonDeps> = ({ server, children }) => {
|
||||
const { DeleteServerModal } = useDependencies(DeleteServerButton);
|
||||
const [isModalOpen, , showModal, hideModal] = useToggle();
|
||||
const navigate = useNavigate();
|
||||
const onClose = useCallback((confirmed: boolean) => {
|
||||
hideModal();
|
||||
if (confirmed) {
|
||||
navigate('/');
|
||||
}
|
||||
}, [hideModal, navigate]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" className={clsx(className, 'p-0 bg-transparent border-0')} onClick={showModal}>
|
||||
{!children && <FontAwesomeIcon fixedWidth icon={deleteIcon} />}
|
||||
<span className={textClassName}>{children ?? 'Remove this server'}</span>
|
||||
<button type="button" className="tw:text-danger tw:hover:underline" onClick={showModal}>
|
||||
{children}
|
||||
</button>
|
||||
|
||||
<DeleteServerModal server={server} isOpen={isModalOpen} toggle={hideModal} />
|
||||
<DeleteServerModal server={server} open={isModalOpen} onClose={onClose} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 { useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
||||
import { useCallback } from 'react';
|
||||
import type { ServerWithId } from './data';
|
||||
|
||||
export interface DeleteServerModalProps {
|
||||
export type DeleteServerModalProps = {
|
||||
server: ServerWithId;
|
||||
toggle: () => void;
|
||||
isOpen: boolean;
|
||||
redirectHome?: boolean;
|
||||
}
|
||||
onClose: (confirmed: boolean) => void;
|
||||
open: boolean;
|
||||
};
|
||||
|
||||
interface DeleteServerModalConnectProps extends DeleteServerModalProps {
|
||||
type DeleteServerModalConnectProps = DeleteServerModalProps & {
|
||||
deleteServer: (server: ServerWithId) => void;
|
||||
}
|
||||
};
|
||||
|
||||
export const DeleteServerModal: FC<DeleteServerModalConnectProps> = (
|
||||
{ server, toggle, isOpen, deleteServer, redirectHome = true },
|
||||
) => {
|
||||
const navigate = useNavigate();
|
||||
const doDelete = useRef<boolean>(false);
|
||||
const toggleAndDelete = () => {
|
||||
doDelete.current = true;
|
||||
toggle();
|
||||
};
|
||||
const onClosed = () => {
|
||||
if (!doDelete.current) {
|
||||
return;
|
||||
export const DeleteServerModal: FC<DeleteServerModalConnectProps> = ({ server, onClose, open, deleteServer }) => {
|
||||
const onClosed = useCallback((exitAction: ExitAction) => {
|
||||
if (exitAction === 'confirm') {
|
||||
deleteServer(server);
|
||||
}
|
||||
|
||||
deleteServer(server);
|
||||
if (redirectHome) {
|
||||
navigate('/');
|
||||
}
|
||||
};
|
||||
}, [deleteServer, server]);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} toggle={toggle} centered onClosed={onClosed}>
|
||||
<ModalHeader toggle={toggle} className="text-danger">Remove server</ModalHeader>
|
||||
<ModalBody>
|
||||
<CardModal
|
||||
open={open}
|
||||
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>
|
||||
<i>
|
||||
@@ -46,11 +39,7 @@ export const DeleteServerModal: FC<DeleteServerModalConnectProps> = (
|
||||
You can create it again at any moment.
|
||||
</i>
|
||||
</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color="link" onClick={toggle}>Cancel</Button>
|
||||
<Button color="danger" onClick={toggleAndDelete}>Delete</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
</div>
|
||||
</CardModal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useParsedQuery } from '@shlinkio/shlink-frontend-kit';
|
||||
import { Button } from '@shlinkio/shlink-frontend-kit/tailwind';
|
||||
import type { FC } from 'react';
|
||||
import { Button } from 'reactstrap';
|
||||
import { NoMenuLayout } from '../common/NoMenuLayout';
|
||||
import type { FCWithDeps } from '../container/utils';
|
||||
import { componentFactory } from '../container/utils';
|
||||
@@ -40,12 +40,12 @@ const EditServer: FCWithDeps<EditServerProps, EditServerDeps> = withSelectedServ
|
||||
return (
|
||||
<NoMenuLayout>
|
||||
<ServerForm
|
||||
title={<h5 className="mb-0">Edit "{selectedServer.name}"</h5>}
|
||||
title={<>Edit "{selectedServer.name}"</>}
|
||||
initialValues={selectedServer}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<Button outline className="me-2" onClick={goBack}>Cancel</Button>
|
||||
<Button outline color="primary">Save</Button>
|
||||
<Button variant="secondary" onClick={goBack}>Cancel</Button>
|
||||
<Button type="submit">Save</Button>
|
||||
</ServerForm>
|
||||
</NoMenuLayout>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { faFileDownload as exportIcon, faPlus as plusIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import type { TimeoutToggle } from '@shlinkio/shlink-frontend-kit';
|
||||
import { 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 { useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Button } from 'reactstrap';
|
||||
import { NoMenuLayout } from '../common/NoMenuLayout';
|
||||
import type { FCWithDeps } from '../container/utils';
|
||||
import { componentFactory, useDependencies } from '../container/utils';
|
||||
@@ -41,48 +39,50 @@ const ManageServers: FCWithDeps<ManageServersProps, ManageServersDeps> = ({ serv
|
||||
[allServers, searchTerm],
|
||||
);
|
||||
const hasAutoConnect = allServers.some(({ autoConnect }) => !!autoConnect);
|
||||
// eslint-disable-next-line react-compiler/react-compiler
|
||||
const [errorImporting, setErrorImporting] = useTimeoutToggle(false, SHOW_IMPORT_MSG_TIME);
|
||||
|
||||
return (
|
||||
<NoMenuLayout className="d-flex flex-column gap-3">
|
||||
<SearchField onChange={setSearchTerm} />
|
||||
<NoMenuLayout className="tw:flex tw:flex-col tw:gap-y-4">
|
||||
<SearchInput onChange={setSearchTerm} />
|
||||
|
||||
<div className="d-flex flex-column flex-md-row gap-2">
|
||||
<div className="d-flex gap-2">
|
||||
<ImportServersBtn className="flex-fill" onImportError={setErrorImporting}>Import servers</ImportServersBtn>
|
||||
<div className="tw:flex tw:flex-col tw:md:flex-row tw:gap-2">
|
||||
<div className="tw:flex tw:gap-2">
|
||||
<ImportServersBtn className="tw:flex-grow" onError={setErrorImporting}>Import servers</ImportServersBtn>
|
||||
{filteredServers.length > 0 && (
|
||||
<Button outline className="flex-fill" onClick={async () => serversExporter.exportServers()}>
|
||||
<FontAwesomeIcon icon={exportIcon} fixedWidth /> Export servers
|
||||
<Button variant="secondary" className="tw:flex-grow" onClick={async () => serversExporter.exportServers()}>
|
||||
<FontAwesomeIcon icon={exportIcon} /> Export servers
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Button outline color="primary" className="ms-md-auto" tag={Link} to="/server/create">
|
||||
<FontAwesomeIcon icon={plusIcon} fixedWidth /> Add a server
|
||||
<Button className="tw:md:ml-auto" to="/server/create">
|
||||
<FontAwesomeIcon icon={plusIcon} /> Add a server
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<SimpleCard>
|
||||
<table className="table table-hover responsive-table mb-0">
|
||||
<thead className="responsive-table__header">
|
||||
<tr>
|
||||
{hasAutoConnect && <th style={{ width: '50px' }}><span className="sr-only">Auto-connect</span></th>}
|
||||
<th>Name</th>
|
||||
<th>Base URL</th>
|
||||
<th><span className="sr-only">Options</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{!filteredServers.length && <tr className="text-center"><td colSpan={4}>No servers found.</td></tr>}
|
||||
{filteredServers.map((server) => (
|
||||
<ManageServersRow key={server.id} server={server} hasAutoConnect={hasAutoConnect} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<SimpleCard className="card">
|
||||
<Table header={(
|
||||
<Table.Row>
|
||||
{hasAutoConnect && (
|
||||
<Table.Cell className="tw:w-[35px]"><span className="tw:sr-only">Auto-connect</span></Table.Cell>
|
||||
)}
|
||||
<Table.Cell>Name</Table.Cell>
|
||||
<Table.Cell>Base URL</Table.Cell>
|
||||
<Table.Cell><span className="sr-only">Options</span></Table.Cell>
|
||||
</Table.Row>
|
||||
)}>
|
||||
{!filteredServers.length && (
|
||||
<Table.Row className="tw:text-center"><Table.Cell colSpan={4}>No servers found.</Table.Cell></Table.Row>
|
||||
)}
|
||||
{filteredServers.map((server) => (
|
||||
<ManageServersRow key={server.id} server={server} hasAutoConnect={hasAutoConnect} />
|
||||
))}
|
||||
</Table>
|
||||
</SimpleCard>
|
||||
|
||||
{errorImporting && (
|
||||
<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>
|
||||
)}
|
||||
</NoMenuLayout>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { faCheck as checkIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { Table } from '@shlinkio/shlink-frontend-kit/tailwind';
|
||||
import type { FC } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Link } from 'react-router';
|
||||
import { UncontrolledTooltip } from 'reactstrap';
|
||||
import type { FCWithDeps } from '../container/utils';
|
||||
import { componentFactory, useDependencies } from '../container/utils';
|
||||
@@ -21,27 +22,27 @@ const ManageServersRow: FCWithDeps<ManageServersRowProps, ManageServersRowDeps>
|
||||
const { ManageServersRowDropdown } = useDependencies(ManageServersRow);
|
||||
|
||||
return (
|
||||
<tr className="responsive-table__row">
|
||||
<Table.Row className="tw:relative">
|
||||
{hasAutoConnect && (
|
||||
<td className="responsive-table__cell" data-th="Auto-connect">
|
||||
<Table.Cell columnName="Auto-connect">
|
||||
{server.autoConnect && (
|
||||
<>
|
||||
<FontAwesomeIcon icon={checkIcon} className="text-primary" id="autoConnectIcon" />
|
||||
<FontAwesomeIcon icon={checkIcon} className="tw:text-brand" id="autoConnectIcon" />
|
||||
<UncontrolledTooltip target="autoConnectIcon" placement="right">
|
||||
Auto-connect to this server
|
||||
</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>
|
||||
</th>
|
||||
<td className="responsive-table__cell" data-th="Base URL">{server.url}</td>
|
||||
<td className="responsive-table__cell text-end">
|
||||
</Table.Cell>
|
||||
<Table.Cell columnName="Base URL" className="tw:max-lg:border-b-0">{server.url}</Table.Cell>
|
||||
<Table.Cell className="tw:text-right tw:max-lg:absolute tw:right-0 tw:-top-1 tw:mx-lg:pt-0">
|
||||
<ManageServersRowDropdown server={server} />
|
||||
</td>
|
||||
</tr>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { RowDropdownBtn, useToggle } from '@shlinkio/shlink-frontend-kit';
|
||||
import type { FC } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Link } from 'react-router';
|
||||
import { DropdownItem } from 'reactstrap';
|
||||
import type { FCWithDeps } from '../container/utils';
|
||||
import { componentFactory, useDependencies } from '../container/utils';
|
||||
@@ -37,23 +37,25 @@ const ManageServersRowDropdown: FCWithDeps<ManageServersRowDropdownConnectProps,
|
||||
const autoConnectIcon = isAutoConnect ? toggleOffIcon : toggleOnIcon;
|
||||
|
||||
return (
|
||||
<RowDropdownBtn minWidth={isAutoConnect ? 210 : 170}>
|
||||
<DropdownItem tag={Link} to={serverUrl}>
|
||||
<FontAwesomeIcon icon={connectIcon} fixedWidth /> Connect
|
||||
</DropdownItem>
|
||||
<DropdownItem tag={Link} to={`${serverUrl}/edit`}>
|
||||
<FontAwesomeIcon icon={editIcon} fixedWidth /> Edit server
|
||||
</DropdownItem>
|
||||
<DropdownItem onClick={() => setAutoConnect(server, !isAutoConnect)}>
|
||||
<FontAwesomeIcon icon={autoConnectIcon} fixedWidth /> {isAutoConnect ? 'Do not a' : 'A'}uto-connect
|
||||
</DropdownItem>
|
||||
<DropdownItem divider tag="hr" />
|
||||
<DropdownItem className="dropdown-item--danger" onClick={showModal}>
|
||||
<FontAwesomeIcon icon={deleteIcon} fixedWidth /> Remove server
|
||||
</DropdownItem>
|
||||
<>
|
||||
<RowDropdownBtn minWidth={isAutoConnect ? 210 : 170}>
|
||||
<DropdownItem tag={Link} to={serverUrl}>
|
||||
<FontAwesomeIcon icon={connectIcon} fixedWidth /> Connect
|
||||
</DropdownItem>
|
||||
<DropdownItem tag={Link} to={`${serverUrl}/edit`}>
|
||||
<FontAwesomeIcon icon={editIcon} fixedWidth /> Edit server
|
||||
</DropdownItem>
|
||||
<DropdownItem onClick={() => setAutoConnect(server, !isAutoConnect)}>
|
||||
<FontAwesomeIcon icon={autoConnectIcon} fixedWidth /> {isAutoConnect ? 'Do not a' : 'A'}uto-connect
|
||||
</DropdownItem>
|
||||
<DropdownItem divider tag="hr" />
|
||||
<DropdownItem className="tw:text-danger" onClick={showModal}>
|
||||
<FontAwesomeIcon icon={deleteIcon} fixedWidth /> Remove server
|
||||
</DropdownItem>
|
||||
</RowDropdownBtn>
|
||||
|
||||
<DeleteServerModal redirectHome={false} server={server} isOpen={isModalOpen} toggle={hideModal} />
|
||||
</RowDropdownBtn>
|
||||
<DeleteServerModal server={server} open={isModalOpen} onClose={hideModal} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { faPlus as plusIcon, faServer as serverIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
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 type { SelectedServer, ServersMap } from './data';
|
||||
import { getServerId } from './data';
|
||||
@@ -13,36 +13,30 @@ export interface ServersDropdownProps {
|
||||
export const ServersDropdown = ({ servers, selectedServer }: ServersDropdownProps) => {
|
||||
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 (
|
||||
<UncontrolledDropdown nav inNavbar>
|
||||
<DropdownToggle nav caret>
|
||||
<FontAwesomeIcon icon={serverIcon} /> <span className="ms-1">Servers</span>
|
||||
<FontAwesomeIcon icon={serverIcon} /> <span className="tw:ml-1">Servers</span>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { clsx } from 'clsx';
|
||||
import type { FC, PropsWithChildren } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ListGroup, ListGroupItem } from 'reactstrap';
|
||||
import type { FC } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import type { ServerWithId } from './data';
|
||||
import './ServersListGroup.scss';
|
||||
|
||||
type ServersListGroupProps = PropsWithChildren<{
|
||||
type ServersListGroupProps = {
|
||||
servers: ServerWithId[];
|
||||
embedded?: boolean;
|
||||
}>;
|
||||
borderless?: boolean;
|
||||
};
|
||||
|
||||
const ServerListItem = ({ id, name }: { id: string; name: string }) => (
|
||||
<ListGroupItem tag={Link} to={`/server/${id}`} className="servers-list__server-item">
|
||||
{name}
|
||||
<FontAwesomeIcon icon={chevronIcon} className="servers-list__server-item-icon" />
|
||||
</ListGroupItem>
|
||||
<Link
|
||||
to={`/server/${id}`}
|
||||
className={clsx(
|
||||
'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 && (
|
||||
<ListGroup
|
||||
<div
|
||||
data-testid="list"
|
||||
tag="div"
|
||||
className={clsx('servers-list__list-group', { 'servers-list__list-group--embedded': embedded })}
|
||||
className={clsx(
|
||||
'tw:w-full tw:border-lm-border tw:dark:border-dm-border',
|
||||
'tw:md:max-h-56 tw:md:overflow-y-auto tw:-mb-1 tw:scroll-thin',
|
||||
{ 'tw:border-y': !borderless },
|
||||
)}
|
||||
>
|
||||
{servers.map(({ id, name }) => <ServerListItem key={id} id={id} name={name} />)}
|
||||
</ListGroup>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ export interface ServerData {
|
||||
name: string;
|
||||
url: string;
|
||||
apiKey: string;
|
||||
forwardCredentials?: boolean;
|
||||
}
|
||||
|
||||
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 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 { Fragment } from 'react';
|
||||
import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
||||
import type { ServerData } from '../data';
|
||||
|
||||
interface DuplicatedServersModalProps {
|
||||
export type DuplicatedServersModalProps = {
|
||||
duplicatedServers: ServerData[];
|
||||
isOpen: boolean;
|
||||
onDiscard: () => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
};
|
||||
|
||||
export const DuplicatedServersModal: FC<DuplicatedServersModalProps> = (
|
||||
{ isOpen, duplicatedServers, onDiscard, onSave },
|
||||
{ open, duplicatedServers, onClose, onConfirm },
|
||||
) => {
|
||||
const hasMultipleServers = duplicatedServers.length > 1;
|
||||
|
||||
return (
|
||||
<Modal centered isOpen={isOpen}>
|
||||
<ModalHeader>Duplicated server{hasMultipleServers && 's'}</ModalHeader>
|
||||
<ModalBody>
|
||||
<p>{hasMultipleServers ? 'The next servers already exist:' : 'There is already a server with:'}</p>
|
||||
<ul>
|
||||
{duplicatedServers.map(({ url, apiKey }, index) => (!hasMultipleServers ? (
|
||||
<Fragment key={index}>
|
||||
<li>URL: <b>{url}</b></li>
|
||||
<li>API key: <b>{apiKey}</b></li>
|
||||
</Fragment>
|
||||
) : <li key={index}><b>{url}</b> - <b>{apiKey}</b></li>))}
|
||||
</ul>
|
||||
<span>
|
||||
{hasMultipleServers ? 'Do you want to ignore duplicated servers' : 'Do you want to save this server anyway'}?
|
||||
</span>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color="link" onClick={onDiscard}>{hasMultipleServers ? 'Ignore duplicates' : 'Discard'}</Button>
|
||||
<Button color="primary" onClick={onSave}>Save anyway</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
<CardModal
|
||||
size="lg"
|
||||
title={`Duplicated server${hasMultipleServers ? 's' : ''}`}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
onConfirm={onConfirm}
|
||||
confirmText={`Save duplicate${hasMultipleServers ? 's' : ''}`}
|
||||
cancelText={hasMultipleServers ? 'Ignore duplicates' : 'Discard'}
|
||||
>
|
||||
<p>{hasMultipleServers ? 'The next servers already exist:' : 'There is already a server with:'}</p>
|
||||
<ul className="tw:list-disc tw:mt-4">
|
||||
{duplicatedServers.map(({ url, apiKey }, index) => (!hasMultipleServers ? (
|
||||
<Fragment key={index}>
|
||||
<li>URL: <b>{url}</b></li>
|
||||
<li>API key: <b>{apiKey}</b></li>
|
||||
</Fragment>
|
||||
) : <li key={index}><b>{url}</b> - <b>{apiKey}</b></li>))}
|
||||
</ul>
|
||||
<span>
|
||||
{hasMultipleServers ? 'Do you want to save duplicated servers' : 'Do you want to save this server'}?
|
||||
</span>
|
||||
</CardModal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { faFileUpload as importIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { useElementRef, useToggle } from '@shlinkio/shlink-frontend-kit';
|
||||
import { Button } from '@shlinkio/shlink-frontend-kit/tailwind';
|
||||
import type { ChangeEvent, PropsWithChildren } from 'react';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { Button, UncontrolledTooltip } from 'reactstrap';
|
||||
import { useCallback, useRef , useState } from 'react';
|
||||
import { UncontrolledTooltip } from 'reactstrap';
|
||||
import type { FCWithDeps } from '../../container/utils';
|
||||
import { componentFactory, useDependencies } from '../../container/utils';
|
||||
import type { ServerData, ServersMap, ServerWithId } from '../data';
|
||||
@@ -13,7 +14,7 @@ import { dedupServers, ensureUniqueIds } from './index';
|
||||
|
||||
export type ImportServersBtnProps = PropsWithChildren<{
|
||||
onImport?: () => void;
|
||||
onImportError?: (error: Error) => void;
|
||||
onError?: (error: Error) => void;
|
||||
tooltipPlacement?: 'top' | 'bottom';
|
||||
className?: string;
|
||||
}>;
|
||||
@@ -31,8 +32,8 @@ const ImportServersBtn: FCWithDeps<ImportServersBtnConnectProps, ImportServersBt
|
||||
createServers,
|
||||
servers,
|
||||
children,
|
||||
onImport = () => {},
|
||||
onImportError = () => {},
|
||||
onImport,
|
||||
onError = () => {},
|
||||
tooltipPlacement = 'bottom',
|
||||
className = '',
|
||||
}) => {
|
||||
@@ -40,50 +41,50 @@ const ImportServersBtn: FCWithDeps<ImportServersBtnConnectProps, ImportServersBt
|
||||
const ref = useElementRef<HTMLInputElement>();
|
||||
const [duplicatedServers, setDuplicatedServers] = useState<ServerData[]>([]);
|
||||
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(
|
||||
async ({ target }: ChangeEvent<HTMLInputElement>) =>
|
||||
serversImporter.importServersFromFile(target.files?.[0])
|
||||
.then((importedServers) => {
|
||||
const { duplicatedServers, newServers } = dedupServers(servers, importedServers);
|
||||
|
||||
importedServersRef.current = ensureUniqueIds(servers, importedServers);
|
||||
newServersRef.current = ensureUniqueIds(servers, newServers);
|
||||
// Immediately create new servers
|
||||
newServersCreatedRef.current = newServers.length > 0;
|
||||
createServers(ensureUniqueIds(servers, newServers));
|
||||
|
||||
if (duplicatedServers.length === 0) {
|
||||
create(importedServersRef.current);
|
||||
} else {
|
||||
// For duplicated servers, ask for confirmation
|
||||
if (duplicatedServers.length > 0) {
|
||||
setDuplicatedServers(duplicatedServers);
|
||||
showModal();
|
||||
} else {
|
||||
onImport?.();
|
||||
}
|
||||
})
|
||||
.then(() => {
|
||||
// Reset file input after processing file
|
||||
(target as { value: string | null }).value = null;
|
||||
})
|
||||
.catch(onImportError),
|
||||
[create, onImportError, servers, serversImporter, showModal],
|
||||
.catch(onError),
|
||||
[createServers, onError, onImport, servers, serversImporter, showModal],
|
||||
);
|
||||
|
||||
const createAllServers = useCallback(() => {
|
||||
create(importedServersRef.current);
|
||||
const createDuplicatedServers = useCallback(() => {
|
||||
createServers(ensureUniqueIds(servers, duplicatedServers));
|
||||
hideModal();
|
||||
}, [create, hideModal]);
|
||||
const createNonDuplicatedServers = useCallback(() => {
|
||||
create(newServersRef.current);
|
||||
onImport?.();
|
||||
}, [createServers, duplicatedServers, hideModal, onImport, servers]);
|
||||
const discardDuplicatedServers = useCallback(() => {
|
||||
hideModal();
|
||||
}, [create, hideModal]);
|
||||
// If duplicated servers were discarded but some non-duplicated servers were created, call onImport
|
||||
if (newServersCreatedRef.current) {
|
||||
onImport?.();
|
||||
}
|
||||
}, [hideModal, onImport]);
|
||||
|
||||
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'}
|
||||
</Button>
|
||||
<UncontrolledTooltip placement={tooltipPlacement} target="importBtn">
|
||||
@@ -93,18 +94,19 @@ const ImportServersBtn: FCWithDeps<ImportServersBtnConnectProps, ImportServersBt
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv"
|
||||
className="d-none"
|
||||
className="tw:hidden"
|
||||
aria-hidden
|
||||
ref={ref}
|
||||
tabIndex={-1}
|
||||
ref={ref as any /* TODO Remove After updating to React 19 */}
|
||||
onChange={onFile}
|
||||
data-testid="csv-file-input"
|
||||
/>
|
||||
|
||||
<DuplicatedServersModal
|
||||
isOpen={isModalOpen}
|
||||
open={isModalOpen}
|
||||
duplicatedServers={duplicatedServers}
|
||||
onDiscard={createNonDuplicatedServers}
|
||||
onSave={createAllServers}
|
||||
onClose={discardDuplicatedServers}
|
||||
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 { Link } from 'react-router-dom';
|
||||
import { Link } from 'react-router';
|
||||
import { NoMenuLayout } from '../../common/NoMenuLayout';
|
||||
import type { FCWithDeps } from '../../container/utils';
|
||||
import { componentFactory, useDependencies } from '../../container/utils';
|
||||
@@ -8,7 +8,6 @@ import type { SelectedServer, ServersMap } from '../data';
|
||||
import { isServerWithId } from '../data';
|
||||
import type { DeleteServerButtonProps } from '../DeleteServerButton';
|
||||
import { ServersListGroup } from '../ServersListGroup';
|
||||
import './ServerError.scss';
|
||||
|
||||
type ServerErrorProps = {
|
||||
servers: ServersMap;
|
||||
@@ -24,8 +23,8 @@ const ServerError: FCWithDeps<ServerErrorProps, ServerErrorDeps> = ({ servers, s
|
||||
|
||||
return (
|
||||
<NoMenuLayout>
|
||||
<div className="server-error__container flex-column">
|
||||
<Message className="w-100 mb-3 mb-md-5" type="error" fullWidth>
|
||||
<div className="tw:flex tw:flex-col tw:items-center tw:gap-y-4 tw:md:gap-y-8">
|
||||
<Message className="tw:w-full tw:lg:w-[80%]" variant="error">
|
||||
{!isServerWithId(selectedServer) && 'Could not find this Shlink server.'}
|
||||
{isServerWithId(selectedServer) && (
|
||||
<>
|
||||
@@ -35,21 +34,21 @@ const ServerError: FCWithDeps<ServerErrorProps, ServerErrorDeps> = ({ servers, s
|
||||
)}
|
||||
</Message>
|
||||
|
||||
<ServersListGroup servers={Object.values(servers)}>
|
||||
<p className="mb-md-3">
|
||||
These are the Shlink servers currently configured. Choose one of
|
||||
them or <Link to="/server/create">add a new one</Link>.
|
||||
</p>
|
||||
</ServersListGroup>
|
||||
<p className="tw:text-xl">
|
||||
These are the Shlink servers currently configured. Choose one of
|
||||
them or <Link to="/server/create">add a new one</Link>.
|
||||
</p>
|
||||
<Card className="tw:w-full tw:max-w-100 tw:overflow-hidden">
|
||||
<ServersListGroup borderless servers={Object.values(servers)} />
|
||||
</Card>
|
||||
|
||||
{isServerWithId(selectedServer) && (
|
||||
<div className="container mt-3 mt-md-5">
|
||||
<p className="fs-5 fw-normal lh-sm">
|
||||
Alternatively, if you think you may have miss-configured this server, you
|
||||
can <DeleteServerButton server={selectedServer} className="server-error__delete-btn">remove it</DeleteServerButton> or
|
||||
<Link to={`/server/${selectedServer.id}/edit?reconnect=true`}>edit it</Link>.
|
||||
</p>
|
||||
</div>
|
||||
<p className="tw:text-xl">
|
||||
Alternatively, if you think you may have misconfigured this server, you
|
||||
can <DeleteServerButton server={selectedServer}>remove
|
||||
it</DeleteServerButton> or
|
||||
<Link to={`/server/${selectedServer.id}/edit?reconnect=true`}>edit it</Link>.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</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 { useState } from 'react';
|
||||
import { handleEventPreventingDefault } from '../../utils/utils';
|
||||
import { usePreventDefault } from '../../utils/utils';
|
||||
import type { ServerData } from '../data';
|
||||
|
||||
type ServerFormProps = PropsWithChildren<{
|
||||
@@ -14,17 +22,43 @@ export const ServerForm: FC<ServerFormProps> = ({ onSubmit, initialValues, child
|
||||
const [name, setName] = useState(initialValues?.name ?? '');
|
||||
const [url, setUrl] = useState(initialValues?.url ?? '');
|
||||
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 (
|
||||
<form className="server-form" name="serverForm" onSubmit={handleSubmit}>
|
||||
<SimpleCard className="mb-3" title={title}>
|
||||
<InputFormGroup value={name} onChange={setName}>Name</InputFormGroup>
|
||||
<InputFormGroup type="url" value={url} onChange={setUrl}>URL</InputFormGroup>
|
||||
<InputFormGroup value={apiKey} onChange={setApiKey}>API key</InputFormGroup>
|
||||
<form name="serverForm" onSubmit={handleSubmit}>
|
||||
<SimpleCard className="tw:mb-4" bodyClassName="tw:flex tw:flex-col tw:gap-y-3" title={title}>
|
||||
<LabelledInput label="Name" value={name} onChange={(e) => setName(e.target.value)} required />
|
||||
<LabelledInput label="URL" type="url" value={url} onChange={(e) => setUrl(e.target.value)} required />
|
||||
<LabelledRevealablePasswordInput
|
||||
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>
|
||||
|
||||
<div className="text-end">{children}</div>
|
||||
<div className="tw:flex tw:items-center tw:justify-end tw:gap-x-2">{children}</div>
|
||||
</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 { useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useParams } from 'react-router';
|
||||
import { NoMenuLayout } from '../../common/NoMenuLayout';
|
||||
import type { FCWithDeps } 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 type { LocalStorage } from '../../utils/services/LocalStorage';
|
||||
import type { ServersMap } from '../data';
|
||||
import { serverWithIdToServerData } from '../data';
|
||||
import { serializeServer } from '../data';
|
||||
|
||||
const SERVERS_FILENAME = 'shlink-servers.csv';
|
||||
|
||||
export class ServersExporter {
|
||||
public constructor(
|
||||
private readonly storage: LocalStorage,
|
||||
private readonly window: Window,
|
||||
private readonly jsonToCsv: JsonToCsv,
|
||||
) {}
|
||||
readonly #storage: LocalStorage;
|
||||
readonly #window: Window;
|
||||
readonly #jsonToCsv: JsonToCsv;
|
||||
|
||||
public constructor(storage: LocalStorage, window: Window, jsonToCsv: JsonToCsv) {
|
||||
this.#storage = storage;
|
||||
this.#window = window;
|
||||
this.#jsonToCsv = jsonToCsv;
|
||||
}
|
||||
|
||||
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 {
|
||||
const csv = this.jsonToCsv(servers);
|
||||
|
||||
saveCsv(this.window, csv, SERVERS_FILENAME);
|
||||
const csv = this.#jsonToCsv(servers);
|
||||
saveCsv(this.#window, csv, SERVERS_FILENAME);
|
||||
} catch (e) {
|
||||
// FIXME Handle error
|
||||
console.error(e);
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import type { CsvToJson } from '../../utils/helpers/csvjson';
|
||||
import type { ServerData } from '../data';
|
||||
import { deserializeServer } from '../data';
|
||||
|
||||
const validateServer = (server: any): server is ServerData =>
|
||||
typeof server.url === 'string' && typeof server.apiKey === 'string' && typeof server.name === 'string';
|
||||
|
||||
const validateServers = (servers: any): servers is ServerData[] =>
|
||||
Array.isArray(servers) && servers.every(validateServer);
|
||||
const validateAndDeserializeServers = (servers: unknown): ServerData[] => {
|
||||
if (!Array.isArray(servers)) {
|
||||
throw new Error('Provided file does not have the right format.');
|
||||
}
|
||||
return servers.map(deserializeServer);
|
||||
};
|
||||
|
||||
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[]> {
|
||||
if (!file) {
|
||||
@@ -16,12 +22,8 @@ export class ServersImporter {
|
||||
}
|
||||
|
||||
const content = await file.text();
|
||||
const servers = await this.csvToJson(content);
|
||||
const servers = await this.#csvToJson(content);
|
||||
|
||||
if (!validateServers(servers)) {
|
||||
throw new Error('Provided file does not have the right format.');
|
||||
}
|
||||
|
||||
return servers;
|
||||
return validateAndDeserializeServers(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 { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
export const useGoBack = () => {
|
||||
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 { useCallback } from 'react';
|
||||
|
||||
export const handleEventPreventingDefault = <T>(handler: () => T) => (e: SyntheticEvent) => {
|
||||
e.preventDefault();
|
||||
handler();
|
||||
};
|
||||
/**
|
||||
* Wraps an event handler so that it calls e.preventDefault() before invoking the event 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 { useMemo } from 'react';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router';
|
||||
|
||||
export type MemoryRouterWithParamsProps = PropsWithChildren<{
|
||||
params: Record<string, string>;
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
import { useToggle } from '@shlinkio/shlink-frontend-kit';
|
||||
import type { FC, ReactElement } from 'react';
|
||||
import { useCallback, useEffect , useState } from 'react';
|
||||
|
||||
interface RenderModalArgs {
|
||||
isOpen: boolean;
|
||||
toggle: () => void;
|
||||
}
|
||||
export type RenderModalArgs = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export const TestModalWrapper: FC<{ renderModal: (args: RenderModalArgs) => ReactElement }> = (
|
||||
{ renderModal },
|
||||
) => {
|
||||
const [isOpen, toggle] = useToggle(true);
|
||||
return renderModal({ isOpen, toggle });
|
||||
const [open, setOpen] = useState(true);
|
||||
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 { buildShlinkApiClient } from '../../../src/api/services/ShlinkApiClientBuilder';
|
||||
import type { ReachableServer, SelectedServer } from '../../../src/servers/data';
|
||||
@@ -5,8 +6,8 @@ import type { ReachableServer, SelectedServer } from '../../../src/servers/data'
|
||||
describe('ShlinkApiClientBuilder', () => {
|
||||
const server = fromPartial<ReachableServer>;
|
||||
|
||||
const createBuilder = () => {
|
||||
const builder = buildShlinkApiClient(fromPartial({}));
|
||||
const createBuilder = (httpClient: HttpClient = fromPartial({})) => {
|
||||
const builder = buildShlinkApiClient(httpClient);
|
||||
return (selectedServer: SelectedServer) => builder(() => fromPartial({ selectedServer }));
|
||||
};
|
||||
|
||||
@@ -16,9 +17,9 @@ describe('ShlinkApiClientBuilder', () => {
|
||||
const secondApiClient = builder(server({ url: 'bar', apiKey: 'bar' }));
|
||||
const thirdApiClient = builder(server({ url: 'bar', apiKey: 'foo' }));
|
||||
|
||||
expect(firstApiClient === secondApiClient).toEqual(false);
|
||||
expect(firstApiClient === thirdApiClient).toEqual(false);
|
||||
expect(secondApiClient === thirdApiClient).toEqual(false);
|
||||
expect(firstApiClient).not.toBe(secondApiClient);
|
||||
expect(firstApiClient).not.toBe(thirdApiClient);
|
||||
expect(secondApiClient).not.toBe(thirdApiClient);
|
||||
});
|
||||
|
||||
it('returns existing instances when provided params are the same', () => {
|
||||
@@ -29,17 +30,42 @@ describe('ShlinkApiClientBuilder', () => {
|
||||
const secondApiClient = builder(selectedServer);
|
||||
const thirdApiClient = builder(selectedServer);
|
||||
|
||||
expect(firstApiClient === secondApiClient).toEqual(true);
|
||||
expect(firstApiClient === thirdApiClient).toEqual(true);
|
||||
expect(secondApiClient === thirdApiClient).toEqual(true);
|
||||
expect(firstApiClient).toBe(secondApiClient);
|
||||
expect(firstApiClient).toBe(thirdApiClient);
|
||||
expect(secondApiClient).toBe(thirdApiClient);
|
||||
});
|
||||
|
||||
it('does not fetch from state when provided param is already selected server', () => {
|
||||
const url = 'url';
|
||||
const apiKey = 'apiKey';
|
||||
const apiClient = buildShlinkApiClient(fromPartial({}))(server({ url, apiKey }));
|
||||
it('does not fetch from state when provided param is already a server', 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 }));
|
||||
|
||||
expect(apiClient['serverInfo'].baseUrl).toEqual(url);
|
||||
expect(apiClient['serverInfo'].apiKey).toEqual(apiKey);
|
||||
await apiClient.health();
|
||||
|
||||
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 { fromPartial } from '@total-typescript/shoehorn';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
import { AppFactory } from '../../src/app/App';
|
||||
import { checkAccessibility } from '../__helpers__/accessibility';
|
||||
|
||||
@@ -54,13 +54,17 @@ describe('<App />', () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
['/foo', 'shlink-wrapper'],
|
||||
['/bar', 'shlink-wrapper'],
|
||||
['/', 'shlink-wrapper d-flex align-items-center pt-3'],
|
||||
])('renders expected classes on shlink-wrapper based on current pathname', async (pathname, expectedClasses) => {
|
||||
const { container } = await setUp(pathname);
|
||||
const shlinkWrapper = container.querySelector('.shlink-wrapper');
|
||||
['/foo', false],
|
||||
['/bar', false],
|
||||
['/', true],
|
||||
])('renders expected classes on shlink-wrapper based on current pathname', async (pathname, isFlex) => {
|
||||
await setUp(pathname);
|
||||
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';
|
||||
|
||||
describe('<AppUpdateBanner />', () => {
|
||||
const toggle = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
const forceUpdate = vi.fn();
|
||||
const setUp = async () => {
|
||||
const result = await act(
|
||||
() => renderWithEvents(<AppUpdateBanner isOpen toggle={toggle} forceUpdate={forceUpdate} />),
|
||||
() => renderWithEvents(<AppUpdateBanner isOpen onClose={onClose} forceUpdate={forceUpdate} />),
|
||||
);
|
||||
await waitFor(() => screen.getByRole('alert'));
|
||||
|
||||
@@ -28,9 +28,9 @@ describe('<AppUpdateBanner />', () => {
|
||||
it('invokes toggle when alert is closed', async () => {
|
||||
const { user } = await setUp();
|
||||
|
||||
expect(toggle).not.toHaveBeenCalled();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
await user.click(screen.getByLabelText('Close'));
|
||||
expect(toggle).toHaveBeenCalled();
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('triggers the update when clicking the button', async () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import { fromPartial } from '@total-typescript/shoehorn';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import { Router } from 'react-router-dom';
|
||||
import { Router } from 'react-router';
|
||||
import { MainHeaderFactory } from '../../src/common/MainHeader';
|
||||
import { checkAccessibility } from '../__helpers__/accessibility';
|
||||
import { renderWithEvents } from '../__helpers__/setUpTest';
|
||||
@@ -51,14 +51,12 @@ describe('<MainHeader />', () => {
|
||||
const toggle = screen.getByLabelText('Toggle navigation');
|
||||
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);
|
||||
expect(icon).toHaveAttribute(
|
||||
'class',
|
||||
expect.stringMatching(/main-header__toggle-icon main-header__toggle-icon--opened$/),
|
||||
);
|
||||
|
||||
expect(icon).toHaveClass('tw:rotate-180');
|
||||
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 () => {
|
||||
|
||||
@@ -30,6 +30,5 @@ describe('<NotFound />', () => {
|
||||
|
||||
expect(link).toHaveAttribute('href', expectedLink);
|
||||
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)));
|
||||
|
||||
it.each([
|
||||
[null, 'text-center'],
|
||||
[fromPartial<SelectedServer>({}), 'text-center'],
|
||||
[fromPartial<ReachableServer>({ version: '1.0.0' }), 'text-center shlink-versions-container--with-sidebar'],
|
||||
])('renders proper col classes based on sidebar status', (selectedServer, expectedClasses) => {
|
||||
[null, false],
|
||||
[fromPartial<SelectedServer>({}), false],
|
||||
[fromPartial<ReachableServer>({ version: '1.0.0' }), true],
|
||||
])('renders proper col classes based on sidebar status', (selectedServer, shouldAddMargin) => {
|
||||
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 { fromPartial } from '@total-typescript/shoehorn';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import { Router } from 'react-router-dom';
|
||||
import { Router } from 'react-router';
|
||||
import { CreateServerFactory } from '../../src/servers/CreateServer';
|
||||
import type { ServersMap } from '../../src/servers/data';
|
||||
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 { createMemoryHistory } from 'history';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Router } from 'react-router';
|
||||
import { DeleteServerButtonFactory } from '../../src/servers/DeleteServerButton';
|
||||
import type { DeleteServerModalProps } from '../../src/servers/DeleteServerModal';
|
||||
import { DeleteServerModal } from '../../src/servers/DeleteServerModal';
|
||||
import { checkAccessibility } from '../__helpers__/accessibility';
|
||||
import { renderWithEvents } from '../__helpers__/setUpTest';
|
||||
|
||||
describe('<DeleteServerButton />', () => {
|
||||
const DeleteServerButton = DeleteServerButtonFactory(fromPartial({
|
||||
DeleteServerModal: ({ isOpen }: DeleteServerModalProps) => <>DeleteServerModal {isOpen ? '[Open]' : '[Closed]'}</>,
|
||||
DeleteServerModal: (props: DeleteServerModalProps) => <DeleteServerModal {...props} deleteServer={vi.fn()} />,
|
||||
}));
|
||||
const setUp = (children?: ReactNode) => renderWithEvents(
|
||||
<DeleteServerButton server={fromPartial({})} textClassName="button">{children}</DeleteServerButton>,
|
||||
);
|
||||
const setUp = (children: ReactNode = 'Remove this server') => {
|
||||
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')));
|
||||
|
||||
@@ -20,7 +30,6 @@ describe('<DeleteServerButton />', () => {
|
||||
['Foo bar'],
|
||||
['baz'],
|
||||
['something'],
|
||||
[undefined],
|
||||
])('renders expected content', (children) => {
|
||||
const { container } = setUp(children);
|
||||
expect(container.firstChild).toBeTruthy();
|
||||
@@ -28,14 +37,21 @@ describe('<DeleteServerButton />', () => {
|
||||
});
|
||||
|
||||
it('displays modal when button is clicked', async () => {
|
||||
const { user, container } = setUp();
|
||||
const { user } = setUp();
|
||||
|
||||
expect(screen.getByText(/DeleteServerModal/)).toHaveTextContent(/Closed/);
|
||||
expect(screen.getByText(/DeleteServerModal/)).not.toHaveTextContent(/Open/);
|
||||
if (container.firstElementChild) {
|
||||
await user.click(container.firstElementChild);
|
||||
}
|
||||
expect(screen.queryByText(/Are you sure you want to remove/)).not.toBeInTheDocument();
|
||||
await user.click(screen.getByText('Remove this server'));
|
||||
expect(screen.getByText(/Are you sure you want to remove/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
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 { createMemoryHistory } from 'history';
|
||||
import { Router } from 'react-router-dom';
|
||||
import { DeleteServerModal } from '../../src/servers/DeleteServerModal';
|
||||
import { checkAccessibility } from '../__helpers__/accessibility';
|
||||
import { renderWithEvents } from '../__helpers__/setUpTest';
|
||||
@@ -10,36 +8,29 @@ import { TestModalWrapper } from '../__helpers__/TestModalWrapper';
|
||||
describe('<DeleteServerModal />', () => {
|
||||
const deleteServerMock = vi.fn();
|
||||
const serverName = 'the_server_name';
|
||||
const setUp = async () => {
|
||||
const history = createMemoryHistory({ initialEntries: ['/foo'] });
|
||||
const result = await act(() => renderWithEvents(
|
||||
<Router location={history.location} navigator={history}>
|
||||
<TestModalWrapper
|
||||
renderModal={(args) => (
|
||||
<DeleteServerModal
|
||||
{...args}
|
||||
server={fromPartial({ name: serverName })}
|
||||
deleteServer={deleteServerMock}
|
||||
/>
|
||||
)}
|
||||
const setUp = () => renderWithEvents(
|
||||
<TestModalWrapper
|
||||
renderModal={(args) => (
|
||||
<DeleteServerModal
|
||||
{...args}
|
||||
server={fromPartial({ name: serverName })}
|
||||
deleteServer={deleteServerMock}
|
||||
/>
|
||||
</Router>,
|
||||
));
|
||||
|
||||
return { history, ...result };
|
||||
};
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
it('passes a11y checks', () => checkAccessibility(setUp()));
|
||||
|
||||
it('renders a modal window', async () => {
|
||||
await setUp();
|
||||
it('renders a modal window', () => {
|
||||
setUp();
|
||||
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading')).toHaveTextContent('Remove server');
|
||||
});
|
||||
|
||||
it('displays the name of the server as part of the content', async () => {
|
||||
await setUp();
|
||||
it('displays the name of the server as part of the content', () => {
|
||||
setUp();
|
||||
|
||||
expect(screen.getByText(/^Are you sure you want to remove/)).toBeInTheDocument();
|
||||
expect(screen.getByText(serverName)).toBeInTheDocument();
|
||||
@@ -47,25 +38,21 @@ describe('<DeleteServerModal />', () => {
|
||||
|
||||
it.each([
|
||||
[() => screen.getByRole('button', { name: 'Cancel' })],
|
||||
[() => screen.getByLabelText('Close')],
|
||||
])('toggles when clicking cancel button', async (getButton) => {
|
||||
const { user, history } = await setUp();
|
||||
[() => screen.getByLabelText('Close dialog')],
|
||||
])('closes dialog when clicking cancel button', async (getButton) => {
|
||||
const { user } = setUp();
|
||||
|
||||
expect(history.location.pathname).toEqual('/foo');
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
await user.click(getButton());
|
||||
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
expect(deleteServerMock).not.toHaveBeenCalled();
|
||||
expect(history.location.pathname).toEqual('/foo'); // No navigation happens, keeping initial pathname
|
||||
});
|
||||
|
||||
it('deletes server when clicking accept button', async () => {
|
||||
const { user, history } = await setUp();
|
||||
const { user } = setUp();
|
||||
|
||||
expect(deleteServerMock).not.toHaveBeenCalled();
|
||||
expect(history.location.pathname).toEqual('/foo');
|
||||
await user.click(screen.getByRole('button', { name: 'Delete' }));
|
||||
|
||||
await waitFor(() => expect(deleteServerMock).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => expect(history.location.pathname).toEqual('/'));
|
||||
expect(deleteServerMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { fireEvent, screen } from '@testing-library/react';
|
||||
import { fromPartial } from '@total-typescript/shoehorn';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import { Router } from 'react-router-dom';
|
||||
import { Router } from 'react-router';
|
||||
import type { ReachableServer, SelectedServer } from '../../src/servers/data';
|
||||
import { EditServerFactory } from '../../src/servers/EditServer';
|
||||
import { checkAccessibility } from '../__helpers__/accessibility';
|
||||
@@ -47,16 +47,16 @@ describe('<EditServer />', () => {
|
||||
it('display the server info in the form components', () => {
|
||||
setUp();
|
||||
|
||||
expect(screen.getByDisplayValue('the_name')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('the_url')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('the_api_key')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/^Name/)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/^URL/)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/^API key/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('edits server and redirects to it when form is submitted', async () => {
|
||||
const { user, history } = setUp();
|
||||
|
||||
await user.type(screen.getByDisplayValue('the_name'), ' edited');
|
||||
await user.type(screen.getByDisplayValue('the_url'), ' edited');
|
||||
await user.type(screen.getByLabelText(/^Name/), ' edited');
|
||||
await user.type(screen.getByLabelText(/^URL/), ' edited');
|
||||
// TODO Using fire event because userEvent.click on the Submit button does not submit the form
|
||||
// await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
fireEvent.submit(screen.getByRole('form'));
|
||||
@@ -65,9 +65,26 @@ describe('<EditServer />', () => {
|
||||
name: 'the_name edited',
|
||||
url: 'the_url edited',
|
||||
apiKey: 'the_api_key',
|
||||
forwardCredentials: false,
|
||||
});
|
||||
|
||||
// After saving we go back, to the first route from history's initialEntries
|
||||
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 { 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 { ManageServersFactory } from '../../src/servers/ManageServers';
|
||||
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 { fromPartial } from '@total-typescript/shoehorn';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
import type { ServerWithId } from '../../src/servers/data';
|
||||
import { ManageServersRowFactory } from '../../src/servers/ManageServersRow';
|
||||
import { checkAccessibility } from '../__helpers__/accessibility';
|
||||
@@ -17,11 +18,9 @@ describe('<ManageServersRow />', () => {
|
||||
};
|
||||
const setUp = (hasAutoConnect = false, autoConnect = false) => render(
|
||||
<MemoryRouter>
|
||||
<table>
|
||||
<tbody>
|
||||
<ManageServersRow server={{ ...server, autoConnect }} hasAutoConnect={hasAutoConnect} />
|
||||
</tbody>
|
||||
</table>
|
||||
<Table header={<Table.Row />}>
|
||||
<ManageServersRow server={{ ...server, autoConnect }} hasAutoConnect={hasAutoConnect} />
|
||||
</Table>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
@@ -32,11 +31,7 @@ describe('<ManageServersRow />', () => {
|
||||
[false, 3],
|
||||
])('renders expected amount of columns', (hasAutoConnect, expectedCols) => {
|
||||
setUp(hasAutoConnect);
|
||||
|
||||
const td = screen.getAllByRole('cell');
|
||||
const th = screen.getAllByRole('columnheader');
|
||||
|
||||
expect(td.length + th.length).toEqual(expectedCols);
|
||||
expect(screen.getAllByRole('cell')).toHaveLength(expectedCols);
|
||||
});
|
||||
|
||||
it('renders a dropdown', () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { screen } from '@testing-library/react';
|
||||
import type { UserEvent } from '@testing-library/user-event';
|
||||
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 { ManageServersRowDropdownFactory } from '../../src/servers/ManageServersRowDropdown';
|
||||
import { checkAccessibility } from '../__helpers__/accessibility';
|
||||
@@ -9,8 +9,8 @@ import { renderWithEvents } from '../__helpers__/setUpTest';
|
||||
|
||||
describe('<ManageServersRowDropdown />', () => {
|
||||
const ManageServersRowDropdown = ManageServersRowDropdownFactory(fromPartial({
|
||||
DeleteServerModal: ({ isOpen }: { isOpen: boolean }) => (
|
||||
<span>DeleteServerModal {isOpen ? '[OPEN]' : '[CLOSED]'}</span>
|
||||
DeleteServerModal: ({ open }: { open: boolean }) => (
|
||||
<span>DeleteServerModal {open ? '[OPEN]' : '[CLOSED]'}</span>
|
||||
),
|
||||
}));
|
||||
const setAutoConnect = vi.fn();
|
||||
@@ -24,15 +24,13 @@ describe('<ManageServersRowDropdown />', () => {
|
||||
};
|
||||
const toggleDropdown = (user: UserEvent) => user.click(screen.getByRole('button'));
|
||||
|
||||
it.each([
|
||||
[setUp],
|
||||
[async () => {
|
||||
const { user, container } = setUp();
|
||||
await toggleDropdown(user);
|
||||
it('passes a11y checks', async () => {
|
||||
const { user, container } = setUp();
|
||||
// Open menu
|
||||
await toggleDropdown(user);
|
||||
|
||||
return { container };
|
||||
}],
|
||||
])('passes a11y checks', (setUp) => checkAccessibility(setUp()));
|
||||
return checkAccessibility({ container });
|
||||
});
|
||||
|
||||
it('renders expected amount of dropdown items', async () => {
|
||||
const { user } = setUp();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { screen } from '@testing-library/react';
|
||||
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 { ServersDropdown } from '../../src/servers/ServersDropdown';
|
||||
import { checkAccessibility } from '../__helpers__/accessibility';
|
||||
@@ -20,7 +20,13 @@ describe('<ServersDropdown />', () => {
|
||||
</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 () => {
|
||||
const { user } = setUp();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
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 { ServersListGroup } from '../../src/servers/ServersListGroup';
|
||||
import { checkAccessibility } from '../__helpers__/accessibility';
|
||||
@@ -10,30 +10,18 @@ describe('<ServersListGroup />', () => {
|
||||
fromPartial({ name: 'foo', id: '123' }),
|
||||
fromPartial({ name: 'bar', id: '456' }),
|
||||
];
|
||||
const setUp = (params: { servers?: ServerWithId[]; withChildren?: boolean; embedded?: boolean } = {}) => {
|
||||
const { servers = [], withChildren = true, embedded } = params;
|
||||
const setUp = (params: { servers?: ServerWithId[]; borderless?: boolean } = {}) => {
|
||||
const { servers = [], borderless } = params;
|
||||
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<ServersListGroup servers={servers} embedded={embedded}>
|
||||
{withChildren ? 'The list of servers' : undefined}
|
||||
</ServersListGroup>
|
||||
<ServersListGroup servers={servers} borderless={borderless} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
};
|
||||
|
||||
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([
|
||||
[servers],
|
||||
[[]],
|
||||
@@ -45,11 +33,17 @@ describe('<ServersListGroup />', () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
[true, 'servers-list__list-group servers-list__list-group--embedded'],
|
||||
[false, 'servers-list__list-group'],
|
||||
[undefined, 'servers-list__list-group'],
|
||||
])('renders proper classes for embedded', (embedded, expectedClasses) => {
|
||||
setUp({ servers, embedded });
|
||||
expect(screen.getByTestId('list')).toHaveAttribute('class', `${expectedClasses} list-group`);
|
||||
[true],
|
||||
[false],
|
||||
[undefined],
|
||||
])('renders proper classes for embedded', (borderless) => {
|
||||
setUp({ servers, borderless });
|
||||
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`] = `
|
||||
<button
|
||||
class="p-0 bg-transparent border-0"
|
||||
class="tw:text-danger tw:hover:underline"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="button"
|
||||
>
|
||||
Foo bar
|
||||
</span>
|
||||
Foo bar
|
||||
</button>
|
||||
`;
|
||||
|
||||
exports[`<DeleteServerButton /> > renders expected content 2`] = `
|
||||
<button
|
||||
class="p-0 bg-transparent border-0"
|
||||
class="tw:text-danger tw:hover:underline"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="button"
|
||||
>
|
||||
baz
|
||||
</span>
|
||||
baz
|
||||
</button>
|
||||
`;
|
||||
|
||||
exports[`<DeleteServerButton /> > renders expected content 3`] = `
|
||||
<button
|
||||
class="p-0 bg-transparent border-0"
|
||||
class="tw:text-danger tw:hover:underline"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
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>
|
||||
something
|
||||
</button>
|
||||
`;
|
||||
|
||||
@@ -2,18 +2,29 @@
|
||||
|
||||
exports[`<ManageServersRow /> > renders auto-connect icon only if server is autoConnect 1`] = `
|
||||
<div>
|
||||
<table>
|
||||
<tbody>
|
||||
<table
|
||||
class="tw:w-full"
|
||||
>
|
||||
<thead
|
||||
class="tw:hidden tw:lg:table-header-group"
|
||||
>
|
||||
<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
|
||||
class="responsive-table__cell"
|
||||
data-th="Auto-connect"
|
||||
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-column="Auto-connect"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="svg-inline--fa fa-check text-primary"
|
||||
class="svg-inline--fa fa-check tw:text-brand"
|
||||
data-icon="check"
|
||||
data-prefix="fas"
|
||||
focusable="false"
|
||||
@@ -28,24 +39,25 @@ exports[`<ManageServersRow /> > renders auto-connect icon only if server is auto
|
||||
/>
|
||||
</svg>
|
||||
</td>
|
||||
<th
|
||||
class="responsive-table__cell"
|
||||
data-th="Name"
|
||||
<td
|
||||
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-column="Name"
|
||||
>
|
||||
<a
|
||||
data-discover="true"
|
||||
href="/server/abc"
|
||||
>
|
||||
My server
|
||||
</a>
|
||||
</th>
|
||||
</td>
|
||||
<td
|
||||
class="responsive-table__cell"
|
||||
data-th="Base URL"
|
||||
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-column="Base URL"
|
||||
>
|
||||
https://example.com
|
||||
</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>
|
||||
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`] = `
|
||||
<div>
|
||||
<table>
|
||||
<tbody>
|
||||
<table
|
||||
class="tw:w-full"
|
||||
>
|
||||
<thead
|
||||
class="tw:hidden tw:lg:table-header-group"
|
||||
>
|
||||
<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
|
||||
class="responsive-table__cell"
|
||||
data-th="Auto-connect"
|
||||
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-column="Auto-connect"
|
||||
/>
|
||||
<th
|
||||
class="responsive-table__cell"
|
||||
data-th="Name"
|
||||
<td
|
||||
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-column="Name"
|
||||
>
|
||||
<a
|
||||
data-discover="true"
|
||||
href="/server/abc"
|
||||
>
|
||||
My server
|
||||
</a>
|
||||
</th>
|
||||
</td>
|
||||
<td
|
||||
class="responsive-table__cell"
|
||||
data-th="Base URL"
|
||||
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-column="Base URL"
|
||||
>
|
||||
https://example.com
|
||||
</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>
|
||||
ManageServersRowDropdown
|
||||
|
||||
@@ -35,111 +35,12 @@ exports[`<ManageServersRowDropdown /> > renders expected size and icon 1`] = `
|
||||
role="menu"
|
||||
style="min-width: 210px;"
|
||||
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>
|
||||
<span>
|
||||
DeleteServerModal
|
||||
[CLOSED]
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -178,110 +79,11 @@ exports[`<ManageServersRowDropdown /> > renders expected size and icon 2`] = `
|
||||
role="menu"
|
||||
style="min-width: 170px;"
|
||||
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>
|
||||
<span>
|
||||
DeleteServerModal
|
||||
[CLOSED]
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -6,10 +6,10 @@ import { checkAccessibility } from '../../__helpers__/accessibility';
|
||||
import { renderWithEvents } from '../../__helpers__/setUpTest';
|
||||
|
||||
describe('<DuplicatedServersModal />', () => {
|
||||
const onDiscard = vi.fn();
|
||||
const onSave = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
const onConfirm = vi.fn();
|
||||
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);
|
||||
|
||||
@@ -32,8 +32,9 @@ describe('<DuplicatedServersModal />', () => {
|
||||
{
|
||||
header: 'Duplicated server',
|
||||
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',
|
||||
confirmButton: 'Save duplicate',
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -41,8 +42,9 @@ describe('<DuplicatedServersModal />', () => {
|
||||
{
|
||||
header: 'Duplicated servers',
|
||||
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',
|
||||
confirmButton: 'Save duplicates',
|
||||
},
|
||||
],
|
||||
])('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.lastParagraph)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: assertions.discardBtn })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: assertions.confirmButton })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
expect(onDiscard).not.toHaveBeenCalled();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
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();
|
||||
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
await user.click(screen.getByRole('button', { name: 'Save anyway' }));
|
||||
expect(onSave).toHaveBeenCalled();
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
await user.click(screen.getByRole('button', { name: 'Save duplicate' }));
|
||||
expect(onConfirm).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -65,9 +65,9 @@ describe('<ImportServersBtn />', () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ btnName: 'Save anyway',savesDuplicatedServers: true },
|
||||
{ btnName: 'Save duplicate', savesDuplicatedServers: true },
|
||||
{ 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 = {
|
||||
name: 'existingServer',
|
||||
url: 'http://s.test/existingUrl',
|
||||
@@ -84,14 +84,20 @@ describe('<ImportServersBtn />', () => {
|
||||
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
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();
|
||||
await user.click(screen.getByRole('button', { name: btnName }));
|
||||
|
||||
expect(createServersMock).toHaveBeenCalledWith(
|
||||
savesDuplicatedServers
|
||||
? [expect.objectContaining(existingServerData), expect.objectContaining(newServer)]
|
||||
: [expect.objectContaining(newServer)],
|
||||
);
|
||||
expect(onImportMock).toHaveBeenCalledTimes(1);
|
||||
// If duplicated servers are saved, there's one extra call
|
||||
if (savesDuplicatedServers) {
|
||||
expect(createServersMock).toHaveBeenLastCalledWith([expect.objectContaining(existingServerData)]);
|
||||
}
|
||||
|
||||
// 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 { 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 { ServerErrorFactory } from '../../../src/servers/helpers/ServerError';
|
||||
import { checkAccessibility } from '../../__helpers__/accessibility';
|
||||
@@ -26,7 +26,7 @@ describe('<ServerError />', () => {
|
||||
notFound: [
|
||||
'Oops! Could not connect to this Shlink server.',
|
||||
'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: [
|
||||
'Oops! Could not connect to this Shlink server.',
|
||||
'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.'],
|
||||
},
|
||||
|
||||
@@ -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 { checkAccessibility } from '../../__helpers__/accessibility';
|
||||
import { renderWithEvents } from '../../__helpers__/setUpTest';
|
||||
|
||||
describe('<ServerForm />', () => {
|
||||
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('renders components', () => {
|
||||
it('renders inputs', () => {
|
||||
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('Advanced options')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('invokes submit callback when submit event is triggered', async () => {
|
||||
@@ -22,4 +26,13 @@ describe('<ServerForm />', () => {
|
||||
fireEvent.submit(screen.getByRole('form'), { preventDefault: vi.fn() });
|
||||
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 type { ServersMap } from '../../../src/servers/data';
|
||||
import { serializeServer } from '../../../src/servers/data';
|
||||
import { ServersExporter } from '../../../src/servers/services/ServersExporter';
|
||||
import type { LocalStorage } from '../../../src/utils/services/LocalStorage';
|
||||
import { appendChild, removeChild, windowMock } from '../../__mocks__/Window.mock';
|
||||
|
||||
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>({
|
||||
get: vi.fn(() => ({
|
||||
abc123: {
|
||||
id: 'abc123',
|
||||
name: 'foo',
|
||||
autoConnect: true,
|
||||
},
|
||||
def456: {
|
||||
id: 'def456',
|
||||
name: 'bar',
|
||||
autoConnect: false,
|
||||
},
|
||||
} as any)),
|
||||
get: vi.fn(() => servers as any),
|
||||
});
|
||||
const erroneousToCsv = vi.fn(() => {
|
||||
throw new Error('');
|
||||
});
|
||||
const createCsvjsonMock = (throwError = false) => (throwError ? erroneousToCsv : vi.fn(() => ''));
|
||||
const createJsonToCsvMock = (throwError = false) => (throwError ? erroneousToCsv : vi.fn(() => ''));
|
||||
|
||||
describe('exportServers', () => {
|
||||
let originalConsole: Console;
|
||||
const error = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
originalConsole = global.console;
|
||||
global.console = fromPartial<Console>({ error });
|
||||
(global as any).Blob = class Blob {};
|
||||
(global as any).URL = { createObjectURL: () => '' };
|
||||
vi.stubGlobal('console', fromPartial<Console>({ error }));
|
||||
});
|
||||
afterEach(() => {
|
||||
global.console = originalConsole;
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('logs an error if something fails', () => {
|
||||
const csvjsonMock = createCsvjsonMock(true);
|
||||
const exporter = new ServersExporter(storageMock, windowMock, csvjsonMock);
|
||||
const jsonToCsvMock = createJsonToCsvMock(true);
|
||||
const exporter = new ServersExporter(storageMock, windowMock, jsonToCsvMock);
|
||||
|
||||
exporter.exportServers();
|
||||
|
||||
@@ -48,7 +52,8 @@ describe('ServersExporter', () => {
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
exporter.exportServers();
|
||||
@@ -57,6 +62,7 @@ describe('ServersExporter', () => {
|
||||
expect(createElement).toHaveBeenCalledTimes(1);
|
||||
expect(appendChild).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 type { RegularServer } from '../../../src/servers/data';
|
||||
import type { RegularServer, ServerData } from '../../../src/servers/data';
|
||||
import { ServersImporter } from '../../../src/servers/services/ServersImporter';
|
||||
|
||||
describe('ServersImporter', () => {
|
||||
@@ -25,20 +25,24 @@ describe('ServersImporter', () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{}],
|
||||
[undefined],
|
||||
[[{ foo: 'bar' }]],
|
||||
[
|
||||
[
|
||||
{ parsedObject: {}, expectedError: 'Provided file does not have the right format.' },
|
||||
{ parsedObject: undefined, expectedError: 'Provided file does not have the right format.' },
|
||||
{
|
||||
parsedObject: [{ foo: 'bar' }],
|
||||
expectedError: 'Server is missing required "url", "apiKey" and/or "name" properties',
|
||||
},
|
||||
{
|
||||
parsedObject: [
|
||||
{
|
||||
url: 1,
|
||||
apiKey: 1,
|
||||
name: 1,
|
||||
},
|
||||
],
|
||||
],
|
||||
[
|
||||
[
|
||||
expectedError: 'Server is missing required "url", "apiKey" and/or "name" properties',
|
||||
},
|
||||
{
|
||||
parsedObject: [
|
||||
{
|
||||
url: 'foo',
|
||||
apiKey: 'foo',
|
||||
@@ -46,26 +50,29 @@ describe('ServersImporter', () => {
|
||||
},
|
||||
{ bar: 'foo' },
|
||||
],
|
||||
],
|
||||
])('rejects with error if provided file does not parse to valid list of servers', async (parsedObject) => {
|
||||
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,
|
||||
expectedError,
|
||||
}) => {
|
||||
csvjsonMock.mockResolvedValue(parsedObject);
|
||||
|
||||
await expect(importer.importServersFromFile(fileMock())).rejects.toEqual(
|
||||
new Error('Provided file does not have the right format.'),
|
||||
);
|
||||
await expect(importer.importServersFromFile(fileMock())).rejects.toEqual(new Error(expectedError));
|
||||
});
|
||||
|
||||
it('reads file when a CSV containing valid servers is provided', async () => {
|
||||
const expectedServers = [
|
||||
const expectedServers: Required<ServerData>[] = [
|
||||
{
|
||||
url: 'foo',
|
||||
apiKey: 'foo',
|
||||
name: 'foo',
|
||||
forwardCredentials: false,
|
||||
},
|
||||
{
|
||||
url: 'bar',
|
||||
apiKey: 'bar',
|
||||
name: 'bar',
|
||||
forwardCredentials: false,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
import { Settings } from '../../src/settings/Settings';
|
||||
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 { resolve } from 'path';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
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();
|
||||
|
||||
/* eslint-disable-next-line no-restricted-exports */
|
||||
export default defineConfig({
|
||||
plugins: [react(), VitePWA({
|
||||
plugins: [react(), tailwindcss(), VitePWA({
|
||||
mode: process.env.NODE_ENV === 'development' ? 'development' : 'production',
|
||||
strategies: 'injectManifest',
|
||||
srcDir: './src',
|
||||
@@ -22,14 +26,24 @@ export default defineConfig({
|
||||
},
|
||||
server: {
|
||||
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
|
||||
|
||||
// Vitest config
|
||||
test: {
|
||||
// Run tests in an actual browser
|
||||
browser: {
|
||||
provider: 'playwright',
|
||||
enabled: true,
|
||||
headless: true,
|
||||
screenshotFailures: false,
|
||||
instances: [{ browser: 'chromium' }],
|
||||
},
|
||||
globals: true,
|
||||
allowOnly: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: './config/test/setupTests.ts',
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
@@ -46,10 +60,23 @@ export default defineConfig({
|
||||
// Required code coverage. Lower than this will make the check fail
|
||||
thresholds: {
|
||||
statements: 95,
|
||||
branches: 90,
|
||||
functions: 90,
|
||||
branches: 95,
|
||||
functions: 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