mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2026-02-26 19:56:41 +00:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aab26e3736 | ||
|
|
0dc6d70dd9 | ||
|
|
4eab3b6935 | ||
|
|
b747e63d51 | ||
|
|
5aa113ec16 | ||
|
|
2e438f9814 | ||
|
|
9a798c20c0 | ||
|
|
9e1a803b8d | ||
|
|
d18ebf8911 | ||
|
|
5c2e99cba1 | ||
|
|
c75a3a4073 | ||
|
|
e68643108a | ||
|
|
8a7a51be2f | ||
|
|
f5e92c6897 |
71
CHANGELOG.md
71
CHANGELOG.md
@@ -4,77 +4,6 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org).
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org).
|
||||||
|
|
||||||
## [4.7.0] - 2026-02-04
|
|
||||||
### Added
|
|
||||||
* [shlink-web-component] Add support for Shlink 5.0.0, by supporting date-based redirect conditions.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
* *Nothing*
|
|
||||||
|
|
||||||
### Deprecated
|
|
||||||
* *Nothing*
|
|
||||||
|
|
||||||
### Removed
|
|
||||||
* *Nothing*
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
* *Nothing*
|
|
||||||
|
|
||||||
|
|
||||||
## [4.6.2] - 2025-11-15
|
|
||||||
### Added
|
|
||||||
* *Nothing*
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
* *Nothing*
|
|
||||||
|
|
||||||
### Deprecated
|
|
||||||
* *Nothing*
|
|
||||||
|
|
||||||
### Removed
|
|
||||||
* *Nothing*
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
* [shlink-web-component#878](https://github.com/shlinkio/shlink-web-component/issues/878) Fix real-time updates interval setting being ignored.
|
|
||||||
|
|
||||||
|
|
||||||
## [4.6.1] - 2025-11-15
|
|
||||||
### Added
|
|
||||||
* *Nothing*
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
* [#802](https://github.com/shlinkio/shlink-web-client/issues/802) Improve dependency injection in components.
|
|
||||||
* Stop injecting redux state and actions.
|
|
||||||
|
|
||||||
### Deprecated
|
|
||||||
* *Nothing*
|
|
||||||
|
|
||||||
### Removed
|
|
||||||
* *Nothing*
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
* Fix small UI issues.
|
|
||||||
|
|
||||||
|
|
||||||
## [4.6.0] - 2025-11-12
|
|
||||||
### Added
|
|
||||||
* [shlink-web-component#839](https://github.com/shlinkio/shlink-web-component/issues/839) Allow filtering short URLs by excluded tags when using Shlink >=4.6.0
|
|
||||||
* [shlink-web-component#838](https://github.com/shlinkio/shlink-web-component/issues/838) Allow filtering tag, orphan and non-orphan visits by domain, when using Shlink >=4.6.0
|
|
||||||
* [shlink-web-component#784](https://github.com/shlinkio/shlink-web-component/issues/784) Add optional `long-url` query parameter to short URL creation to prefill the long URL programmatically.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
* *Nothing*
|
|
||||||
|
|
||||||
### Deprecated
|
|
||||||
* *Nothing*
|
|
||||||
|
|
||||||
### Removed
|
|
||||||
* Drop support for Shlink older than 4.0.0
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
* *Nothing*
|
|
||||||
|
|
||||||
|
|
||||||
## [4.5.1] - 2025-08-13
|
## [4.5.1] - 2025-08-13
|
||||||
### Added
|
### Added
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM node:25.6-alpine AS node
|
FROM node:24.5-alpine AS node
|
||||||
COPY . /shlink-web-client
|
COPY . /shlink-web-client
|
||||||
ARG VERSION="latest"
|
ARG VERSION="latest"
|
||||||
ENV VERSION=${VERSION}
|
ENV VERSION=${VERSION}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM mcr.microsoft.com/playwright:v1.58.2-noble
|
FROM mcr.microsoft.com/playwright:v1.54.2-noble
|
||||||
|
|
||||||
ENV NODE_VERSION 22.14
|
ENV NODE_VERSION 22.14
|
||||||
ENV TINI_VERSION v0.19.0
|
ENV TINI_VERSION v0.19.0
|
||||||
|
|||||||
9515
package-lock.json
generated
9515
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
86
package.json
86
package.json
@@ -7,7 +7,7 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"lint": "eslint src test config/test *.config.{js,ts}",
|
"lint": "eslint src test config/test",
|
||||||
"lint:fix": "node --run lint -- --fix",
|
"lint:fix": "node --run lint -- --fix",
|
||||||
"types": "tsc",
|
"types": "tsc",
|
||||||
"start": "vite serve --host=0.0.0.0",
|
"start": "vite serve --host=0.0.0.0",
|
||||||
@@ -20,68 +20,66 @@
|
|||||||
"test:verbose": "node --run test -- --verbose"
|
"test:verbose": "node --run test -- --verbose"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^7.2.0",
|
"@fortawesome/fontawesome-free": "^7.0.0",
|
||||||
"@fortawesome/fontawesome-svg-core": "^7.2.0",
|
"@fortawesome/fontawesome-svg-core": "^7.0.0",
|
||||||
"@fortawesome/free-brands-svg-icons": "^7.2.0",
|
"@fortawesome/free-brands-svg-icons": "^7.0.0",
|
||||||
"@fortawesome/free-regular-svg-icons": "^7.2.0",
|
"@fortawesome/free-regular-svg-icons": "^7.0.0",
|
||||||
"@fortawesome/free-solid-svg-icons": "^7.2.0",
|
"@fortawesome/free-solid-svg-icons": "^7.0.0",
|
||||||
"@fortawesome/react-fontawesome": "^3.2.0",
|
"@fortawesome/react-fontawesome": "^0.2.3",
|
||||||
"@json2csv/plainjs": "^7.0.6",
|
"@json2csv/plainjs": "^7.0.6",
|
||||||
"@reduxjs/toolkit": "^2.11.2",
|
"@reduxjs/toolkit": "^2.8.2",
|
||||||
"@shlinkio/data-manipulation": "^1.0.4",
|
"@shlinkio/data-manipulation": "^1.0.3",
|
||||||
"@shlinkio/shlink-frontend-kit": "^1.4.0",
|
"@shlinkio/shlink-frontend-kit": "^1.1.0",
|
||||||
"@shlinkio/shlink-js-sdk": "^3.1.0",
|
"@shlinkio/shlink-js-sdk": "^2.2.1",
|
||||||
"@shlinkio/shlink-web-component": "^0.18.0",
|
"@shlinkio/shlink-web-component": "^0.16.1",
|
||||||
"@vitest/browser-playwright": "^4.0.18",
|
|
||||||
"bottlejs": "^2.0.1",
|
"bottlejs": "^2.0.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"compare-versions": "^6.1.1",
|
"compare-versions": "^6.1.1",
|
||||||
"csvtojson": "^2.0.14",
|
"csvtojson": "^2.0.10",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.1.1",
|
||||||
"react-external-link": "^2.6.1",
|
"react-external-link": "^2.5.0",
|
||||||
"react-redux": "^9.2.0",
|
"react-redux": "^9.2.0",
|
||||||
"react-router": "^7.13.0",
|
"react-router": "^7.8.0",
|
||||||
"redux-localstorage-simple": "^2.5.1",
|
"redux-localstorage-simple": "^2.5.1",
|
||||||
"workbox-core": "^7.4.0",
|
"workbox-core": "^7.3.0",
|
||||||
"workbox-expiration": "^7.4.0",
|
"workbox-expiration": "^7.3.0",
|
||||||
"workbox-precaching": "^7.4.0",
|
"workbox-precaching": "^7.3.0",
|
||||||
"workbox-routing": "^7.4.0",
|
"workbox-routing": "^7.3.0",
|
||||||
"workbox-strategies": "^7.4.0"
|
"workbox-strategies": "^7.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@shlinkio/eslint-config-js-coding-standard": "~3.7.0",
|
"@shlinkio/eslint-config-js-coding-standard": "~3.5.0",
|
||||||
"@stylistic/eslint-plugin": "^5.7.1",
|
"@stylistic/eslint-plugin": "^5.2.3",
|
||||||
"@tailwindcss/vite": "^4.2.0",
|
"@tailwindcss/vite": "^4.1.11",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.6.4",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.0",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@total-typescript/shoehorn": "^0.1.2",
|
"@total-typescript/shoehorn": "^0.1.2",
|
||||||
"@types/node": "^25.3.0",
|
"@types/react": "^19.1.9",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react-dom": "^19.1.7",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
"@vitejs/plugin-react": "^5.1.4",
|
"@vitest/browser": "^3.2.4",
|
||||||
"@vitest/browser": "^4.0.3",
|
"@vitest/coverage-v8": "^3.2.4",
|
||||||
"@vitest/coverage-v8": "^4.0.18",
|
|
||||||
"adm-zip": "^0.5.16",
|
"adm-zip": "^0.5.16",
|
||||||
"axe-core": "^4.11.1",
|
"axe-core": "^4.10.3",
|
||||||
"chalk": "^5.6.2",
|
"chalk": "^5.5.0",
|
||||||
"eslint": "^9.39.2",
|
"eslint": "^9.33.0",
|
||||||
"eslint-plugin-import": "^2.32.0",
|
"eslint-plugin-import": "^2.32.0",
|
||||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||||
"eslint-plugin-react": "^7.37.5",
|
"eslint-plugin-react": "^7.37.5",
|
||||||
"eslint-plugin-react-compiler": "^19.0.0-beta-714736e-20250131",
|
"eslint-plugin-react-compiler": "^19.0.0-beta-714736e-20250131",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"eslint-plugin-simple-import-sort": "^12.1.1",
|
"eslint-plugin-simple-import-sort": "^12.1.1",
|
||||||
"history": "^5.3.0",
|
"history": "^5.3.0",
|
||||||
"playwright": "^1.58.2",
|
"playwright": "^1.54.2",
|
||||||
"tailwindcss": "^4.1.3",
|
"tailwindcss": "^4.1.3",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.8.3",
|
||||||
"typescript-eslint": "^8.54.0",
|
"typescript-eslint": "^8.39.0",
|
||||||
"vite": "^7.3.1",
|
"vite": "^7.1.1",
|
||||||
"vite-plugin-pwa": "^1.2.0",
|
"vite-plugin-pwa": "^1.0.2",
|
||||||
"vitest": "^4.0.3"
|
"vitest": "^3.0.5"
|
||||||
},
|
},
|
||||||
"browserslist": [
|
"browserslist": [
|
||||||
">0.2%",
|
">0.2%",
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import type { HttpClient } from '@shlinkio/shlink-js-sdk';
|
import type { HttpClient } from '@shlinkio/shlink-js-sdk';
|
||||||
import { ShlinkApiClient } from '@shlinkio/shlink-js-sdk';
|
import { ShlinkApiClient } from '@shlinkio/shlink-js-sdk';
|
||||||
|
import type { GetState } from '../../container/types';
|
||||||
import type { ServerWithId } from '../../servers/data';
|
import type { ServerWithId } from '../../servers/data';
|
||||||
import { hasServerData } from '../../servers/data';
|
import { hasServerData } from '../../servers/data';
|
||||||
import type { GetState } from '../../store';
|
|
||||||
|
|
||||||
const apiClients: Map<string, ShlinkApiClient> = new Map();
|
const apiClients: Map<string, ShlinkApiClient> = new Map();
|
||||||
|
|
||||||
|
const isGetState = (getStateOrSelectedServer: GetState | ServerWithId): getStateOrSelectedServer is GetState =>
|
||||||
|
typeof getStateOrSelectedServer === 'function';
|
||||||
const getSelectedServerFromState = (getState: GetState): ServerWithId => {
|
const getSelectedServerFromState = (getState: GetState): ServerWithId => {
|
||||||
const { selectedServer } = getState();
|
const { selectedServer } = getState();
|
||||||
if (!hasServerData(selectedServer)) {
|
if (!hasServerData(selectedServer)) {
|
||||||
@@ -16,7 +18,7 @@ const getSelectedServerFromState = (getState: GetState): ServerWithId => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const buildShlinkApiClient = (httpClient: HttpClient) => (getStateOrSelectedServer: GetState | ServerWithId) => {
|
export const buildShlinkApiClient = (httpClient: HttpClient) => (getStateOrSelectedServer: GetState | ServerWithId) => {
|
||||||
const { url: baseUrl, apiKey, forwardCredentials } = typeof getStateOrSelectedServer === 'function'
|
const { url: baseUrl, apiKey, forwardCredentials } = isGetState(getStateOrSelectedServer)
|
||||||
? getSelectedServerFromState(getStateOrSelectedServer)
|
? getSelectedServerFromState(getStateOrSelectedServer)
|
||||||
: getStateOrSelectedServer;
|
: getStateOrSelectedServer;
|
||||||
const serverKey = `${apiKey}_${baseUrl}_${forwardCredentials ? 'forward' : 'no-forward'}`;
|
const serverKey = `${apiKey}_${baseUrl}_${forwardCredentials ? 'forward' : 'no-forward'}`;
|
||||||
@@ -32,7 +34,6 @@ export const buildShlinkApiClient = (httpClient: HttpClient) => (getStateOrSelec
|
|||||||
{ requestCredentials: forwardCredentials ? 'include' : undefined },
|
{ requestCredentials: forwardCredentials ? 'include' : undefined },
|
||||||
);
|
);
|
||||||
apiClients.set(serverKey, apiClient);
|
apiClients.set(serverKey, apiClient);
|
||||||
|
|
||||||
return apiClient;
|
return apiClient;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
6
src/api/services/provideServices.ts
Normal file
6
src/api/services/provideServices.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import type Bottle from 'bottlejs';
|
||||||
|
import { buildShlinkApiClient } from './ShlinkApiClientBuilder';
|
||||||
|
|
||||||
|
export const provideServices = (bottle: Bottle) => {
|
||||||
|
bottle.serviceFactory('buildShlinkApiClient', buildShlinkApiClient, 'HttpClient');
|
||||||
|
};
|
||||||
@@ -1,32 +1,61 @@
|
|||||||
import { changeThemeInMarkup, getSystemPreferredTheme } from '@shlinkio/shlink-frontend-kit';
|
import { changeThemeInMarkup, getSystemPreferredTheme } from '@shlinkio/shlink-frontend-kit';
|
||||||
|
import type { Settings } from '@shlinkio/shlink-web-component/settings';
|
||||||
import { clsx } from 'clsx';
|
import { clsx } from 'clsx';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { useEffect } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { Route, Routes, useLocation } from 'react-router';
|
import { Route, Routes, useLocation } from 'react-router';
|
||||||
import { AppUpdateBanner } from '../common/AppUpdateBanner';
|
import { AppUpdateBanner } from '../common/AppUpdateBanner';
|
||||||
import { Home } from '../common/Home';
|
|
||||||
import { MainHeader } from '../common/MainHeader';
|
|
||||||
import { NotFound } from '../common/NotFound';
|
import { NotFound } from '../common/NotFound';
|
||||||
import { ShlinkVersionsContainer } from '../common/ShlinkVersionsContainer';
|
import type { FCWithDeps } from '../container/utils';
|
||||||
import { ShlinkWebComponentContainer } from '../common/ShlinkWebComponentContainer';
|
import { componentFactory, useDependencies } from '../container/utils';
|
||||||
import { CreateServer } from '../servers/CreateServer';
|
import type { ServersMap } from '../servers/data';
|
||||||
import { EditServer } from '../servers/EditServer';
|
|
||||||
import { ManageServers } from '../servers/ManageServers';
|
|
||||||
import { useLoadRemoteServers } from '../servers/reducers/remoteServers';
|
|
||||||
import { useSettings } from '../settings/reducers/settings';
|
|
||||||
import { Settings } from '../settings/Settings';
|
|
||||||
import { forceUpdate } from '../utils/helpers/sw';
|
import { forceUpdate } from '../utils/helpers/sw';
|
||||||
import { useAppUpdated } from './reducers/appUpdates';
|
|
||||||
|
|
||||||
export const App: FC = () => {
|
type AppProps = {
|
||||||
const { appUpdated, resetAppUpdate } = useAppUpdated();
|
fetchServers: () => void;
|
||||||
|
servers: ServersMap;
|
||||||
|
settings: Settings;
|
||||||
|
resetAppUpdate: () => void;
|
||||||
|
appUpdated: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
useLoadRemoteServers();
|
type AppDeps = {
|
||||||
|
MainHeader: FC;
|
||||||
|
Home: FC;
|
||||||
|
ShlinkWebComponentContainer: FC;
|
||||||
|
CreateServer: FC;
|
||||||
|
EditServer: FC;
|
||||||
|
Settings: FC;
|
||||||
|
ManageServers: FC;
|
||||||
|
ShlinkVersionsContainer: FC;
|
||||||
|
};
|
||||||
|
|
||||||
|
const App: FCWithDeps<AppProps, AppDeps> = (
|
||||||
|
{ fetchServers, servers, settings, appUpdated, resetAppUpdate },
|
||||||
|
) => {
|
||||||
|
const {
|
||||||
|
MainHeader,
|
||||||
|
Home,
|
||||||
|
ShlinkWebComponentContainer,
|
||||||
|
CreateServer,
|
||||||
|
EditServer,
|
||||||
|
Settings,
|
||||||
|
ManageServers,
|
||||||
|
ShlinkVersionsContainer,
|
||||||
|
} = useDependencies(App);
|
||||||
|
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const initialServers = useRef(servers);
|
||||||
const isHome = location.pathname === '/';
|
const isHome = location.pathname === '/';
|
||||||
|
|
||||||
const { settings } = useSettings();
|
useEffect(() => {
|
||||||
|
// Try to fetch the remote servers if the list is empty during first render.
|
||||||
|
// We use a ref because we don't care if the servers list becomes empty later.
|
||||||
|
if (Object.keys(initialServers.current).length === 0) {
|
||||||
|
fetchServers();
|
||||||
|
}
|
||||||
|
}, [fetchServers]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
changeThemeInMarkup(settings.ui?.theme ?? getSystemPreferredTheme());
|
changeThemeInMarkup(settings.ui?.theme ?? getSystemPreferredTheme());
|
||||||
}, [settings.ui?.theme]);
|
}, [settings.ui?.theme]);
|
||||||
@@ -69,3 +98,14 @@ export const App: FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const AppFactory = componentFactory(App, [
|
||||||
|
'MainHeader',
|
||||||
|
'Home',
|
||||||
|
'ShlinkWebComponentContainer',
|
||||||
|
'CreateServer',
|
||||||
|
'EditServer',
|
||||||
|
'Settings',
|
||||||
|
'ManageServers',
|
||||||
|
'ShlinkVersionsContainer',
|
||||||
|
]);
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { createSlice } from '@reduxjs/toolkit';
|
import { createSlice } from '@reduxjs/toolkit';
|
||||||
import { useCallback } from 'react';
|
|
||||||
import { useAppDispatch, useAppSelector } from '../../store';
|
|
||||||
|
|
||||||
const { actions, reducer } = createSlice({
|
const { actions, reducer } = createSlice({
|
||||||
name: 'shlink/appUpdates',
|
name: 'shlink/appUpdates',
|
||||||
@@ -14,12 +12,3 @@ const { actions, reducer } = createSlice({
|
|||||||
export const { appUpdateAvailable, resetAppUpdate } = actions;
|
export const { appUpdateAvailable, resetAppUpdate } = actions;
|
||||||
|
|
||||||
export const appUpdatesReducer = reducer;
|
export const appUpdatesReducer = reducer;
|
||||||
|
|
||||||
export const useAppUpdated = () => {
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
const appUpdateAvailable = useCallback(() => dispatch(actions.appUpdateAvailable()), [dispatch]);
|
|
||||||
const resetAppUpdate = useCallback(() => dispatch(actions.resetAppUpdate()), [dispatch]);
|
|
||||||
const appUpdated = useAppSelector((state) => state.appUpdated);
|
|
||||||
|
|
||||||
return { appUpdated, appUpdateAvailable, resetAppUpdate };
|
|
||||||
};
|
|
||||||
|
|||||||
14
src/app/services/provideServices.ts
Normal file
14
src/app/services/provideServices.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import type Bottle from 'bottlejs';
|
||||||
|
import type { ConnectDecorator } from '../../container/types';
|
||||||
|
import { AppFactory } from '../App';
|
||||||
|
import { appUpdateAvailable, resetAppUpdate } from '../reducers/appUpdates';
|
||||||
|
|
||||||
|
export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||||
|
// Components
|
||||||
|
bottle.factory('App', AppFactory);
|
||||||
|
bottle.decorator('App', connect(['servers', 'settings', 'appUpdated'], ['fetchServers', 'resetAppUpdate']));
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
bottle.serviceFactory('appUpdateAvailable', () => appUpdateAvailable);
|
||||||
|
bottle.serviceFactory('resetAppUpdate', () => resetAppUpdate);
|
||||||
|
};
|
||||||
@@ -2,18 +2,19 @@ import { faExternalLinkAlt, faPlus } from '@fortawesome/free-solid-svg-icons';
|
|||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { Button, Card } from '@shlinkio/shlink-frontend-kit';
|
import { Button, Card } from '@shlinkio/shlink-frontend-kit';
|
||||||
import { clsx } from 'clsx';
|
import { clsx } from 'clsx';
|
||||||
import type { FC } from 'react';
|
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { ExternalLink } from 'react-external-link';
|
import { ExternalLink } from 'react-external-link';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import { withoutSelectedServer } from '../servers/helpers/withoutSelectedServer';
|
import type { ServersMap } from '../servers/data';
|
||||||
import { useServers } from '../servers/reducers/servers';
|
|
||||||
import { ServersListGroup } from '../servers/ServersListGroup';
|
import { ServersListGroup } from '../servers/ServersListGroup';
|
||||||
import { ShlinkLogo } from './img/ShlinkLogo';
|
import { ShlinkLogo } from './img/ShlinkLogo';
|
||||||
|
|
||||||
export const Home: FC = withoutSelectedServer(() => {
|
export type HomeProps = {
|
||||||
|
servers: ServersMap;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Home = ({ servers }: HomeProps) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { servers } = useServers();
|
|
||||||
const serversList = Object.values(servers);
|
const serversList = Object.values(servers);
|
||||||
const hasServers = serversList.length > 0;
|
const hasServers = serversList.length > 0;
|
||||||
|
|
||||||
@@ -67,4 +68,4 @@ export const Home: FC = withoutSelectedServer(() => {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
};
|
||||||
|
|||||||
@@ -3,10 +3,16 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|||||||
import { NavBar } from '@shlinkio/shlink-frontend-kit';
|
import { NavBar } from '@shlinkio/shlink-frontend-kit';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { Link, useLocation } from 'react-router';
|
import { Link, useLocation } from 'react-router';
|
||||||
import { ServersDropdown } from '../servers/ServersDropdown';
|
import type { FCWithDeps } from '../container/utils';
|
||||||
|
import { componentFactory, useDependencies } from '../container/utils';
|
||||||
import { ShlinkLogo } from './img/ShlinkLogo';
|
import { ShlinkLogo } from './img/ShlinkLogo';
|
||||||
|
|
||||||
export const MainHeader: FC = () => {
|
type MainHeaderDeps = {
|
||||||
|
ServersDropdown: FC;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MainHeader: FCWithDeps<unknown, MainHeaderDeps> = () => {
|
||||||
|
const { ServersDropdown } = useDependencies(MainHeader);
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
|
|
||||||
const settingsPath = '/settings';
|
const settingsPath = '/settings';
|
||||||
@@ -31,3 +37,5 @@ export const MainHeader: FC = () => {
|
|||||||
</NavBar>
|
</NavBar>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const MainHeaderFactory = componentFactory(MainHeader, ['ServersDropdown']);
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import { clsx } from 'clsx';
|
import { clsx } from 'clsx';
|
||||||
|
import type { SelectedServer } from '../servers/data';
|
||||||
import { isReachableServer } from '../servers/data';
|
import { isReachableServer } from '../servers/data';
|
||||||
import { useSelectedServer } from '../servers/reducers/selectedServer';
|
|
||||||
import { ShlinkVersions } from './ShlinkVersions';
|
import { ShlinkVersions } from './ShlinkVersions';
|
||||||
|
|
||||||
export const ShlinkVersionsContainer = () => {
|
export type ShlinkVersionsContainerProps = {
|
||||||
const { selectedServer } = useSelectedServer();
|
selectedServer: SelectedServer;
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={clsx('text-center', { 'md:ml-(--aside-menu-width)': isReachableServer(selectedServer) })}
|
|
||||||
>
|
|
||||||
<ShlinkVersions selectedServer={selectedServer} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const ShlinkVersionsContainer = ({ selectedServer }: ShlinkVersionsContainerProps) => (
|
||||||
|
<div
|
||||||
|
className={clsx('text-center', { 'md:ml-(--aside-menu-width)': isReachableServer(selectedServer) })}
|
||||||
|
>
|
||||||
|
<ShlinkVersions selectedServer={selectedServer} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|||||||
@@ -4,34 +4,40 @@ import {
|
|||||||
ShlinkSidebarVisibilityProvider,
|
ShlinkSidebarVisibilityProvider,
|
||||||
ShlinkWebComponent,
|
ShlinkWebComponent,
|
||||||
} from '@shlinkio/shlink-web-component';
|
} from '@shlinkio/shlink-web-component';
|
||||||
|
import type { Settings } from '@shlinkio/shlink-web-component/settings';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
import type { ShlinkApiClientBuilder } from '../api/services/ShlinkApiClientBuilder';
|
import type { ShlinkApiClientBuilder } from '../api/services/ShlinkApiClientBuilder';
|
||||||
import { withDependencies } from '../container/context';
|
import type { FCWithDeps } from '../container/utils';
|
||||||
|
import { componentFactory, useDependencies } from '../container/utils';
|
||||||
import { isReachableServer } from '../servers/data';
|
import { isReachableServer } from '../servers/data';
|
||||||
import { ServerError } from '../servers/helpers/ServerError';
|
import type { WithSelectedServerProps } from '../servers/helpers/withSelectedServer';
|
||||||
import { withSelectedServer } from '../servers/helpers/withSelectedServer';
|
import { withSelectedServer } from '../servers/helpers/withSelectedServer';
|
||||||
import { useSelectedServer } from '../servers/reducers/selectedServer';
|
|
||||||
import { useSettings } from '../settings/reducers/settings';
|
|
||||||
import { NotFound } from './NotFound';
|
import { NotFound } from './NotFound';
|
||||||
|
|
||||||
export type ShlinkWebComponentContainerProps = {
|
type ShlinkWebComponentContainerProps = WithSelectedServerProps & {
|
||||||
TagColorsStorage: TagColorsStorage;
|
settings: Settings;
|
||||||
buildShlinkApiClient: ShlinkApiClientBuilder;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const ShlinkWebComponentContainerBase: FC<
|
type ShlinkWebComponentContainerDeps = {
|
||||||
ShlinkWebComponentContainerProps
|
buildShlinkApiClient: ShlinkApiClientBuilder,
|
||||||
|
TagColorsStorage: TagColorsStorage,
|
||||||
|
ServerError: FC,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ShlinkWebComponentContainer: FCWithDeps<
|
||||||
|
ShlinkWebComponentContainerProps,
|
||||||
|
ShlinkWebComponentContainerDeps
|
||||||
// FIXME Using `memo` here to solve a flickering effect in charts.
|
// FIXME Using `memo` here to solve a flickering effect in charts.
|
||||||
// memo is probably not the right solution. The root cause is the withSelectedServer HOC, but I couldn't fix the
|
// memo is probably not the right solution. The root cause is the withSelectedServer HOC, but I couldn't fix the
|
||||||
// extra rendering there.
|
// extra rendering there.
|
||||||
// This should be revisited at some point.
|
// This should be revisited at some point.
|
||||||
> = withSelectedServer(memo(({
|
> = withSelectedServer(memo(({ selectedServer, settings }) => {
|
||||||
buildShlinkApiClient,
|
const {
|
||||||
TagColorsStorage: tagColorsStorage,
|
buildShlinkApiClient,
|
||||||
}) => {
|
TagColorsStorage: tagColorsStorage,
|
||||||
const { selectedServer } = useSelectedServer();
|
ServerError,
|
||||||
const { settings } = useSettings();
|
} = useDependencies(ShlinkWebComponentContainer);
|
||||||
|
|
||||||
if (!isReachableServer(selectedServer)) {
|
if (!isReachableServer(selectedServer)) {
|
||||||
return <ServerError />;
|
return <ServerError />;
|
||||||
@@ -56,7 +62,8 @@ const ShlinkWebComponentContainerBase: FC<
|
|||||||
);
|
);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const ShlinkWebComponentContainer = withDependencies(ShlinkWebComponentContainerBase, [
|
export const ShlinkWebComponentContainerFactory = componentFactory(ShlinkWebComponentContainer, [
|
||||||
'buildShlinkApiClient',
|
'buildShlinkApiClient',
|
||||||
'TagColorsStorage',
|
'TagColorsStorage',
|
||||||
|
'ServerError',
|
||||||
]);
|
]);
|
||||||
|
|||||||
35
src/common/services/provideServices.ts
Normal file
35
src/common/services/provideServices.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { FetchHttpClient } from '@shlinkio/shlink-js-sdk/fetch';
|
||||||
|
import type Bottle from 'bottlejs';
|
||||||
|
import type { ConnectDecorator } from '../../container/types';
|
||||||
|
import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer';
|
||||||
|
import { ErrorHandler } from '../ErrorHandler';
|
||||||
|
import { Home } from '../Home';
|
||||||
|
import { MainHeaderFactory } from '../MainHeader';
|
||||||
|
import { ScrollToTop } from '../ScrollToTop';
|
||||||
|
import { ShlinkVersionsContainer } from '../ShlinkVersionsContainer';
|
||||||
|
import { ShlinkWebComponentContainerFactory } from '../ShlinkWebComponentContainer';
|
||||||
|
|
||||||
|
export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||||
|
// Services
|
||||||
|
bottle.constant('window', window);
|
||||||
|
bottle.constant('console', console);
|
||||||
|
bottle.constant('fetch', window.fetch.bind(window));
|
||||||
|
bottle.service('HttpClient', FetchHttpClient, 'fetch');
|
||||||
|
|
||||||
|
// Components
|
||||||
|
bottle.serviceFactory('ScrollToTop', () => ScrollToTop);
|
||||||
|
|
||||||
|
bottle.factory('MainHeader', MainHeaderFactory);
|
||||||
|
|
||||||
|
bottle.serviceFactory('Home', () => Home);
|
||||||
|
bottle.decorator('Home', withoutSelectedServer);
|
||||||
|
bottle.decorator('Home', connect(['servers'], ['resetSelectedServer']));
|
||||||
|
|
||||||
|
bottle.factory('ShlinkWebComponentContainer', ShlinkWebComponentContainerFactory);
|
||||||
|
bottle.decorator('ShlinkWebComponentContainer', connect(['selectedServer', 'settings'], ['selectServer']));
|
||||||
|
|
||||||
|
bottle.serviceFactory('ShlinkVersionsContainer', () => ShlinkVersionsContainer);
|
||||||
|
bottle.decorator('ShlinkVersionsContainer', connect(['selectedServer']));
|
||||||
|
|
||||||
|
bottle.serviceFactory('ErrorHandler', () => ErrorHandler);
|
||||||
|
};
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import type { IContainer } from 'bottlejs';
|
|
||||||
import { type ComponentType, createContext, useContext } from 'react';
|
|
||||||
|
|
||||||
const ContainerContext = createContext<IContainer | null>(null);
|
|
||||||
|
|
||||||
export const ContainerProvider = ContainerContext.Provider;
|
|
||||||
|
|
||||||
const useContainer = (wrapperName: string): IContainer => {
|
|
||||||
const container = useContext(ContainerContext);
|
|
||||||
if (!container) {
|
|
||||||
throw new Error(`You cannot use "${wrapperName}" outside of a ContainerProvider`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return container;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook used to extract dependencies from the container in other hooks.
|
|
||||||
*/
|
|
||||||
export const useDependencies = <T extends unknown[]>(...names: string[]): T => {
|
|
||||||
const container = useContainer('useDependencies');
|
|
||||||
|
|
||||||
return names.map((name) => {
|
|
||||||
const dependency = container[name];
|
|
||||||
if (!dependency) {
|
|
||||||
throw new Error(`Dependency with name "${name}" not found in container`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return dependency;
|
|
||||||
}) as T;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Optionalize<P, K extends keyof P> = Omit<P, K> & Partial<Pick<P, K>>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Higher Order Component used to inject services into components as props.
|
|
||||||
* All dependencies become optional props so that they can still be explicitly set in tests if desired.
|
|
||||||
*/
|
|
||||||
export function withDependencies<
|
|
||||||
Props extends Record<string, unknown>,
|
|
||||||
DependencyName extends string & keyof Props,
|
|
||||||
>(
|
|
||||||
Component: ComponentType<Props>,
|
|
||||||
dependencyNames: DependencyName[],
|
|
||||||
): ComponentType<Optionalize<Props, DependencyName>> {
|
|
||||||
function Wrapper(props: Omit<Props, DependencyName>) {
|
|
||||||
const container = useContext(ContainerContext);
|
|
||||||
|
|
||||||
// Inject services, unless they have been overridden by props passed from
|
|
||||||
// the parent component.
|
|
||||||
const dependencies: Partial<Record<DependencyName, unknown>> = {};
|
|
||||||
for (const dependency of dependencyNames) {
|
|
||||||
if (!(dependency in props)) {
|
|
||||||
dependencies[dependency] = container?.[dependency];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const propsWithServices = { ...dependencies, ...props } as Props;
|
|
||||||
return <Component {...propsWithServices} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Wrapper;
|
|
||||||
}
|
|
||||||
@@ -1,32 +1,42 @@
|
|||||||
import { useTimeoutToggle } from '@shlinkio/shlink-frontend-kit';
|
import type { IContainer } from 'bottlejs';
|
||||||
import { FetchHttpClient } from '@shlinkio/shlink-js-sdk/fetch';
|
|
||||||
import Bottle from 'bottlejs';
|
import Bottle from 'bottlejs';
|
||||||
import { buildShlinkApiClient } from '../api/services/ShlinkApiClientBuilder';
|
import { connect as reduxConnect } from 'react-redux';
|
||||||
import { ServersExporter } from '../servers/services/ServersExporter';
|
import { provideServices as provideApiServices } from '../api/services/provideServices';
|
||||||
import { ServersImporter } from '../servers/services/ServersImporter';
|
import { provideServices as provideAppServices } from '../app/services/provideServices';
|
||||||
import { csvToJson, jsonToCsv } from '../utils/helpers/csvjson';
|
import { provideServices as provideCommonServices } from '../common/services/provideServices';
|
||||||
import { LocalStorage } from '../utils/services/LocalStorage';
|
import { provideServices as provideServersServices } from '../servers/services/provideServices';
|
||||||
import { TagColorsStorage } from '../utils/services/TagColorsStorage';
|
import { provideServices as provideSettingsServices } from '../settings/services/provideServices';
|
||||||
|
import { provideServices as provideUtilsServices } from '../utils/services/provideServices';
|
||||||
|
import type { ConnectDecorator } from './types';
|
||||||
|
|
||||||
|
type LazyActionMap = Record<string, (...args: unknown[]) => unknown>;
|
||||||
|
|
||||||
const bottle = new Bottle();
|
const bottle = new Bottle();
|
||||||
|
|
||||||
export const { container } = bottle;
|
export const { container } = bottle;
|
||||||
|
|
||||||
bottle.constant('window', window);
|
const lazyService = <T extends (...args: unknown[]) => unknown, K>(cont: IContainer, serviceName: string) =>
|
||||||
bottle.constant('console', console);
|
(...args: any[]) => (cont[serviceName] as T)(...args) as K;
|
||||||
bottle.constant('fetch', window.fetch.bind(window));
|
|
||||||
bottle.service('HttpClient', FetchHttpClient, 'fetch');
|
|
||||||
|
|
||||||
bottle.constant('localStorage', window.localStorage);
|
const mapActionService = (map: LazyActionMap, actionName: string): LazyActionMap => ({
|
||||||
bottle.service('Storage', LocalStorage, 'localStorage');
|
...map,
|
||||||
bottle.service('TagColorsStorage', TagColorsStorage, 'Storage');
|
// Wrap actual action service in a function so that it is lazily created the first time it is called
|
||||||
|
[actionName]: lazyService(container, actionName),
|
||||||
|
});
|
||||||
|
|
||||||
bottle.constant('csvToJson', csvToJson);
|
const pickProps = (propsToPick: string[]) => (obj: any) => Object.fromEntries(
|
||||||
bottle.constant('jsonToCsv', jsonToCsv);
|
propsToPick.map((key) => [key, obj[key]]),
|
||||||
|
);
|
||||||
|
|
||||||
bottle.serviceFactory('useTimeoutToggle', () => useTimeoutToggle);
|
const connect: ConnectDecorator = (propsFromState: string[] | null, actionServiceNames: string[] = []) =>
|
||||||
|
reduxConnect(
|
||||||
|
propsFromState ? pickProps(propsFromState) : null,
|
||||||
|
actionServiceNames.reduce(mapActionService, {}),
|
||||||
|
);
|
||||||
|
|
||||||
bottle.serviceFactory('buildShlinkApiClient', buildShlinkApiClient, 'HttpClient');
|
provideAppServices(bottle, connect);
|
||||||
|
provideCommonServices(bottle, connect);
|
||||||
bottle.service('ServersImporter', ServersImporter, 'csvToJson');
|
provideApiServices(bottle);
|
||||||
bottle.service('ServersExporter', ServersExporter, 'Storage', 'window', 'jsonToCsv');
|
provideServersServices(bottle, connect);
|
||||||
|
provideUtilsServices(bottle);
|
||||||
|
provideSettingsServices(bottle, connect);
|
||||||
|
|||||||
25
src/container/store.ts
Normal file
25
src/container/store.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { configureStore } from '@reduxjs/toolkit';
|
||||||
|
import type { IContainer } from 'bottlejs';
|
||||||
|
import type { RLSOptions } from 'redux-localstorage-simple';
|
||||||
|
import { load, save } from 'redux-localstorage-simple';
|
||||||
|
import { initReducers } from '../reducers';
|
||||||
|
import { migrateDeprecatedSettings } from '../settings/helpers';
|
||||||
|
import type { ShlinkState } from './types';
|
||||||
|
|
||||||
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
|
const localStorageConfig: RLSOptions = {
|
||||||
|
states: ['settings', 'servers'],
|
||||||
|
namespace: 'shlink',
|
||||||
|
namespaceSeparator: '.',
|
||||||
|
debounce: 300,
|
||||||
|
};
|
||||||
|
const preloadedState = migrateDeprecatedSettings(load(localStorageConfig) as ShlinkState);
|
||||||
|
|
||||||
|
export const setUpStore = (container: IContainer) => configureStore({
|
||||||
|
devTools: !isProduction,
|
||||||
|
reducer: initReducers(container),
|
||||||
|
preloadedState,
|
||||||
|
middleware: (defaultMiddlewaresIncludingReduxThunk) =>
|
||||||
|
defaultMiddlewaresIncludingReduxThunk({ immutableCheck: false, serializableCheck: false }) // State is too big for these
|
||||||
|
.concat(save(localStorageConfig)),
|
||||||
|
});
|
||||||
13
src/container/types.ts
Normal file
13
src/container/types.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import type { Settings } from '@shlinkio/shlink-web-component/settings';
|
||||||
|
import type { SelectedServer, ServersMap } from '../servers/data';
|
||||||
|
|
||||||
|
export interface ShlinkState {
|
||||||
|
servers: ServersMap;
|
||||||
|
selectedServer: SelectedServer;
|
||||||
|
settings: Settings;
|
||||||
|
appUpdated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ConnectDecorator = (props: string[] | null, actions?: string[]) => any;
|
||||||
|
|
||||||
|
export type GetState = () => ShlinkState;
|
||||||
27
src/container/utils.ts
Normal file
27
src/container/utils.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import type { IContainer } from 'bottlejs';
|
||||||
|
import type { FC } from 'react';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
export type FCWithDeps<Props, Deps> = FC<Props> & Partial<Deps>;
|
||||||
|
|
||||||
|
export function useDependencies<Deps>(obj: Deps): Omit<Required<Deps>, keyof FC> {
|
||||||
|
return useMemo(() => obj as Omit<Required<Deps>, keyof FC>, [obj]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function componentFactory<Deps, CompType = Omit<Partial<Deps>, keyof FC>>(
|
||||||
|
Component: CompType,
|
||||||
|
deps: ReadonlyArray<keyof CompType>,
|
||||||
|
) {
|
||||||
|
return (container: IContainer, console = globalThis.console) => {
|
||||||
|
deps.forEach((dep) => {
|
||||||
|
const resolvedDependency = container[dep as string];
|
||||||
|
if (!resolvedDependency && process.env.NODE_ENV !== 'production') {
|
||||||
|
console.error(`[Debug] Could not find "${dep as string}" dependency in container`);
|
||||||
|
}
|
||||||
|
|
||||||
|
Component[dep] = resolvedDependency;
|
||||||
|
});
|
||||||
|
|
||||||
|
return Component;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2,30 +2,24 @@ import { createRoot } from 'react-dom/client';
|
|||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
import { BrowserRouter } from 'react-router';
|
import { BrowserRouter } from 'react-router';
|
||||||
import pack from '../package.json';
|
import pack from '../package.json';
|
||||||
import { App } from './app/App';
|
|
||||||
import { appUpdateAvailable } from './app/reducers/appUpdates';
|
|
||||||
import { ErrorHandler } from './common/ErrorHandler';
|
|
||||||
import { ScrollToTop } from './common/ScrollToTop';
|
|
||||||
import { container } from './container';
|
import { container } from './container';
|
||||||
import { ContainerProvider } from './container/context';
|
import { setUpStore } from './container/store';
|
||||||
import { register as registerServiceWorker } from './serviceWorkerRegistration';
|
import { register as registerServiceWorker } from './serviceWorkerRegistration';
|
||||||
import { setUpStore } from './store';
|
|
||||||
import './tailwind.css';
|
import './tailwind.css';
|
||||||
|
|
||||||
const store = setUpStore();
|
const store = setUpStore(container);
|
||||||
|
const { App, ScrollToTop, ErrorHandler, appUpdateAvailable } = container;
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<ContainerProvider value={container}>
|
<Provider store={store}>
|
||||||
<Provider store={store}>
|
<BrowserRouter basename={pack.homepage}>
|
||||||
<BrowserRouter basename={pack.homepage}>
|
<ErrorHandler>
|
||||||
<ErrorHandler>
|
<ScrollToTop>
|
||||||
<ScrollToTop>
|
<App />
|
||||||
<App />
|
</ScrollToTop>
|
||||||
</ScrollToTop>
|
</ErrorHandler>
|
||||||
</ErrorHandler>
|
</BrowserRouter>
|
||||||
</BrowserRouter>
|
</Provider>,
|
||||||
</Provider>
|
|
||||||
</ContainerProvider>,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Learn more about service workers: https://cra.link/PWA
|
// Learn more about service workers: https://cra.link/PWA
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { combineReducers } from '@reduxjs/toolkit';
|
import { combineReducers } from '@reduxjs/toolkit';
|
||||||
|
import type { IContainer } from 'bottlejs';
|
||||||
import { appUpdatesReducer } from '../app/reducers/appUpdates';
|
import { appUpdatesReducer } from '../app/reducers/appUpdates';
|
||||||
import { selectedServerReducer } from '../servers/reducers/selectedServer';
|
|
||||||
import { serversReducer } from '../servers/reducers/servers';
|
import { serversReducer } from '../servers/reducers/servers';
|
||||||
import { settingsReducer } from '../settings/reducers/settings';
|
import { settingsReducer } from '../settings/reducers/settings';
|
||||||
|
|
||||||
export const initReducers = () => combineReducers({
|
export const initReducers = (container: IContainer) => combineReducers({
|
||||||
appUpdated: appUpdatesReducer,
|
appUpdated: appUpdatesReducer,
|
||||||
servers: serversReducer,
|
servers: serversReducer,
|
||||||
selectedServer: selectedServerReducer,
|
selectedServer: container.selectedServerReducer,
|
||||||
settings: settingsReducer,
|
settings: settingsReducer,
|
||||||
});
|
});
|
||||||
@@ -1,22 +1,27 @@
|
|||||||
import type { ResultProps, TimeoutToggle } from '@shlinkio/shlink-frontend-kit';
|
import type { ResultProps,TimeoutToggle } from '@shlinkio/shlink-frontend-kit';
|
||||||
import { Button, Result, useToggle } from '@shlinkio/shlink-frontend-kit';
|
import { Button, Result,useToggle } from '@shlinkio/shlink-frontend-kit';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import { NoMenuLayout } from '../common/NoMenuLayout';
|
import { NoMenuLayout } from '../common/NoMenuLayout';
|
||||||
import { withDependencies } from '../container/context';
|
import type { FCWithDeps } from '../container/utils';
|
||||||
|
import { componentFactory, useDependencies } from '../container/utils';
|
||||||
import { useGoBack } from '../utils/helpers/hooks';
|
import { useGoBack } from '../utils/helpers/hooks';
|
||||||
import type { ServerData } from './data';
|
import type { ServerData, ServersMap, ServerWithId } from './data';
|
||||||
import { ensureUniqueIds } from './helpers';
|
import { ensureUniqueIds } from './helpers';
|
||||||
import { DuplicatedServersModal } from './helpers/DuplicatedServersModal';
|
import { DuplicatedServersModal } from './helpers/DuplicatedServersModal';
|
||||||
import { ImportServersBtn } from './helpers/ImportServersBtn';
|
import type { ImportServersBtnProps } from './helpers/ImportServersBtn';
|
||||||
import { ServerForm } from './helpers/ServerForm';
|
import { ServerForm } from './helpers/ServerForm';
|
||||||
import { withoutSelectedServer } from './helpers/withoutSelectedServer';
|
|
||||||
import { useServers } from './reducers/servers';
|
|
||||||
|
|
||||||
const SHOW_IMPORT_MSG_TIME = 4000;
|
const SHOW_IMPORT_MSG_TIME = 4000;
|
||||||
|
|
||||||
export type CreateServerProps = {
|
type CreateServerProps = {
|
||||||
|
createServers: (servers: ServerWithId[]) => void;
|
||||||
|
servers: ServersMap;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CreateServerDeps = {
|
||||||
|
ImportServersBtn: FC<ImportServersBtnProps>;
|
||||||
useTimeoutToggle: TimeoutToggle;
|
useTimeoutToggle: TimeoutToggle;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -29,12 +34,14 @@ const ImportResult = ({ variant }: Pick<ResultProps, 'variant'>) => (
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const CreateServerBase: FC<CreateServerProps> = withoutSelectedServer(({ useTimeoutToggle }) => {
|
const CreateServer: FCWithDeps<CreateServerProps, CreateServerDeps> = ({ servers, createServers }) => {
|
||||||
const { servers, createServers } = useServers();
|
const { ImportServersBtn, useTimeoutToggle } = useDependencies(CreateServer);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const goBack = useGoBack();
|
const goBack = useGoBack();
|
||||||
const hasServers = !!Object.keys(servers).length;
|
const hasServers = !!Object.keys(servers).length;
|
||||||
|
// eslint-disable-next-line react-compiler/react-compiler
|
||||||
const [serversImported, setServersImported] = useTimeoutToggle({ delay: SHOW_IMPORT_MSG_TIME });
|
const [serversImported, setServersImported] = useTimeoutToggle({ delay: SHOW_IMPORT_MSG_TIME });
|
||||||
|
// eslint-disable-next-line react-compiler/react-compiler
|
||||||
const [errorImporting, setErrorImporting] = useTimeoutToggle({ delay: SHOW_IMPORT_MSG_TIME });
|
const [errorImporting, setErrorImporting] = useTimeoutToggle({ delay: SHOW_IMPORT_MSG_TIME });
|
||||||
const { flag: isConfirmModalOpen, toggle: toggleConfirmModal } = useToggle();
|
const { flag: isConfirmModalOpen, toggle: toggleConfirmModal } = useToggle();
|
||||||
const [serverData, setServerData] = useState<ServerData>();
|
const [serverData, setServerData] = useState<ServerData>();
|
||||||
@@ -79,6 +86,6 @@ const CreateServerBase: FC<CreateServerProps> = withoutSelectedServer(({ useTime
|
|||||||
/>
|
/>
|
||||||
</NoMenuLayout>
|
</NoMenuLayout>
|
||||||
);
|
);
|
||||||
});
|
};
|
||||||
|
|
||||||
export const CreateServer = withDependencies(CreateServerBase, ['useTimeoutToggle']);
|
export const CreateServerFactory = componentFactory(CreateServer, ['ImportServersBtn', 'useTimeoutToggle']);
|
||||||
|
|||||||
@@ -2,14 +2,21 @@ import { useToggle } from '@shlinkio/shlink-frontend-kit';
|
|||||||
import type { FC, PropsWithChildren } from 'react';
|
import type { FC, PropsWithChildren } from 'react';
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
|
import type { FCWithDeps } from '../container/utils';
|
||||||
|
import { componentFactory, useDependencies } from '../container/utils';
|
||||||
import type { ServerWithId } from './data';
|
import type { ServerWithId } from './data';
|
||||||
import { DeleteServerModal } from './DeleteServerModal';
|
import type { DeleteServerModalProps } from './DeleteServerModal';
|
||||||
|
|
||||||
export type DeleteServerButtonProps = PropsWithChildren<{
|
export type DeleteServerButtonProps = PropsWithChildren<{
|
||||||
server: ServerWithId;
|
server: ServerWithId;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export const DeleteServerButton: FC<DeleteServerButtonProps> = ({ server, children }) => {
|
type DeleteServerButtonDeps = {
|
||||||
|
DeleteServerModal: FC<DeleteServerModalProps>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DeleteServerButton: FCWithDeps<DeleteServerButtonProps, DeleteServerButtonDeps> = ({ server, children }) => {
|
||||||
|
const { DeleteServerModal } = useDependencies(DeleteServerButton);
|
||||||
const { flag: isModalOpen, setToTrue: showModal, setToFalse: hideModal } = useToggle();
|
const { flag: isModalOpen, setToTrue: showModal, setToFalse: hideModal } = useToggle();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const onClose = useCallback((confirmed: boolean) => {
|
const onClose = useCallback((confirmed: boolean) => {
|
||||||
@@ -28,3 +35,5 @@ export const DeleteServerButton: FC<DeleteServerButtonProps> = ({ server, childr
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const DeleteServerButtonFactory = componentFactory(DeleteServerButton, ['DeleteServerModal']);
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { CardModal } from '@shlinkio/shlink-frontend-kit';
|
|||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import type { ServerWithId } from './data';
|
import type { ServerWithId } from './data';
|
||||||
import { useServers } from './reducers/servers';
|
|
||||||
|
|
||||||
export type DeleteServerModalProps = {
|
export type DeleteServerModalProps = {
|
||||||
server: ServerWithId;
|
server: ServerWithId;
|
||||||
@@ -11,8 +10,11 @@ export type DeleteServerModalProps = {
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DeleteServerModal: FC<DeleteServerModalProps> = ({ server, onClose, open }) => {
|
type DeleteServerModalConnectProps = DeleteServerModalProps & {
|
||||||
const { deleteServer } = useServers();
|
deleteServer: (server: ServerWithId) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DeleteServerModal: FC<DeleteServerModalConnectProps> = ({ server, onClose, open, deleteServer }) => {
|
||||||
const onClosed = useCallback((exitAction: ExitAction) => {
|
const onClosed = useCallback((exitAction: ExitAction) => {
|
||||||
if (exitAction === 'confirm') {
|
if (exitAction === 'confirm') {
|
||||||
deleteServer(server);
|
deleteServer(server);
|
||||||
|
|||||||
@@ -1,17 +1,26 @@
|
|||||||
import { Button, useParsedQuery } from '@shlinkio/shlink-frontend-kit';
|
import { Button,useParsedQuery } from '@shlinkio/shlink-frontend-kit';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { NoMenuLayout } from '../common/NoMenuLayout';
|
import { NoMenuLayout } from '../common/NoMenuLayout';
|
||||||
|
import type { FCWithDeps } from '../container/utils';
|
||||||
|
import { componentFactory } from '../container/utils';
|
||||||
import { useGoBack } from '../utils/helpers/hooks';
|
import { useGoBack } from '../utils/helpers/hooks';
|
||||||
import type { ServerData } from './data';
|
import type { ServerData } from './data';
|
||||||
import { isServerWithId } from './data';
|
import { isServerWithId } from './data';
|
||||||
import { ServerForm } from './helpers/ServerForm';
|
import { ServerForm } from './helpers/ServerForm';
|
||||||
|
import type { WithSelectedServerProps } from './helpers/withSelectedServer';
|
||||||
import { withSelectedServer } from './helpers/withSelectedServer';
|
import { withSelectedServer } from './helpers/withSelectedServer';
|
||||||
import { useSelectedServer } from './reducers/selectedServer';
|
|
||||||
import { useServers } from './reducers/servers';
|
|
||||||
|
|
||||||
export const EditServer: FC = withSelectedServer(() => {
|
type EditServerProps = WithSelectedServerProps & {
|
||||||
const { editServer } = useServers();
|
editServer: (serverId: string, serverData: ServerData) => void;
|
||||||
const { selectServer, selectedServer } = useSelectedServer();
|
};
|
||||||
|
|
||||||
|
type EditServerDeps = {
|
||||||
|
ServerError: FC;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EditServer: FCWithDeps<EditServerProps, EditServerDeps> = withSelectedServer((
|
||||||
|
{ editServer, selectedServer, selectServer },
|
||||||
|
) => {
|
||||||
const goBack = useGoBack();
|
const goBack = useGoBack();
|
||||||
const { reconnect } = useParsedQuery<{ reconnect?: 'true' }>();
|
const { reconnect } = useParsedQuery<{ reconnect?: 'true' }>();
|
||||||
|
|
||||||
@@ -40,3 +49,5 @@ export const EditServer: FC = withSelectedServer(() => {
|
|||||||
</NoMenuLayout>
|
</NoMenuLayout>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const EditServerFactory = componentFactory(EditServer, ['ServerError']);
|
||||||
|
|||||||
@@ -5,25 +5,33 @@ import { Button, Result, SearchInput, SimpleCard, Table } from '@shlinkio/shlink
|
|||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { NoMenuLayout } from '../common/NoMenuLayout';
|
import { NoMenuLayout } from '../common/NoMenuLayout';
|
||||||
import { withDependencies } from '../container/context';
|
import type { FCWithDeps } from '../container/utils';
|
||||||
import { ImportServersBtn } from './helpers/ImportServersBtn';
|
import { componentFactory, useDependencies } from '../container/utils';
|
||||||
import { withoutSelectedServer } from './helpers/withoutSelectedServer';
|
import type { ServersMap } from './data';
|
||||||
import { ManageServersRow } from './ManageServersRow';
|
import type { ImportServersBtnProps } from './helpers/ImportServersBtn';
|
||||||
import { useServers } from './reducers/servers';
|
import type { ManageServersRowProps } from './ManageServersRow';
|
||||||
import type { ServersExporter } from './services/ServersExporter';
|
import type { ServersExporter } from './services/ServersExporter';
|
||||||
|
|
||||||
export type ManageServersProps = {
|
type ManageServersProps = {
|
||||||
|
servers: ServersMap;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ManageServersDeps = {
|
||||||
ServersExporter: ServersExporter;
|
ServersExporter: ServersExporter;
|
||||||
|
ImportServersBtn: FC<ImportServersBtnProps>;
|
||||||
useTimeoutToggle: TimeoutToggle;
|
useTimeoutToggle: TimeoutToggle;
|
||||||
|
ManageServersRow: FC<ManageServersRowProps>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const SHOW_IMPORT_MSG_TIME = 4000;
|
const SHOW_IMPORT_MSG_TIME = 4000;
|
||||||
|
|
||||||
const ManageServersBase: FC<ManageServersProps> = withoutSelectedServer(({
|
const ManageServers: FCWithDeps<ManageServersProps, ManageServersDeps> = ({ servers }) => {
|
||||||
ServersExporter: serversExporter,
|
const {
|
||||||
useTimeoutToggle,
|
ServersExporter: serversExporter,
|
||||||
}) => {
|
ImportServersBtn,
|
||||||
const { servers } = useServers();
|
useTimeoutToggle,
|
||||||
|
ManageServersRow,
|
||||||
|
} = useDependencies(ManageServers);
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const allServers = useMemo(() => Object.values(servers), [servers]);
|
const allServers = useMemo(() => Object.values(servers), [servers]);
|
||||||
const filteredServers = useMemo(
|
const filteredServers = useMemo(
|
||||||
@@ -31,7 +39,7 @@ const ManageServersBase: FC<ManageServersProps> = withoutSelectedServer(({
|
|||||||
[allServers, searchTerm],
|
[allServers, searchTerm],
|
||||||
);
|
);
|
||||||
const hasAutoConnect = allServers.some(({ autoConnect }) => !!autoConnect);
|
const hasAutoConnect = allServers.some(({ autoConnect }) => !!autoConnect);
|
||||||
|
// eslint-disable-next-line react-compiler/react-compiler
|
||||||
const [errorImporting, setErrorImporting] = useTimeoutToggle({ delay: SHOW_IMPORT_MSG_TIME });
|
const [errorImporting, setErrorImporting] = useTimeoutToggle({ delay: SHOW_IMPORT_MSG_TIME });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -79,6 +87,11 @@ const ManageServersBase: FC<ManageServersProps> = withoutSelectedServer(({
|
|||||||
)}
|
)}
|
||||||
</NoMenuLayout>
|
</NoMenuLayout>
|
||||||
);
|
);
|
||||||
});
|
};
|
||||||
|
|
||||||
export const ManageServers = withDependencies(ManageServersBase, ['ServersExporter', 'useTimeoutToggle']);
|
export const ManageServersFactory = componentFactory(ManageServers, [
|
||||||
|
'ServersExporter',
|
||||||
|
'ImportServersBtn',
|
||||||
|
'useTimeoutToggle',
|
||||||
|
'ManageServersRow',
|
||||||
|
]);
|
||||||
|
|||||||
@@ -3,15 +3,22 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|||||||
import { Table, Tooltip, useTooltip } from '@shlinkio/shlink-frontend-kit';
|
import { Table, Tooltip, useTooltip } from '@shlinkio/shlink-frontend-kit';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
|
import type { FCWithDeps } from '../container/utils';
|
||||||
|
import { componentFactory, useDependencies } from '../container/utils';
|
||||||
import type { ServerWithId } from './data';
|
import type { ServerWithId } from './data';
|
||||||
import { ManageServersRowDropdown } from './ManageServersRowDropdown';
|
import type { ManageServersRowDropdownProps } from './ManageServersRowDropdown';
|
||||||
|
|
||||||
export type ManageServersRowProps = {
|
export type ManageServersRowProps = {
|
||||||
server: ServerWithId;
|
server: ServerWithId;
|
||||||
hasAutoConnect: boolean;
|
hasAutoConnect: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ManageServersRow: FC<ManageServersRowProps> = ({ server, hasAutoConnect }) => {
|
type ManageServersRowDeps = {
|
||||||
|
ManageServersRowDropdown: FC<ManageServersRowDropdownProps>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ManageServersRow: FCWithDeps<ManageServersRowProps, ManageServersRowDeps> = ({ server, hasAutoConnect }) => {
|
||||||
|
const { ManageServersRowDropdown } = useDependencies(ManageServersRow);
|
||||||
const { anchor, tooltip } = useTooltip();
|
const { anchor, tooltip } = useTooltip();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -24,7 +31,6 @@ export const ManageServersRow: FC<ManageServersRowProps> = ({ server, hasAutoCon
|
|||||||
icon={checkIcon}
|
icon={checkIcon}
|
||||||
className="text-lm-brand dark:text-dm-brand"
|
className="text-lm-brand dark:text-dm-brand"
|
||||||
{...anchor}
|
{...anchor}
|
||||||
data-testid="auto-connect"
|
|
||||||
/>
|
/>
|
||||||
<Tooltip {...tooltip}>Auto-connect to this server</Tooltip>
|
<Tooltip {...tooltip}>Auto-connect to this server</Tooltip>
|
||||||
</>
|
</>
|
||||||
@@ -41,3 +47,5 @@ export const ManageServersRow: FC<ManageServersRowProps> = ({ server, hasAutoCon
|
|||||||
</Table.Row>
|
</Table.Row>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const ManageServersRowFactory = componentFactory(ManageServersRow, ['ManageServersRowDropdown']);
|
||||||
|
|||||||
@@ -6,18 +6,29 @@ import {
|
|||||||
faPlug as connectIcon,
|
faPlug as connectIcon,
|
||||||
} from '@fortawesome/free-solid-svg-icons';
|
} from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { RowDropdown, useToggle } from '@shlinkio/shlink-frontend-kit';
|
import { RowDropdown,useToggle } from '@shlinkio/shlink-frontend-kit';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
|
import type { FCWithDeps } from '../container/utils';
|
||||||
|
import { componentFactory, useDependencies } from '../container/utils';
|
||||||
import type { ServerWithId } from './data';
|
import type { ServerWithId } from './data';
|
||||||
import { DeleteServerModal } from './DeleteServerModal';
|
import type { DeleteServerModalProps } from './DeleteServerModal';
|
||||||
import { useServers } from './reducers/servers';
|
|
||||||
|
|
||||||
export type ManageServersRowDropdownProps = {
|
export type ManageServersRowDropdownProps = {
|
||||||
server: ServerWithId;
|
server: ServerWithId;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ManageServersRowDropdown: FC<ManageServersRowDropdownProps> = ({ server }) => {
|
type ManageServersRowDropdownConnectProps = ManageServersRowDropdownProps & {
|
||||||
const { setAutoConnect } = useServers();
|
setAutoConnect: (server: ServerWithId, autoConnect: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ManageServersRowDropdownDeps = {
|
||||||
|
DeleteServerModal: FC<DeleteServerModalProps>
|
||||||
|
};
|
||||||
|
|
||||||
|
const ManageServersRowDropdown: FCWithDeps<ManageServersRowDropdownConnectProps, ManageServersRowDropdownDeps> = (
|
||||||
|
{ server, setAutoConnect },
|
||||||
|
) => {
|
||||||
|
const { DeleteServerModal } = useDependencies(ManageServersRowDropdown);
|
||||||
const { flag: isModalOpen, setToTrue: showModal, setToFalse: hideModal } = useToggle();
|
const { flag: isModalOpen, setToTrue: showModal, setToFalse: hideModal } = useToggle();
|
||||||
const serverUrl = `/server/${server.id}`;
|
const serverUrl = `/server/${server.id}`;
|
||||||
const { autoConnect: isAutoConnect } = server;
|
const { autoConnect: isAutoConnect } = server;
|
||||||
@@ -45,3 +56,5 @@ export const ManageServersRowDropdown: FC<ManageServersRowDropdownProps> = ({ se
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const ManageServersRowDropdownFactory = componentFactory(ManageServersRowDropdown, ['DeleteServerModal']);
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import { faPlus as plusIcon, faServer as serverIcon } from '@fortawesome/free-solid-svg-icons';
|
import { faPlus as plusIcon, faServer as serverIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { Dropdown, NavBar } from '@shlinkio/shlink-frontend-kit';
|
import { Dropdown, NavBar } from '@shlinkio/shlink-frontend-kit';
|
||||||
import type { FC } from 'react';
|
import type { SelectedServer, ServersMap } from './data';
|
||||||
import { getServerId } from './data';
|
import { getServerId } from './data';
|
||||||
import { useSelectedServer } from './reducers/selectedServer';
|
|
||||||
import { useServers } from './reducers/servers';
|
|
||||||
|
|
||||||
export const ServersDropdown: FC = () => {
|
export interface ServersDropdownProps {
|
||||||
const { servers } = useServers();
|
servers: ServersMap;
|
||||||
|
selectedServer: SelectedServer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ServersDropdown = ({ servers, selectedServer }: ServersDropdownProps) => {
|
||||||
const serversList = Object.values(servers);
|
const serversList = Object.values(servers);
|
||||||
const { selectedServer } = useSelectedServer();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NavBar.Dropdown buttonContent={(
|
<NavBar.Dropdown buttonContent={(
|
||||||
|
|||||||
@@ -26,16 +26,18 @@ const ServerListItem = ({ id, name }: { id: string; name: string }) => (
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const ServersListGroup: FC<ServersListGroupProps> = ({ servers, borderless }) => (
|
export const ServersListGroup: FC<ServersListGroupProps> = ({ servers, borderless }) => (
|
||||||
servers.length > 0 && (
|
<>
|
||||||
<div
|
{servers.length > 0 && (
|
||||||
data-testid="list"
|
<div
|
||||||
className={clsx(
|
data-testid="list"
|
||||||
'w-full border-lm-border dark:border-dm-border',
|
className={clsx(
|
||||||
'md:max-h-56 md:overflow-y-auto -mb-1 scroll-thin',
|
'w-full border-lm-border dark:border-dm-border',
|
||||||
{ 'border-y': !borderless },
|
'md:max-h-56 md:overflow-y-auto -mb-1 scroll-thin',
|
||||||
)}
|
{ 'border-y': !borderless },
|
||||||
>
|
)}
|
||||||
{servers.map(({ id, name }) => <ServerListItem key={id} id={id} name={name} />)}
|
>
|
||||||
</div>
|
{servers.map(({ id, name }) => <ServerListItem key={id} id={id} name={name} />)}
|
||||||
)
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export const DuplicatedServersModal: FC<DuplicatedServersModalProps> = (
|
|||||||
cancelText={hasMultipleServers ? 'Ignore duplicates' : 'Discard'}
|
cancelText={hasMultipleServers ? 'Ignore duplicates' : 'Discard'}
|
||||||
>
|
>
|
||||||
<p>{hasMultipleServers ? 'The next servers already exist:' : 'There is already a server with:'}</p>
|
<p>{hasMultipleServers ? 'The next servers already exist:' : 'There is already a server with:'}</p>
|
||||||
<ul className="list-disc my-4 pl-5">
|
<ul className="list-disc mt-4">
|
||||||
{duplicatedServers.map(({ url, apiKey }, index) => (!hasMultipleServers ? (
|
{duplicatedServers.map(({ url, apiKey }, index) => (!hasMultipleServers ? (
|
||||||
<Fragment key={index}>
|
<Fragment key={index}>
|
||||||
<li>URL: <b>{url}</b></li>
|
<li>URL: <b>{url}</b></li>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { faFileUpload as importIcon } from '@fortawesome/free-solid-svg-icons';
|
import { faFileUpload as importIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { Button, Tooltip, useToggle, useTooltip } from '@shlinkio/shlink-frontend-kit';
|
import { Button, Tooltip, useToggle , useTooltip } from '@shlinkio/shlink-frontend-kit';
|
||||||
import type { ChangeEvent, FC, PropsWithChildren } from 'react';
|
import type { ChangeEvent, PropsWithChildren } from 'react';
|
||||||
import { useCallback, useRef, useState } from 'react';
|
import { useCallback, useRef, useState } from 'react';
|
||||||
import { withDependencies } from '../../container/context';
|
import type { FCWithDeps } from '../../container/utils';
|
||||||
import type { ServerData } from '../data';
|
import { componentFactory, useDependencies } from '../../container/utils';
|
||||||
import { useServers } from '../reducers/servers';
|
import type { ServerData, ServersMap, ServerWithId } from '../data';
|
||||||
import type { ServersImporter } from '../services/ServersImporter';
|
import type { ServersImporter } from '../services/ServersImporter';
|
||||||
import { DuplicatedServersModal } from './DuplicatedServersModal';
|
import { DuplicatedServersModal } from './DuplicatedServersModal';
|
||||||
import { dedupServers, ensureUniqueIds } from './index';
|
import { dedupServers, ensureUniqueIds } from './index';
|
||||||
@@ -15,20 +15,27 @@ export type ImportServersBtnProps = PropsWithChildren<{
|
|||||||
onError?: (error: Error) => void;
|
onError?: (error: Error) => void;
|
||||||
tooltipPlacement?: 'top' | 'bottom';
|
tooltipPlacement?: 'top' | 'bottom';
|
||||||
className?: string;
|
className?: string;
|
||||||
|
|
||||||
// Injected
|
|
||||||
ServersImporter: ServersImporter
|
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
const ImportServersBtnBase: FC<ImportServersBtnProps> = ({
|
type ImportServersBtnConnectProps = ImportServersBtnProps & {
|
||||||
|
createServers: (servers: ServerWithId[]) => void;
|
||||||
|
servers: ServersMap;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ImportServersBtnDeps = {
|
||||||
|
ServersImporter: ServersImporter
|
||||||
|
};
|
||||||
|
|
||||||
|
const ImportServersBtn: FCWithDeps<ImportServersBtnConnectProps, ImportServersBtnDeps> = ({
|
||||||
|
createServers,
|
||||||
|
servers,
|
||||||
children,
|
children,
|
||||||
onImport,
|
onImport,
|
||||||
onError = () => {},
|
onError = () => {},
|
||||||
tooltipPlacement = 'bottom',
|
tooltipPlacement = 'bottom',
|
||||||
className = '',
|
className = '',
|
||||||
ServersImporter: serversImporter,
|
|
||||||
}) => {
|
}) => {
|
||||||
const { createServers, servers } = useServers();
|
const { ServersImporter: serversImporter } = useDependencies(ImportServersBtn);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const { anchor, tooltip } = useTooltip({ placement: tooltipPlacement });
|
const { anchor, tooltip } = useTooltip({ placement: tooltipPlacement });
|
||||||
const [duplicatedServers, setDuplicatedServers] = useState<ServerData[]>([]);
|
const [duplicatedServers, setDuplicatedServers] = useState<ServerData[]>([]);
|
||||||
@@ -104,4 +111,4 @@ const ImportServersBtnBase: FC<ImportServersBtnProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ImportServersBtn = withDependencies(ImportServersBtnBase, ['ServersImporter']);
|
export const ImportServersBtnFactory = componentFactory(ImportServersBtn, ['ServersImporter']);
|
||||||
|
|||||||
@@ -2,15 +2,24 @@ import { Card, Message } from '@shlinkio/shlink-frontend-kit';
|
|||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
import { NoMenuLayout } from '../../common/NoMenuLayout';
|
import { NoMenuLayout } from '../../common/NoMenuLayout';
|
||||||
|
import type { FCWithDeps } from '../../container/utils';
|
||||||
|
import { componentFactory, useDependencies } from '../../container/utils';
|
||||||
|
import type { SelectedServer, ServersMap } from '../data';
|
||||||
import { isServerWithId } from '../data';
|
import { isServerWithId } from '../data';
|
||||||
import { DeleteServerButton } from '../DeleteServerButton';
|
import type { DeleteServerButtonProps } from '../DeleteServerButton';
|
||||||
import { useSelectedServer } from '../reducers/selectedServer';
|
|
||||||
import { useServers } from '../reducers/servers';
|
|
||||||
import { ServersListGroup } from '../ServersListGroup';
|
import { ServersListGroup } from '../ServersListGroup';
|
||||||
|
|
||||||
export const ServerError: FC = () => {
|
type ServerErrorProps = {
|
||||||
const { servers } = useServers();
|
servers: ServersMap;
|
||||||
const { selectedServer } = useSelectedServer();
|
selectedServer: SelectedServer;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ServerErrorDeps = {
|
||||||
|
DeleteServerButton: FC<DeleteServerButtonProps>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ServerError: FCWithDeps<ServerErrorProps, ServerErrorDeps> = ({ servers, selectedServer }) => {
|
||||||
|
const { DeleteServerButton } = useDependencies(ServerError);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NoMenuLayout>
|
<NoMenuLayout>
|
||||||
@@ -45,3 +54,5 @@ export const ServerError: FC = () => {
|
|||||||
</NoMenuLayout>
|
</NoMenuLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const ServerErrorFactory = componentFactory(ServerError, ['DeleteServerButton']);
|
||||||
|
|||||||
@@ -3,14 +3,27 @@ import type { FC } from 'react';
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useParams } from 'react-router';
|
import { useParams } from 'react-router';
|
||||||
import { NoMenuLayout } from '../../common/NoMenuLayout';
|
import { NoMenuLayout } from '../../common/NoMenuLayout';
|
||||||
|
import type { FCWithDeps } from '../../container/utils';
|
||||||
|
import { useDependencies } from '../../container/utils';
|
||||||
|
import type { SelectedServer } from '../data';
|
||||||
import { isNotFoundServer } from '../data';
|
import { isNotFoundServer } from '../data';
|
||||||
import { useSelectedServer } from '../reducers/selectedServer';
|
|
||||||
import { ServerError } from './ServerError';
|
|
||||||
|
|
||||||
export function withSelectedServer<T extends object>(WrappedComponent: FC<T>) {
|
export type WithSelectedServerProps = {
|
||||||
const ComponentWrapper: FC<T> = (props) => {
|
selectServer: (serverId: string) => void;
|
||||||
|
selectedServer: SelectedServer;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WithSelectedServerPropsDeps = {
|
||||||
|
ServerError: FC;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function withSelectedServer<T extends object>(
|
||||||
|
WrappedComponent: FCWithDeps<WithSelectedServerProps & T, WithSelectedServerPropsDeps>,
|
||||||
|
) {
|
||||||
|
const ComponentWrapper: FCWithDeps<WithSelectedServerProps & T, WithSelectedServerPropsDeps> = (props) => {
|
||||||
|
const { ServerError } = useDependencies(ComponentWrapper);
|
||||||
const params = useParams<{ serverId: string }>();
|
const params = useParams<{ serverId: string }>();
|
||||||
const { selectServer, selectedServer } = useSelectedServer();
|
const { selectServer, selectedServer } = props;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (params.serverId) {
|
if (params.serverId) {
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useSelectedServer } from '../reducers/selectedServer';
|
|
||||||
|
|
||||||
export function withoutSelectedServer<T extends object>(WrappedComponent: FC<T>) {
|
interface WithoutSelectedServerProps {
|
||||||
return (props: T) => {
|
resetSelectedServer: () => unknown;
|
||||||
const { resetSelectedServer } = useSelectedServer();
|
}
|
||||||
|
|
||||||
|
export function withoutSelectedServer<T extends object>(WrappedComponent: FC<WithoutSelectedServerProps & T>) {
|
||||||
|
return (props: WithoutSelectedServerProps & T) => {
|
||||||
|
const { resetSelectedServer } = props;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
resetSelectedServer();
|
resetSelectedServer();
|
||||||
}, [resetSelectedServer]);
|
}, [resetSelectedServer]);
|
||||||
|
|||||||
@@ -1,46 +1,21 @@
|
|||||||
import type { HttpClient } from '@shlinkio/shlink-js-sdk';
|
import type { HttpClient } from '@shlinkio/shlink-js-sdk';
|
||||||
import { useCallback, useEffect, useRef } from 'react';
|
|
||||||
import pack from '../../../package.json';
|
import pack from '../../../package.json';
|
||||||
import { useDependencies } from '../../container/context';
|
import { createAsyncThunk } from '../../utils/helpers/redux';
|
||||||
import { useAppDispatch } from '../../store';
|
|
||||||
import { createAsyncThunk } from '../../store/helpers';
|
|
||||||
import { hasServerData } from '../data';
|
import { hasServerData } from '../data';
|
||||||
import { ensureUniqueIds } from '../helpers';
|
import { ensureUniqueIds } from '../helpers';
|
||||||
import { createServers, useServers } from './servers';
|
import { createServers } from './servers';
|
||||||
|
|
||||||
const responseToServersList = (data: any) => ensureUniqueIds(
|
const responseToServersList = (data: any) => ensureUniqueIds(
|
||||||
{},
|
{},
|
||||||
(Array.isArray(data) ? data.filter(hasServerData) : []),
|
(Array.isArray(data) ? data.filter(hasServerData) : []),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const fetchServers = createAsyncThunk(
|
export const fetchServers = (httpClient: HttpClient) => createAsyncThunk(
|
||||||
'shlink/remoteServers/fetchServers',
|
'shlink/remoteServers/fetchServers',
|
||||||
async (httpClient: HttpClient, { dispatch }): Promise<void> => {
|
async (_: void, { dispatch }): Promise<void> => {
|
||||||
const resp = await httpClient.jsonRequest<any>(`${pack.homepage}/servers.json`);
|
const resp = await httpClient.jsonRequest<any>(`${pack.homepage}/servers.json`);
|
||||||
const result = responseToServersList(resp);
|
const result = responseToServersList(resp);
|
||||||
|
|
||||||
dispatch(createServers(result));
|
dispatch(createServers(result));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
export const useRemoteServers = () => {
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
const [httpClient] = useDependencies<[HttpClient]>('HttpClient');
|
|
||||||
const dispatchFetchServer = useCallback(() => dispatch(fetchServers(httpClient)), [dispatch, httpClient]);
|
|
||||||
|
|
||||||
return { fetchServers: dispatchFetchServer };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useLoadRemoteServers = () => {
|
|
||||||
const { fetchServers } = useRemoteServers();
|
|
||||||
const { servers } = useServers();
|
|
||||||
const initialServers = useRef(servers);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Try to fetch the remote servers if the list is empty during first render.
|
|
||||||
// We use a ref because we don't care if the servers list becomes empty later.
|
|
||||||
if (Object.keys(initialServers.current).length === 0) {
|
|
||||||
fetchServers();
|
|
||||||
}
|
|
||||||
}, [fetchServers]);
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import { createAction, createSlice } from '@reduxjs/toolkit';
|
import { createAction, createSlice } from '@reduxjs/toolkit';
|
||||||
import { memoizeWith } from '@shlinkio/data-manipulation';
|
import { memoizeWith } from '@shlinkio/data-manipulation';
|
||||||
import type { ShlinkHealth } from '@shlinkio/shlink-web-component/api-contract';
|
import type { ShlinkHealth } from '@shlinkio/shlink-web-component/api-contract';
|
||||||
import { useCallback } from 'react';
|
|
||||||
import type { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
import type { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||||
import { useDependencies } from '../../container/context';
|
import { createAsyncThunk } from '../../utils/helpers/redux';
|
||||||
import { useAppDispatch, useAppSelector } from '../../store';
|
|
||||||
import { createAsyncThunk } from '../../store/helpers';
|
|
||||||
import { versionToPrintable, versionToSemVer as toSemVer } from '../../utils/helpers/version';
|
import { versionToPrintable, versionToSemVer as toSemVer } from '../../utils/helpers/version';
|
||||||
import type { SelectedServer, ServerWithId } from '../data';
|
import type { SelectedServer, ServerWithId } from '../data';
|
||||||
|
|
||||||
@@ -32,14 +29,9 @@ const initialState: SelectedServer = null;
|
|||||||
|
|
||||||
export const resetSelectedServer = createAction<void>(`${REDUCER_PREFIX}/resetSelectedServer`);
|
export const resetSelectedServer = createAction<void>(`${REDUCER_PREFIX}/resetSelectedServer`);
|
||||||
|
|
||||||
export type SelectServerOptions = {
|
export const selectServer = (buildShlinkApiClient: ShlinkApiClientBuilder) => createAsyncThunk(
|
||||||
serverId: string;
|
|
||||||
buildShlinkApiClient: ShlinkApiClientBuilder;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const selectServer = createAsyncThunk(
|
|
||||||
`${REDUCER_PREFIX}/selectServer`,
|
`${REDUCER_PREFIX}/selectServer`,
|
||||||
async ({ serverId, buildShlinkApiClient }: SelectServerOptions, { dispatch, getState }): Promise<SelectedServer> => {
|
async (serverId: string, { dispatch, getState }): Promise<SelectedServer> => {
|
||||||
dispatch(resetSelectedServer());
|
dispatch(resetSelectedServer());
|
||||||
|
|
||||||
const { servers } = getState();
|
const { servers } = getState();
|
||||||
@@ -64,29 +56,14 @@ export const selectServer = createAsyncThunk(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
export const { reducer: selectedServerReducer } = createSlice({
|
type SelectServerThunk = ReturnType<typeof selectServer>;
|
||||||
|
|
||||||
|
export const selectedServerReducerCreator = (selectServerThunk: SelectServerThunk) => createSlice({
|
||||||
name: REDUCER_PREFIX,
|
name: REDUCER_PREFIX,
|
||||||
initialState: initialState as SelectedServer,
|
initialState,
|
||||||
reducers: {},
|
reducers: {},
|
||||||
extraReducers: (builder) => {
|
extraReducers: (builder) => {
|
||||||
builder.addCase(resetSelectedServer, () => initialState);
|
builder.addCase(resetSelectedServer, () => initialState);
|
||||||
builder.addCase(selectServer.fulfilled, (_, { payload }) => payload);
|
builder.addCase(selectServerThunk.fulfilled, (_, { payload }) => payload as any);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const useSelectedServer = () => {
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
const [buildShlinkApiClient] = useDependencies<[ShlinkApiClientBuilder]>('buildShlinkApiClient');
|
|
||||||
const dispatchResetSelectedServer = useCallback(() => dispatch(resetSelectedServer()), [dispatch]);
|
|
||||||
const dispatchSelectServer = useCallback(
|
|
||||||
(serverId: string) => dispatch(selectServer({ serverId, buildShlinkApiClient })),
|
|
||||||
[buildShlinkApiClient, dispatch],
|
|
||||||
);
|
|
||||||
const selectedServer = useAppSelector(({ selectedServer }) => selectedServer);
|
|
||||||
|
|
||||||
return {
|
|
||||||
selectedServer,
|
|
||||||
resetSelectedServer: dispatchResetSelectedServer,
|
|
||||||
selectServer: dispatchSelectServer,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,23 +1,21 @@
|
|||||||
import type { PayloadAction } from '@reduxjs/toolkit';
|
import type { PayloadAction } from '@reduxjs/toolkit';
|
||||||
import { createSlice } from '@reduxjs/toolkit';
|
import { createSlice } from '@reduxjs/toolkit';
|
||||||
import { useCallback } from 'react';
|
|
||||||
import { useAppDispatch, useAppSelector } from '../../store';
|
|
||||||
import type { ServerData, ServersMap, ServerWithId } from '../data';
|
import type { ServerData, ServersMap, ServerWithId } from '../data';
|
||||||
import { serversListToMap } from '../helpers';
|
import { serversListToMap } from '../helpers';
|
||||||
|
|
||||||
type EditServer = {
|
interface EditServer {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
serverData: Partial<ServerData>;
|
serverData: Partial<ServerData>;
|
||||||
};
|
}
|
||||||
|
|
||||||
type SetAutoConnect = {
|
interface SetAutoConnect {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
autoConnect: boolean;
|
autoConnect: boolean;
|
||||||
};
|
}
|
||||||
|
|
||||||
const initialState: ServersMap = {};
|
const initialState: ServersMap = {};
|
||||||
|
|
||||||
export const { actions, reducer: serversReducer } = createSlice({
|
export const { actions, reducer } = createSlice({
|
||||||
name: 'shlink/servers',
|
name: 'shlink/servers',
|
||||||
initialState,
|
initialState,
|
||||||
reducers: {
|
reducers: {
|
||||||
@@ -67,19 +65,4 @@ export const { actions, reducer: serversReducer } = createSlice({
|
|||||||
|
|
||||||
export const { editServer, deleteServer, setAutoConnect, createServers } = actions;
|
export const { editServer, deleteServer, setAutoConnect, createServers } = actions;
|
||||||
|
|
||||||
export const useServers = () => {
|
export const serversReducer = reducer;
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
const servers = useAppSelector((state) => state.servers);
|
|
||||||
const editServer = useCallback(
|
|
||||||
(serverId: string, serverData: Partial<ServerData>) => dispatch(actions.editServer(serverId, serverData)),
|
|
||||||
[dispatch],
|
|
||||||
);
|
|
||||||
const deleteServer = useCallback((server: ServerWithId) => dispatch(actions.deleteServer(server)), [dispatch]);
|
|
||||||
const setAutoConnect = useCallback(
|
|
||||||
(serverData: ServerWithId, autoConnect: boolean) => dispatch(actions.setAutoConnect(serverData, autoConnect)),
|
|
||||||
[dispatch],
|
|
||||||
);
|
|
||||||
const createServers = useCallback((servers: ServerWithId[]) => dispatch(actions.createServers(servers)), [dispatch]);
|
|
||||||
|
|
||||||
return { servers, editServer, deleteServer, setAutoConnect, createServers };
|
|
||||||
};
|
|
||||||
|
|||||||
73
src/servers/services/provideServices.ts
Normal file
73
src/servers/services/provideServices.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import type Bottle from 'bottlejs';
|
||||||
|
import type { ConnectDecorator } from '../../container/types';
|
||||||
|
import { CreateServerFactory } from '../CreateServer';
|
||||||
|
import { DeleteServerButtonFactory } from '../DeleteServerButton';
|
||||||
|
import { DeleteServerModal } from '../DeleteServerModal';
|
||||||
|
import { EditServerFactory } from '../EditServer';
|
||||||
|
import { ImportServersBtnFactory } from '../helpers/ImportServersBtn';
|
||||||
|
import { ServerErrorFactory } from '../helpers/ServerError';
|
||||||
|
import { withoutSelectedServer } from '../helpers/withoutSelectedServer';
|
||||||
|
import { ManageServersFactory } from '../ManageServers';
|
||||||
|
import { ManageServersRowFactory } from '../ManageServersRow';
|
||||||
|
import { ManageServersRowDropdownFactory } from '../ManageServersRowDropdown';
|
||||||
|
import { fetchServers } from '../reducers/remoteServers';
|
||||||
|
import {
|
||||||
|
resetSelectedServer,
|
||||||
|
selectedServerReducerCreator,
|
||||||
|
selectServer,
|
||||||
|
} from '../reducers/selectedServer';
|
||||||
|
import { createServers, deleteServer, editServer, setAutoConnect } from '../reducers/servers';
|
||||||
|
import { ServersDropdown } from '../ServersDropdown';
|
||||||
|
import { ServersExporter } from './ServersExporter';
|
||||||
|
import { ServersImporter } from './ServersImporter';
|
||||||
|
|
||||||
|
export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||||
|
// Components
|
||||||
|
bottle.factory('ManageServers', ManageServersFactory);
|
||||||
|
bottle.decorator('ManageServers', withoutSelectedServer);
|
||||||
|
bottle.decorator('ManageServers', connect(['selectedServer', 'servers'], ['resetSelectedServer']));
|
||||||
|
|
||||||
|
bottle.factory('ManageServersRow', ManageServersRowFactory);
|
||||||
|
|
||||||
|
bottle.factory('ManageServersRowDropdown', ManageServersRowDropdownFactory);
|
||||||
|
bottle.decorator('ManageServersRowDropdown', connect(null, ['setAutoConnect']));
|
||||||
|
|
||||||
|
bottle.factory('CreateServer', CreateServerFactory);
|
||||||
|
bottle.decorator('CreateServer', withoutSelectedServer);
|
||||||
|
bottle.decorator('CreateServer', connect(['selectedServer', 'servers'], ['createServers', 'resetSelectedServer']));
|
||||||
|
|
||||||
|
bottle.factory('EditServer', EditServerFactory);
|
||||||
|
bottle.decorator('EditServer', connect(['selectedServer'], ['editServer', 'selectServer', 'resetSelectedServer']));
|
||||||
|
|
||||||
|
bottle.serviceFactory('ServersDropdown', () => ServersDropdown);
|
||||||
|
bottle.decorator('ServersDropdown', connect(['servers', 'selectedServer']));
|
||||||
|
|
||||||
|
bottle.serviceFactory('DeleteServerModal', () => DeleteServerModal);
|
||||||
|
bottle.decorator('DeleteServerModal', connect(null, ['deleteServer']));
|
||||||
|
|
||||||
|
bottle.factory('DeleteServerButton', DeleteServerButtonFactory);
|
||||||
|
|
||||||
|
bottle.factory('ImportServersBtn', ImportServersBtnFactory);
|
||||||
|
bottle.decorator('ImportServersBtn', connect(['servers'], ['createServers']));
|
||||||
|
|
||||||
|
bottle.factory('ServerError', ServerErrorFactory);
|
||||||
|
bottle.decorator('ServerError', connect(['servers', 'selectedServer']));
|
||||||
|
|
||||||
|
// Services
|
||||||
|
bottle.service('ServersImporter', ServersImporter, 'csvToJson');
|
||||||
|
bottle.service('ServersExporter', ServersExporter, 'Storage', 'window', 'jsonToCsv');
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
bottle.serviceFactory('selectServer', selectServer, 'buildShlinkApiClient', 'loadMercureInfo');
|
||||||
|
bottle.serviceFactory('createServers', () => createServers);
|
||||||
|
bottle.serviceFactory('deleteServer', () => deleteServer);
|
||||||
|
bottle.serviceFactory('editServer', () => editServer);
|
||||||
|
bottle.serviceFactory('setAutoConnect', () => setAutoConnect);
|
||||||
|
bottle.serviceFactory('fetchServers', fetchServers, 'HttpClient');
|
||||||
|
|
||||||
|
bottle.serviceFactory('resetSelectedServer', () => resetSelectedServer);
|
||||||
|
|
||||||
|
// Reducers
|
||||||
|
bottle.serviceFactory('selectedServerReducerCreator', selectedServerReducerCreator, 'selectServer');
|
||||||
|
bottle.serviceFactory('selectedServerReducer', (obj) => obj.reducer, 'selectedServerReducerCreator');
|
||||||
|
};
|
||||||
@@ -1,18 +1,20 @@
|
|||||||
|
import type { Settings as AppSettings } from '@shlinkio/shlink-web-component/settings';
|
||||||
import { ShlinkWebSettings } from '@shlinkio/shlink-web-component/settings';
|
import { ShlinkWebSettings } from '@shlinkio/shlink-web-component/settings';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { NoMenuLayout } from '../common/NoMenuLayout';
|
import { NoMenuLayout } from '../common/NoMenuLayout';
|
||||||
import { DEFAULT_SHORT_URLS_ORDERING, useSettings } from './reducers/settings';
|
import { DEFAULT_SHORT_URLS_ORDERING } from './reducers/settings';
|
||||||
|
|
||||||
export const Settings: FC = () => {
|
export type SettingsProps = {
|
||||||
const { settings, setSettings } = useSettings();
|
settings: AppSettings;
|
||||||
|
setSettings: (newSettings: AppSettings) => void;
|
||||||
return (
|
|
||||||
<NoMenuLayout>
|
|
||||||
<ShlinkWebSettings
|
|
||||||
settings={settings}
|
|
||||||
onUpdateSettings={setSettings}
|
|
||||||
defaultShortUrlsListOrdering={DEFAULT_SHORT_URLS_ORDERING}
|
|
||||||
/>
|
|
||||||
</NoMenuLayout>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const Settings: FC<SettingsProps> = ({ settings, setSettings }) => (
|
||||||
|
<NoMenuLayout>
|
||||||
|
<ShlinkWebSettings
|
||||||
|
settings={settings}
|
||||||
|
onUpdateSettings={setSettings}
|
||||||
|
defaultShortUrlsListOrdering={DEFAULT_SHORT_URLS_ORDERING}
|
||||||
|
/>
|
||||||
|
</NoMenuLayout>
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
export const migrateDeprecatedSettings = (state: any): any => {
|
import type { ShlinkState } from '../../container/types';
|
||||||
|
|
||||||
|
export const migrateDeprecatedSettings = (state: Partial<ShlinkState>): Partial<ShlinkState> => {
|
||||||
|
if (!state.settings) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
// The "last180Days" interval had a typo, with a lowercase d
|
// The "last180Days" interval had a typo, with a lowercase d
|
||||||
if (state.settings?.visits?.defaultInterval === 'last180days') {
|
if (state.settings.visits && (state.settings.visits.defaultInterval as any) === 'last180days') {
|
||||||
state.settings.visits.defaultInterval = 'last180Days';
|
state.settings.visits.defaultInterval = 'last180Days';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ import { createSlice } from '@reduxjs/toolkit';
|
|||||||
import { mergeDeepRight } from '@shlinkio/data-manipulation';
|
import { mergeDeepRight } from '@shlinkio/data-manipulation';
|
||||||
import { getSystemPreferredTheme } from '@shlinkio/shlink-frontend-kit';
|
import { getSystemPreferredTheme } from '@shlinkio/shlink-frontend-kit';
|
||||||
import type { Settings, ShortUrlsListSettings } from '@shlinkio/shlink-web-component/settings';
|
import type { Settings, ShortUrlsListSettings } from '@shlinkio/shlink-web-component/settings';
|
||||||
import { useCallback } from 'react';
|
|
||||||
import { useAppDispatch, useAppSelector } from '../../store';
|
|
||||||
import type { Defined } from '../../utils/types';
|
import type { Defined } from '../../utils/types';
|
||||||
|
|
||||||
type ShortUrlsOrder = Defined<ShortUrlsListSettings['defaultOrdering']>;
|
type ShortUrlsOrder = Defined<ShortUrlsListSettings['defaultOrdering']>;
|
||||||
@@ -20,7 +18,9 @@ const initialState: Settings = {
|
|||||||
realTimeUpdates: {
|
realTimeUpdates: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
shortUrlCreation: {},
|
shortUrlCreation: {
|
||||||
|
validateUrls: false,
|
||||||
|
},
|
||||||
ui: {
|
ui: {
|
||||||
theme: getSystemPreferredTheme(),
|
theme: getSystemPreferredTheme(),
|
||||||
},
|
},
|
||||||
@@ -43,11 +43,3 @@ const { reducer, actions } = createSlice({
|
|||||||
export const { setSettings } = actions;
|
export const { setSettings } = actions;
|
||||||
|
|
||||||
export const settingsReducer = reducer;
|
export const settingsReducer = reducer;
|
||||||
|
|
||||||
export const useSettings = () => {
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
const setSettings = useCallback((settings: Settings) => dispatch(actions.setSettings(settings)), [dispatch]);
|
|
||||||
const settings = useAppSelector((state) => state.settings);
|
|
||||||
|
|
||||||
return { settings, setSettings };
|
|
||||||
};
|
|
||||||
|
|||||||
15
src/settings/services/provideServices.ts
Normal file
15
src/settings/services/provideServices.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import type Bottle from 'bottlejs';
|
||||||
|
import type { ConnectDecorator } from '../../container/types';
|
||||||
|
import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer';
|
||||||
|
import { setSettings } from '../reducers/settings';
|
||||||
|
import { Settings } from '../Settings';
|
||||||
|
|
||||||
|
export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||||
|
// Components
|
||||||
|
bottle.serviceFactory('Settings', () => Settings);
|
||||||
|
bottle.decorator('Settings', withoutSelectedServer);
|
||||||
|
bottle.decorator('Settings', connect(['settings'], ['setSettings', 'resetSelectedServer']));
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
bottle.serviceFactory('setSettings', () => setSettings);
|
||||||
|
};
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import { configureStore } from '@reduxjs/toolkit';
|
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
|
||||||
import type { RLSOptions } from 'redux-localstorage-simple';
|
|
||||||
import { load, save } from 'redux-localstorage-simple';
|
|
||||||
import { migrateDeprecatedSettings } from '../settings/helpers';
|
|
||||||
import { initReducers } from './reducers';
|
|
||||||
|
|
||||||
const localStorageConfig: RLSOptions = {
|
|
||||||
states: ['settings', 'servers'],
|
|
||||||
namespace: 'shlink',
|
|
||||||
namespaceSeparator: '.',
|
|
||||||
debounce: 300,
|
|
||||||
};
|
|
||||||
const getStateFromLocalStorage = () => migrateDeprecatedSettings(load(localStorageConfig));
|
|
||||||
|
|
||||||
const isProduction = process.env.NODE_ENV === 'production';
|
|
||||||
export const setUpStore = (preloadedState = getStateFromLocalStorage()) => configureStore({
|
|
||||||
devTools: !isProduction,
|
|
||||||
reducer: initReducers(),
|
|
||||||
preloadedState,
|
|
||||||
middleware: (defaultMiddlewaresIncludingReduxThunk) =>
|
|
||||||
defaultMiddlewaresIncludingReduxThunk().concat(save(localStorageConfig)),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type StoreType = ReturnType<typeof setUpStore>;
|
|
||||||
export type AppDispatch = StoreType['dispatch'];
|
|
||||||
export type GetState = StoreType['getState'];
|
|
||||||
export type RootState = ReturnType<GetState>;
|
|
||||||
|
|
||||||
// Typed versions of useDispatch() and useSelector()
|
|
||||||
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
|
|
||||||
export const useAppSelector = useSelector.withTypes<RootState>();
|
|
||||||
@@ -8,5 +8,7 @@
|
|||||||
:root {
|
:root {
|
||||||
--footer-height: 2.3rem;
|
--footer-height: 2.3rem;
|
||||||
--footer-margin: .8rem;
|
--footer-margin: .8rem;
|
||||||
|
/* FIXME Remove this once updated to shlink-web-component 0.15.1 */
|
||||||
|
--header-height: 52px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import type { AsyncThunkPayloadCreator } from '@reduxjs/toolkit';
|
import type { AsyncThunkPayloadCreator } from '@reduxjs/toolkit';
|
||||||
import { createAsyncThunk as baseCreateAsyncThunk } from '@reduxjs/toolkit';
|
import { createAsyncThunk as baseCreateAsyncThunk } from '@reduxjs/toolkit';
|
||||||
import type { RootState } from '.';
|
import type { ShlinkState } from '../../container/types';
|
||||||
|
|
||||||
export const createAsyncThunk = <Returned, ThunkArg>(
|
export const createAsyncThunk = <Returned, ThunkArg>(
|
||||||
typePrefix: string,
|
typePrefix: string,
|
||||||
payloadCreator: AsyncThunkPayloadCreator<Returned, ThunkArg, { state: RootState, serializedErrorType: any }>,
|
payloadCreator: AsyncThunkPayloadCreator<Returned, ThunkArg, { state: ShlinkState, serializedErrorType: any }>,
|
||||||
) => baseCreateAsyncThunk(
|
) => baseCreateAsyncThunk(
|
||||||
typePrefix,
|
typePrefix,
|
||||||
payloadCreator,
|
payloadCreator,
|
||||||
16
src/utils/services/provideServices.ts
Normal file
16
src/utils/services/provideServices.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { useTimeoutToggle } from '@shlinkio/shlink-frontend-kit';
|
||||||
|
import type Bottle from 'bottlejs';
|
||||||
|
import { csvToJson, jsonToCsv } from '../helpers/csvjson';
|
||||||
|
import { LocalStorage } from './LocalStorage';
|
||||||
|
import { TagColorsStorage } from './TagColorsStorage';
|
||||||
|
|
||||||
|
export const provideServices = (bottle: Bottle) => {
|
||||||
|
bottle.constant('localStorage', window.localStorage);
|
||||||
|
bottle.service('Storage', LocalStorage, 'localStorage');
|
||||||
|
bottle.service('TagColorsStorage', TagColorsStorage, 'Storage');
|
||||||
|
|
||||||
|
bottle.constant('csvToJson', csvToJson);
|
||||||
|
bottle.constant('jsonToCsv', jsonToCsv);
|
||||||
|
|
||||||
|
bottle.serviceFactory('useTimeoutToggle', () => useTimeoutToggle);
|
||||||
|
};
|
||||||
23
test/__helpers__/MemoryRouterWithParams.tsx
Normal file
23
test/__helpers__/MemoryRouterWithParams.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import type { FC, PropsWithChildren } from 'react';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { MemoryRouter, Route, Routes } from 'react-router';
|
||||||
|
|
||||||
|
export type MemoryRouterWithParamsProps = PropsWithChildren<{
|
||||||
|
params: Record<string, string>;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap any component using useParams() with MemoryRouterWithParams, in order to determine wat the hook should return
|
||||||
|
*/
|
||||||
|
export const MemoryRouterWithParams: FC<MemoryRouterWithParamsProps> = ({ children, params }) => {
|
||||||
|
const pathname = useMemo(() => `/${Object.values(params).join('/')}`, [params]);
|
||||||
|
const pathPattern = useMemo(() => `/:${Object.keys(params).join('/:')}`, [params]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MemoryRouter>
|
||||||
|
<Routes location={{ pathname }}>
|
||||||
|
<Route path={pathPattern} element={children} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
};
|
||||||
8
test/__helpers__/setUpTest.ts
Normal file
8
test/__helpers__/setUpTest.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import type { ReactElement } from 'react';
|
||||||
|
|
||||||
|
export const renderWithEvents = (element: ReactElement) => ({
|
||||||
|
user: userEvent.setup(),
|
||||||
|
...render(element),
|
||||||
|
});
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import type { ShlinkApiClient } from '@shlinkio/shlink-js-sdk';
|
|
||||||
import type { RenderOptions } from '@testing-library/react';
|
|
||||||
import { render } from '@testing-library/react';
|
|
||||||
import userEvent from '@testing-library/user-event';
|
|
||||||
import { fromPartial } from '@total-typescript/shoehorn';
|
|
||||||
import type { PropsWithChildren, ReactElement } from 'react';
|
|
||||||
import { Provider } from 'react-redux';
|
|
||||||
import { ContainerProvider } from '../../src/container/context';
|
|
||||||
import type { RootState } from '../../src/store';
|
|
||||||
import { setUpStore } from '../../src/store';
|
|
||||||
|
|
||||||
export const renderWithEvents = (element: ReactElement, options?: RenderOptions) => ({
|
|
||||||
user: userEvent.setup(),
|
|
||||||
...render(element, options),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type RenderOptionsWithState = Omit<RenderOptions, 'wrapper'> & {
|
|
||||||
/** Initial state for the redux store */
|
|
||||||
initialState?: Partial<RootState>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* If provided, it will set this as the `buildShlinkApiClient` dependency in the `ContainerProvider`.
|
|
||||||
* If more dependencies are needed, then explicitly define your own `ContainerProvider` and make sure it includes a
|
|
||||||
* `buildShlinkApiClient` service.
|
|
||||||
*
|
|
||||||
* Defaults to vi.fn()
|
|
||||||
*/
|
|
||||||
buildShlinkApiClient?: () => ShlinkApiClient;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render provided ReactElement wrapped in a redux `Provider` and a `ContainerProvider` with a single
|
|
||||||
* `buildShlinkApiClient` dependency.
|
|
||||||
*/
|
|
||||||
export const renderWithStore = (
|
|
||||||
element: ReactElement,
|
|
||||||
{ initialState = {}, buildShlinkApiClient = vi.fn(), ...options }: RenderOptionsWithState = {},
|
|
||||||
) => {
|
|
||||||
const store = setUpStore(initialState);
|
|
||||||
const Wrapper = ({ children }: PropsWithChildren) => (
|
|
||||||
<ContainerProvider value={fromPartial({ buildShlinkApiClient })}>
|
|
||||||
<Provider store={store}>{children}</Provider>
|
|
||||||
</ContainerProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
store,
|
|
||||||
...renderWithEvents(element, { ...options, wrapper: Wrapper }),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,51 +1,50 @@
|
|||||||
import type { HttpClient } from '@shlinkio/shlink-js-sdk';
|
import { act, render, screen } from '@testing-library/react';
|
||||||
import { act, screen } from '@testing-library/react';
|
|
||||||
import { fromPartial } from '@total-typescript/shoehorn';
|
import { fromPartial } from '@total-typescript/shoehorn';
|
||||||
import { MemoryRouter } from 'react-router';
|
import { MemoryRouter } from 'react-router';
|
||||||
import { App } from '../../src/app/App';
|
import { AppFactory } from '../../src/app/App';
|
||||||
import { ContainerProvider } from '../../src/container/context';
|
|
||||||
import type { ServerWithId } from '../../src/servers/data';
|
|
||||||
import { checkAccessibility } from '../__helpers__/accessibility';
|
import { checkAccessibility } from '../__helpers__/accessibility';
|
||||||
import { renderWithStore } from '../__helpers__/setUpTest';
|
|
||||||
|
|
||||||
vi.mock(import('../../src/common/ShlinkWebComponentContainer'), () => ({
|
|
||||||
ShlinkWebComponentContainer: () => <span>ShlinkWebComponentContainer</span>,
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('<App />', () => {
|
describe('<App />', () => {
|
||||||
const setUp = async (activeRoute = '/') => act(() => renderWithStore(
|
const App = AppFactory(
|
||||||
|
fromPartial({
|
||||||
|
MainHeader: () => <>MainHeader</>,
|
||||||
|
Home: () => <>Home</>,
|
||||||
|
ShlinkWebComponentContainer: () => <>ShlinkWebComponentContainer</>,
|
||||||
|
CreateServer: () => <>CreateServer</>,
|
||||||
|
EditServer: () => <>EditServer</>,
|
||||||
|
Settings: () => <>SettingsComp</>,
|
||||||
|
ManageServers: () => <>ManageServers</>,
|
||||||
|
ShlinkVersionsContainer: () => <>ShlinkVersions</>,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const setUp = async (activeRoute = '/') => act(() => render(
|
||||||
<MemoryRouter initialEntries={[{ pathname: activeRoute }]}>
|
<MemoryRouter initialEntries={[{ pathname: activeRoute }]}>
|
||||||
<ContainerProvider
|
<App
|
||||||
value={fromPartial({
|
fetchServers={() => {}}
|
||||||
HttpClient: fromPartial<HttpClient>({}),
|
servers={{}}
|
||||||
buildShlinkApiClient: vi.fn(),
|
settings={fromPartial({})}
|
||||||
useTimeoutToggle: vi.fn().mockReturnValue([false, vi.fn()]),
|
appUpdated={false}
|
||||||
})}
|
resetAppUpdate={() => {}}
|
||||||
>
|
/>
|
||||||
<App />
|
|
||||||
</ContainerProvider>
|
|
||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
{
|
|
||||||
initialState: {
|
|
||||||
servers: {
|
|
||||||
abc123: fromPartial<ServerWithId>({ id: 'abc123', name: 'abc123 server' }),
|
|
||||||
def456: fromPartial<ServerWithId>({ id: 'def456', name: 'def456 server' }),
|
|
||||||
},
|
|
||||||
settings: fromPartial({}),
|
|
||||||
appUpdated: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
));
|
));
|
||||||
|
|
||||||
it('passes a11y checks', () => checkAccessibility(setUp()));
|
it('passes a11y checks', () => checkAccessibility(setUp()));
|
||||||
|
|
||||||
|
it('renders children components', async () => {
|
||||||
|
await setUp();
|
||||||
|
|
||||||
|
expect(screen.getByText('MainHeader')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('ShlinkVersions')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
['/settings/general', 'User interface'],
|
['/settings/foo', 'SettingsComp'],
|
||||||
['/settings/short-urls', 'Short URLs form'],
|
['/settings/bar', 'SettingsComp'],
|
||||||
['/manage-servers', 'Add a server'],
|
['/manage-servers', 'ManageServers'],
|
||||||
['/server/create', 'Add new server'],
|
['/server/create', 'CreateServer'],
|
||||||
['/server/abc123/edit', 'Edit "abc123 server"'],
|
['/server/abc123/edit', 'EditServer'],
|
||||||
['/server/def456/edit', 'Edit "def456 server"'],
|
['/server/def456/edit', 'EditServer'],
|
||||||
['/server/abc123/foo', 'ShlinkWebComponentContainer'],
|
['/server/abc123/foo', 'ShlinkWebComponentContainer'],
|
||||||
['/server/def456/bar', 'ShlinkWebComponentContainer'],
|
['/server/def456/bar', 'ShlinkWebComponentContainer'],
|
||||||
['/other', 'Oops! We could not find requested route.'],
|
['/other', 'Oops! We could not find requested route.'],
|
||||||
|
|||||||
@@ -1,19 +1,15 @@
|
|||||||
import { screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import { fromPartial } from '@total-typescript/shoehorn';
|
import { fromPartial } from '@total-typescript/shoehorn';
|
||||||
import { MemoryRouter } from 'react-router';
|
import { MemoryRouter } from 'react-router';
|
||||||
import { Home } from '../../src/common/Home';
|
import { Home } from '../../src/common/Home';
|
||||||
import type { ServersMap, ServerWithId } from '../../src/servers/data';
|
import type { ServersMap, ServerWithId } from '../../src/servers/data';
|
||||||
import { checkAccessibility } from '../__helpers__/accessibility';
|
import { checkAccessibility } from '../__helpers__/accessibility';
|
||||||
import { renderWithStore } from '../__helpers__/setUpTest';
|
|
||||||
|
|
||||||
describe('<Home />', () => {
|
describe('<Home />', () => {
|
||||||
const setUp = (servers: ServersMap = {}) => renderWithStore(
|
const setUp = (servers: ServersMap = {}) => render(
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
<Home />
|
<Home servers={servers} />
|
||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
{
|
|
||||||
initialState: { servers },
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
it('passes a11y checks', () => checkAccessibility(
|
it('passes a11y checks', () => checkAccessibility(
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
import { screen } from '@testing-library/react';
|
import { screen } from '@testing-library/react';
|
||||||
|
import { fromPartial } from '@total-typescript/shoehorn';
|
||||||
import { createMemoryHistory } from 'history';
|
import { createMemoryHistory } from 'history';
|
||||||
import { Router } from 'react-router';
|
import { Router } from 'react-router';
|
||||||
import { MainHeader } from '../../src/common/MainHeader';
|
import { MainHeaderFactory } from '../../src/common/MainHeader';
|
||||||
import { checkAccessibility } from '../__helpers__/accessibility';
|
import { checkAccessibility } from '../__helpers__/accessibility';
|
||||||
import { renderWithStore } from '../__helpers__/setUpTest';
|
import { renderWithEvents } from '../__helpers__/setUpTest';
|
||||||
|
|
||||||
describe('<MainHeader />', () => {
|
describe('<MainHeader />', () => {
|
||||||
|
const MainHeader = MainHeaderFactory(fromPartial({
|
||||||
|
// Fake this component as a li[role="menuitem"], as it gets rendered inside a ul[role="menu"]
|
||||||
|
ServersDropdown: () => <li role="menuitem">ServersDropdown</li>,
|
||||||
|
}));
|
||||||
const setUp = (pathname = '') => {
|
const setUp = (pathname = '') => {
|
||||||
const history = createMemoryHistory();
|
const history = createMemoryHistory();
|
||||||
history.push(pathname);
|
history.push(pathname);
|
||||||
|
|
||||||
return renderWithStore(
|
return renderWithEvents(
|
||||||
<Router location={history.location} navigator={history} unstable_useTransitions={false}>
|
<Router location={history.location} navigator={history}>
|
||||||
<MainHeader />
|
<MainHeader />
|
||||||
</Router>,
|
</Router>,
|
||||||
);
|
);
|
||||||
@@ -21,7 +26,7 @@ describe('<MainHeader />', () => {
|
|||||||
|
|
||||||
it('renders ServersDropdown', () => {
|
it('renders ServersDropdown', () => {
|
||||||
setUp();
|
setUp();
|
||||||
expect(screen.getByRole('button', { name: 'Servers' })).toBeInTheDocument();
|
expect(screen.getByText('ServersDropdown')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
|
import { render } from '@testing-library/react';
|
||||||
import { fromPartial } from '@total-typescript/shoehorn';
|
import { fromPartial } from '@total-typescript/shoehorn';
|
||||||
import { ShlinkVersionsContainer } from '../../src/common/ShlinkVersionsContainer';
|
import { ShlinkVersionsContainer } from '../../src/common/ShlinkVersionsContainer';
|
||||||
import type { ReachableServer, SelectedServer } from '../../src/servers/data';
|
import type { ReachableServer, SelectedServer } from '../../src/servers/data';
|
||||||
import { checkAccessibility } from '../__helpers__/accessibility';
|
import { checkAccessibility } from '../__helpers__/accessibility';
|
||||||
import { renderWithStore } from '../__helpers__/setUpTest';
|
|
||||||
|
|
||||||
describe('<ShlinkVersionsContainer />', () => {
|
describe('<ShlinkVersionsContainer />', () => {
|
||||||
const setUp = (selectedServer: SelectedServer = null) => renderWithStore(<ShlinkVersionsContainer />, {
|
const setUp = (selectedServer: SelectedServer = null) => render(
|
||||||
initialState: { selectedServer },
|
<ShlinkVersionsContainer selectedServer={selectedServer} />,
|
||||||
});
|
);
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
[null],
|
[null],
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import { fromPartial } from '@total-typescript/shoehorn';
|
import { fromPartial } from '@total-typescript/shoehorn';
|
||||||
import { MemoryRouter } from 'react-router';
|
import { ShlinkWebComponentContainerFactory } from '../../src/common/ShlinkWebComponentContainer';
|
||||||
import { ShlinkWebComponentContainer } from '../../src/common/ShlinkWebComponentContainer';
|
|
||||||
import type { NonReachableServer, NotFoundServer, SelectedServer } from '../../src/servers/data';
|
import type { NonReachableServer, NotFoundServer, SelectedServer } from '../../src/servers/data';
|
||||||
import { checkAccessibility } from '../__helpers__/accessibility';
|
import { checkAccessibility } from '../__helpers__/accessibility';
|
||||||
import { renderWithStore } from '../__helpers__/setUpTest';
|
import { MemoryRouterWithParams } from '../__helpers__/MemoryRouterWithParams';
|
||||||
|
|
||||||
vi.mock('@shlinkio/shlink-web-component', () => ({
|
vi.mock('@shlinkio/shlink-web-component', () => ({
|
||||||
ShlinkSidebarVisibilityProvider: ({ children }: any) => children,
|
ShlinkSidebarVisibilityProvider: ({ children }: any) => children,
|
||||||
@@ -13,13 +12,15 @@ vi.mock('@shlinkio/shlink-web-component', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
describe('<ShlinkWebComponentContainer />', () => {
|
describe('<ShlinkWebComponentContainer />', () => {
|
||||||
const setUp = (selectedServer: SelectedServer) => renderWithStore(
|
const ShlinkWebComponentContainer = ShlinkWebComponentContainerFactory(fromPartial({
|
||||||
<MemoryRouter>
|
buildShlinkApiClient: vi.fn().mockReturnValue(fromPartial({})),
|
||||||
<ShlinkWebComponentContainer TagColorsStorage={fromPartial({})} />
|
TagColorsStorage: fromPartial({}),
|
||||||
</MemoryRouter>,
|
ServerError: () => <>ServerError</>,
|
||||||
{
|
}));
|
||||||
initialState: { selectedServer, servers: {}, settings: {} },
|
const setUp = (selectedServer: SelectedServer) => render(
|
||||||
},
|
<MemoryRouterWithParams params={{ serverId: 'abc123' }}>
|
||||||
|
<ShlinkWebComponentContainer selectServer={vi.fn()} selectedServer={selectedServer} settings={{}} />
|
||||||
|
</MemoryRouterWithParams>,
|
||||||
);
|
);
|
||||||
|
|
||||||
it('passes a11y checks', () => checkAccessibility(setUp(fromPartial({ version: '3.0.0' }))));
|
it('passes a11y checks', () => checkAccessibility(setUp(fromPartial({ version: '3.0.0' }))));
|
||||||
@@ -28,20 +29,18 @@ describe('<ShlinkWebComponentContainer />', () => {
|
|||||||
setUp(null);
|
setUp(null);
|
||||||
|
|
||||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('ServerError')).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText('ShlinkWebComponent')).not.toBeInTheDocument();
|
expect(screen.queryByText('ShlinkWebComponent')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
[fromPartial<NotFoundServer>({ serverNotFound: true }), 'Could not find this Shlink server.'],
|
[fromPartial<NotFoundServer>({ serverNotFound: true })],
|
||||||
[
|
[fromPartial<NonReachableServer>({ serverNotReachable: true })],
|
||||||
fromPartial<NonReachableServer>({ id: 'foo', serverNotReachable: true }),
|
])('shows error for non reachable servers', (selectedServer) => {
|
||||||
/Could not connect to this Shlink server/,
|
|
||||||
],
|
|
||||||
])('shows error for non reachable servers', (selectedServer, expectedError) => {
|
|
||||||
setUp(selectedServer);
|
setUp(selectedServer);
|
||||||
|
|
||||||
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
|
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
|
||||||
expect(screen.getByText(expectedError)).toBeInTheDocument();
|
expect(screen.getByText('ServerError')).toBeInTheDocument();
|
||||||
expect(screen.queryByText('ShlinkWebComponent')).not.toBeInTheDocument();
|
expect(screen.queryByText('ShlinkWebComponent')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -49,6 +48,7 @@ describe('<ShlinkWebComponentContainer />', () => {
|
|||||||
setUp(fromPartial({ version: '3.0.0' }));
|
setUp(fromPartial({ version: '3.0.0' }));
|
||||||
|
|
||||||
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
|
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('ServerError')).not.toBeInTheDocument();
|
||||||
expect(screen.getByText('ShlinkWebComponent')).toBeInTheDocument();
|
expect(screen.getByText('ShlinkWebComponent')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
import { render } from '@testing-library/react';
|
|
||||||
import { fromPartial } from '@total-typescript/shoehorn';
|
|
||||||
import { ContainerProvider, useDependencies } from '../../src/container/context';
|
|
||||||
|
|
||||||
describe('context', () => {
|
|
||||||
describe('useDependencies', () => {
|
|
||||||
let lastDependencies: unknown[];
|
|
||||||
|
|
||||||
function TestComponent({ name}: { name: string }) {
|
|
||||||
// eslint-disable-next-line react-compiler/react-compiler
|
|
||||||
lastDependencies = useDependencies(name);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
it('throws when used outside of ContainerProvider', () => {
|
|
||||||
expect(() => render(<TestComponent name="foo" />)).toThrowError(
|
|
||||||
'You cannot use "useDependencies" outside of a ContainerProvider',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws when requested dependency is not found in container', () => {
|
|
||||||
expect(() => render(
|
|
||||||
<ContainerProvider value={fromPartial({})}>
|
|
||||||
<TestComponent name="foo" />
|
|
||||||
</ContainerProvider>,
|
|
||||||
)).toThrowError('Dependency with name "foo" not found in container');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('gets dependency from container', () => {
|
|
||||||
render(
|
|
||||||
<ContainerProvider value={fromPartial({ foo: 'the dependency' })}>
|
|
||||||
<TestComponent name="foo" />
|
|
||||||
</ContainerProvider>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(lastDependencies).toEqual(['the dependency']);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -2,10 +2,10 @@ import { fireEvent, screen, waitFor } from '@testing-library/react';
|
|||||||
import { fromPartial } from '@total-typescript/shoehorn';
|
import { fromPartial } from '@total-typescript/shoehorn';
|
||||||
import { createMemoryHistory } from 'history';
|
import { createMemoryHistory } from 'history';
|
||||||
import { Router } from 'react-router';
|
import { Router } from 'react-router';
|
||||||
import { CreateServer } from '../../src/servers/CreateServer';
|
import { CreateServerFactory } from '../../src/servers/CreateServer';
|
||||||
import type { ServersMap } from '../../src/servers/data';
|
import type { ServersMap } from '../../src/servers/data';
|
||||||
import { checkAccessibility } from '../__helpers__/accessibility';
|
import { checkAccessibility } from '../__helpers__/accessibility';
|
||||||
import { renderWithStore } from '../__helpers__/setUpTest';
|
import { renderWithEvents } from '../__helpers__/setUpTest';
|
||||||
|
|
||||||
type SetUpOptions = {
|
type SetUpOptions = {
|
||||||
serversImported?: boolean;
|
serversImported?: boolean;
|
||||||
@@ -14,8 +14,9 @@ type SetUpOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe('<CreateServer />', () => {
|
describe('<CreateServer />', () => {
|
||||||
|
const createServersMock = vi.fn();
|
||||||
const defaultServers: ServersMap = {
|
const defaultServers: ServersMap = {
|
||||||
foo: fromPartial({ url: 'https://existing_url.com', apiKey: 'existing_api_key', id: 'foo' }),
|
foo: fromPartial({ url: 'https://existing_url.com', apiKey: 'existing_api_key' }),
|
||||||
};
|
};
|
||||||
const setUp = ({ serversImported = false, importFailed = false, servers = defaultServers }: SetUpOptions = {}) => {
|
const setUp = ({ serversImported = false, importFailed = false, servers = defaultServers }: SetUpOptions = {}) => {
|
||||||
let callCount = 0;
|
let callCount = 0;
|
||||||
@@ -24,17 +25,18 @@ describe('<CreateServer />', () => {
|
|||||||
callCount += 1;
|
callCount += 1;
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
|
const CreateServer = CreateServerFactory(fromPartial({
|
||||||
|
ImportServersBtn: () => <>ImportServersBtn</>,
|
||||||
|
useTimeoutToggle,
|
||||||
|
}));
|
||||||
const history = createMemoryHistory({ initialEntries: ['/foo', '/bar'] });
|
const history = createMemoryHistory({ initialEntries: ['/foo', '/bar'] });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
history,
|
history,
|
||||||
...renderWithStore(
|
...renderWithEvents(
|
||||||
<Router location={history.location} navigator={history} unstable_useTransitions={false}>
|
<Router location={history.location} navigator={history}>
|
||||||
<CreateServer useTimeoutToggle={useTimeoutToggle} />
|
<CreateServer createServers={createServersMock} servers={servers} />
|
||||||
</Router>,
|
</Router>,
|
||||||
{
|
|
||||||
initialState: { servers },
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -60,24 +62,27 @@ describe('<CreateServer />', () => {
|
|||||||
expect(screen.getByText('The servers could not be imported. Make sure the format is correct.')).toBeInTheDocument();
|
expect(screen.getByText('The servers could not be imported. Make sure the format is correct.')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows import button when no servers exist yet', () => {
|
||||||
|
setUp({ servers: {} });
|
||||||
|
expect(screen.queryByText('ImportServersBtn')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it('creates server data when form is submitted', async () => {
|
it('creates server data when form is submitted', async () => {
|
||||||
const { user, history, store } = setUp();
|
const { user, history } = setUp();
|
||||||
const expectedServerId = 'the_name-the_url.com';
|
|
||||||
|
expect(createServersMock).not.toHaveBeenCalled();
|
||||||
|
|
||||||
await user.type(screen.getByLabelText(/^Name/), 'the_name');
|
await user.type(screen.getByLabelText(/^Name/), 'the_name');
|
||||||
await user.type(screen.getByLabelText(/^URL/), 'https://the_url.com');
|
await user.type(screen.getByLabelText(/^URL/), 'https://the_url.com');
|
||||||
await user.type(screen.getByLabelText(/^API key/), 'the_api_key');
|
await user.type(screen.getByLabelText(/^API key/), 'the_api_key');
|
||||||
|
|
||||||
expect(store.getState().servers[expectedServerId]).not.toBeDefined();
|
|
||||||
fireEvent.submit(screen.getByRole('form'));
|
fireEvent.submit(screen.getByRole('form'));
|
||||||
expect(store.getState().servers[expectedServerId]).toEqual(expect.objectContaining({
|
|
||||||
id: expectedServerId,
|
expect(createServersMock).toHaveBeenCalledWith([expect.objectContaining({
|
||||||
name: 'the_name',
|
name: 'the_name',
|
||||||
url: 'https://the_url.com',
|
url: 'https://the_url.com',
|
||||||
apiKey: 'the_api_key',
|
apiKey: 'the_api_key',
|
||||||
}));
|
})]);
|
||||||
|
expect(history.location.pathname).toEqual(expect.stringMatching(/^\/server\//));
|
||||||
expect(history.location.pathname).toEqual(`/server/${expectedServerId}`);
|
|
||||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -87,12 +92,12 @@ describe('<CreateServer />', () => {
|
|||||||
await user.type(screen.getByLabelText(/^Name/), 'the_name');
|
await user.type(screen.getByLabelText(/^Name/), 'the_name');
|
||||||
await user.type(screen.getByLabelText(/^URL/), 'https://existing_url.com');
|
await user.type(screen.getByLabelText(/^URL/), 'https://existing_url.com');
|
||||||
await user.type(screen.getByLabelText(/^API key/), 'existing_api_key');
|
await user.type(screen.getByLabelText(/^API key/), 'existing_api_key');
|
||||||
|
|
||||||
fireEvent.submit(screen.getByRole('form'));
|
fireEvent.submit(screen.getByRole('form'));
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument());
|
||||||
await user.click(screen.getByRole('button', { name: 'Discard' }));
|
await user.click(screen.getByRole('button', { name: 'Discard' }));
|
||||||
|
|
||||||
|
expect(createServersMock).not.toHaveBeenCalled();
|
||||||
expect(history.location.pathname).toEqual('/foo'); // Goes back to first route from history's initialEntries
|
expect(history.location.pathname).toEqual('/foo'); // Goes back to first route from history's initialEntries
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,15 +3,20 @@ import { fromPartial } from '@total-typescript/shoehorn';
|
|||||||
import { createMemoryHistory } from 'history';
|
import { createMemoryHistory } from 'history';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { Router } from 'react-router';
|
import { Router } from 'react-router';
|
||||||
import { DeleteServerButton } from '../../src/servers/DeleteServerButton';
|
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 { checkAccessibility } from '../__helpers__/accessibility';
|
||||||
import { renderWithStore } from '../__helpers__/setUpTest';
|
import { renderWithEvents } from '../__helpers__/setUpTest';
|
||||||
|
|
||||||
describe('<DeleteServerButton />', () => {
|
describe('<DeleteServerButton />', () => {
|
||||||
|
const DeleteServerButton = DeleteServerButtonFactory(fromPartial({
|
||||||
|
DeleteServerModal: (props: DeleteServerModalProps) => <DeleteServerModal {...props} deleteServer={vi.fn()} />,
|
||||||
|
}));
|
||||||
const setUp = (children: ReactNode = 'Remove this server') => {
|
const setUp = (children: ReactNode = 'Remove this server') => {
|
||||||
const history = createMemoryHistory({ initialEntries: ['/foo'] });
|
const history = createMemoryHistory({ initialEntries: ['/foo'] });
|
||||||
const result = renderWithStore(
|
const result = renderWithEvents(
|
||||||
<Router location={history.location} navigator={history} unstable_useTransitions={false}>
|
<Router location={history.location} navigator={history}>
|
||||||
<DeleteServerButton server={fromPartial({})}>{children}</DeleteServerButton>
|
<DeleteServerButton server={fromPartial({})}>{children}</DeleteServerButton>
|
||||||
</Router>,
|
</Router>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
import { screen } from '@testing-library/react';
|
import { screen } from '@testing-library/react';
|
||||||
import { fromPartial } from '@total-typescript/shoehorn';
|
import { fromPartial } from '@total-typescript/shoehorn';
|
||||||
import type { ServerWithId } from '../../src/servers/data';
|
|
||||||
import { DeleteServerModal } from '../../src/servers/DeleteServerModal';
|
import { DeleteServerModal } from '../../src/servers/DeleteServerModal';
|
||||||
import { checkAccessibility } from '../__helpers__/accessibility';
|
import { checkAccessibility } from '../__helpers__/accessibility';
|
||||||
import { renderWithStore } from '../__helpers__/setUpTest';
|
import { renderWithEvents } from '../__helpers__/setUpTest';
|
||||||
import { TestModalWrapper } from '../__helpers__/TestModalWrapper';
|
import { TestModalWrapper } from '../__helpers__/TestModalWrapper';
|
||||||
|
|
||||||
describe('<DeleteServerModal />', () => {
|
describe('<DeleteServerModal />', () => {
|
||||||
|
const deleteServerMock = vi.fn();
|
||||||
const serverName = 'the_server_name';
|
const serverName = 'the_server_name';
|
||||||
const server = fromPartial<ServerWithId>({ id: 'foo', name: serverName });
|
const setUp = () => renderWithEvents(
|
||||||
const setUp = () => renderWithStore(
|
|
||||||
<TestModalWrapper
|
<TestModalWrapper
|
||||||
renderModal={(args) => <DeleteServerModal {...args} server={server} />}
|
renderModal={(args) => (
|
||||||
|
<DeleteServerModal
|
||||||
|
{...args}
|
||||||
|
server={fromPartial({ name: serverName })}
|
||||||
|
deleteServer={deleteServerMock}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>,
|
/>,
|
||||||
{
|
|
||||||
initialState: {
|
|
||||||
servers: { foo: server },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
it('passes a11y checks', () => checkAccessibility(setUp()));
|
it('passes a11y checks', () => checkAccessibility(setUp()));
|
||||||
@@ -40,21 +40,19 @@ describe('<DeleteServerModal />', () => {
|
|||||||
[() => screen.getByRole('button', { name: 'Cancel' })],
|
[() => screen.getByRole('button', { name: 'Cancel' })],
|
||||||
[() => screen.getByLabelText('Close dialog')],
|
[() => screen.getByLabelText('Close dialog')],
|
||||||
])('closes dialog when clicking cancel button', async (getButton) => {
|
])('closes dialog when clicking cancel button', async (getButton) => {
|
||||||
const { user, store } = setUp();
|
const { user } = setUp();
|
||||||
|
|
||||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||||
await user.click(getButton());
|
await user.click(getButton());
|
||||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||||
|
expect(deleteServerMock).not.toHaveBeenCalled();
|
||||||
// No server has been deleted
|
|
||||||
expect(Object.keys(store.getState().servers)).toHaveLength(1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('deletes server when clicking accept button', async () => {
|
it('deletes server when clicking accept button', async () => {
|
||||||
const { user, store } = setUp();
|
const { user } = setUp();
|
||||||
|
|
||||||
expect(Object.keys(store.getState().servers)).toHaveLength(1);
|
expect(deleteServerMock).not.toHaveBeenCalled();
|
||||||
await user.click(screen.getByRole('button', { name: 'Delete' }));
|
await user.click(screen.getByRole('button', { name: 'Delete' }));
|
||||||
expect(Object.keys(store.getState().servers)).toHaveLength(0);
|
expect(deleteServerMock).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,34 +1,30 @@
|
|||||||
import { fireEvent, screen, waitFor } from '@testing-library/react';
|
import { fireEvent, screen } from '@testing-library/react';
|
||||||
import { fromPartial } from '@total-typescript/shoehorn';
|
import { fromPartial } from '@total-typescript/shoehorn';
|
||||||
import { createMemoryHistory } from 'history';
|
import { createMemoryHistory } from 'history';
|
||||||
import { Router } from 'react-router';
|
import { Router } from 'react-router';
|
||||||
import type { ReachableServer, SelectedServer } from '../../src/servers/data';
|
import type { ReachableServer, SelectedServer } from '../../src/servers/data';
|
||||||
import { isServerWithId } from '../../src/servers/data';
|
import { EditServerFactory } from '../../src/servers/EditServer';
|
||||||
import { EditServer } from '../../src/servers/EditServer';
|
|
||||||
import { checkAccessibility } from '../__helpers__/accessibility';
|
import { checkAccessibility } from '../__helpers__/accessibility';
|
||||||
import { renderWithStore } from '../__helpers__/setUpTest';
|
import { renderWithEvents } from '../__helpers__/setUpTest';
|
||||||
|
|
||||||
describe('<EditServer />', () => {
|
describe('<EditServer />', () => {
|
||||||
|
const ServerError = vi.fn();
|
||||||
|
const editServerMock = vi.fn();
|
||||||
const defaultSelectedServer = fromPartial<ReachableServer>({
|
const defaultSelectedServer = fromPartial<ReachableServer>({
|
||||||
id: 'abc123',
|
id: 'abc123',
|
||||||
name: 'the_name',
|
name: 'the_name',
|
||||||
url: 'the_url',
|
url: 'the_url',
|
||||||
apiKey: 'the_api_key',
|
apiKey: 'the_api_key',
|
||||||
});
|
});
|
||||||
|
const EditServer = EditServerFactory(fromPartial({ ServerError }));
|
||||||
const setUp = (selectedServer: SelectedServer = defaultSelectedServer) => {
|
const setUp = (selectedServer: SelectedServer = defaultSelectedServer) => {
|
||||||
const history = createMemoryHistory({ initialEntries: ['/foo', '/bar'] });
|
const history = createMemoryHistory({ initialEntries: ['/foo', '/bar'] });
|
||||||
return {
|
return {
|
||||||
history,
|
history,
|
||||||
...renderWithStore(
|
...renderWithEvents(
|
||||||
<Router location={history.location} navigator={history} unstable_useTransitions={false}>
|
<Router location={history.location} navigator={history}>
|
||||||
<EditServer />
|
<EditServer editServer={editServerMock} selectedServer={selectedServer} selectServer={vi.fn()} />
|
||||||
</Router>,
|
</Router>,
|
||||||
{
|
|
||||||
initialState: {
|
|
||||||
selectedServer,
|
|
||||||
servers: isServerWithId(selectedServer) ? { [selectedServer.id]: selectedServer } : {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -57,7 +53,7 @@ describe('<EditServer />', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('edits server and redirects to it when form is submitted', async () => {
|
it('edits server and redirects to it when form is submitted', async () => {
|
||||||
const { user, history, store } = setUp();
|
const { user, history } = setUp();
|
||||||
|
|
||||||
await user.type(screen.getByLabelText(/^Name/), ' edited');
|
await user.type(screen.getByLabelText(/^Name/), ' edited');
|
||||||
await user.type(screen.getByLabelText(/^URL/), ' edited');
|
await user.type(screen.getByLabelText(/^URL/), ' edited');
|
||||||
@@ -65,10 +61,12 @@ describe('<EditServer />', () => {
|
|||||||
// await user.click(screen.getByRole('button', { name: 'Save' }));
|
// await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||||
fireEvent.submit(screen.getByRole('form'));
|
fireEvent.submit(screen.getByRole('form'));
|
||||||
|
|
||||||
expect(store.getState().servers[defaultSelectedServer.id]).toEqual(expect.objectContaining({
|
expect(editServerMock).toHaveBeenCalledWith('abc123', {
|
||||||
name: 'the_name edited',
|
name: 'the_name edited',
|
||||||
url: 'the_url edited',
|
url: 'the_url edited',
|
||||||
}));
|
apiKey: 'the_api_key',
|
||||||
|
forwardCredentials: false,
|
||||||
|
});
|
||||||
|
|
||||||
// After saving we go back, to the first route from history's initialEntries
|
// After saving we go back, to the first route from history's initialEntries
|
||||||
expect(history.location.pathname).toEqual('/foo');
|
expect(history.location.pathname).toEqual('/foo');
|
||||||
@@ -77,15 +75,16 @@ describe('<EditServer />', () => {
|
|||||||
it.each([
|
it.each([
|
||||||
{ forwardCredentials: true },
|
{ forwardCredentials: true },
|
||||||
{ forwardCredentials: false },
|
{ forwardCredentials: false },
|
||||||
])('edits advanced options - forward credentials', async ({ forwardCredentials }) => {
|
])('edits advanced options - forward credentials', async (serverPartial) => {
|
||||||
const { user, store } = setUp({ ...defaultSelectedServer, forwardCredentials });
|
const { user } = setUp({ ...defaultSelectedServer, ...serverPartial });
|
||||||
|
|
||||||
await user.click(screen.getByText('Advanced options'));
|
await user.click(screen.getByText('Advanced options'));
|
||||||
await user.click(screen.getByLabelText('Forward credentials to this server on every request.'));
|
await user.click(screen.getByLabelText('Forward credentials to this server on every request.'));
|
||||||
|
|
||||||
fireEvent.submit(screen.getByRole('form'));
|
fireEvent.submit(screen.getByRole('form'));
|
||||||
|
|
||||||
await waitFor(() => expect(store.getState().servers[defaultSelectedServer.id]).toEqual(expect.objectContaining({
|
expect(editServerMock).toHaveBeenCalledWith('abc123', expect.objectContaining({
|
||||||
forwardCredentials: !forwardCredentials,
|
forwardCredentials: !serverPartial.forwardCredentials,
|
||||||
})));
|
}));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,25 +2,28 @@ import { screen, waitFor } from '@testing-library/react';
|
|||||||
import { fromPartial } from '@total-typescript/shoehorn';
|
import { fromPartial } from '@total-typescript/shoehorn';
|
||||||
import { MemoryRouter } from 'react-router';
|
import { MemoryRouter } from 'react-router';
|
||||||
import type { ServersMap, ServerWithId } from '../../src/servers/data';
|
import type { ServersMap, ServerWithId } from '../../src/servers/data';
|
||||||
import { ManageServers } from '../../src/servers/ManageServers';
|
import { ManageServersFactory } from '../../src/servers/ManageServers';
|
||||||
import type { ServersExporter } from '../../src/servers/services/ServersExporter';
|
import type { ServersExporter } from '../../src/servers/services/ServersExporter';
|
||||||
import { checkAccessibility } from '../__helpers__/accessibility';
|
import { checkAccessibility } from '../__helpers__/accessibility';
|
||||||
import { renderWithStore } from '../__helpers__/setUpTest';
|
import { renderWithEvents } from '../__helpers__/setUpTest';
|
||||||
|
|
||||||
describe('<ManageServers />', () => {
|
describe('<ManageServers />', () => {
|
||||||
const exportServers = vi.fn();
|
const exportServers = vi.fn();
|
||||||
const serversExporter = fromPartial<ServersExporter>({ exportServers });
|
const serversExporter = fromPartial<ServersExporter>({ exportServers });
|
||||||
const useTimeoutToggle = vi.fn().mockReturnValue([false, vi.fn()]);
|
const useTimeoutToggle = vi.fn().mockReturnValue([false, vi.fn()]);
|
||||||
|
const ManageServers = ManageServersFactory(fromPartial({
|
||||||
|
ServersExporter: serversExporter,
|
||||||
|
ImportServersBtn: () => <span>ImportServersBtn</span>,
|
||||||
|
useTimeoutToggle,
|
||||||
|
ManageServersRow: ({ hasAutoConnect }: { hasAutoConnect: boolean }) => (
|
||||||
|
<tr><td>ManageServersRow {hasAutoConnect ? '[YES]' : '[NO]'}</td></tr>
|
||||||
|
),
|
||||||
|
}));
|
||||||
const createServerMock = (value: string, autoConnect = false) => fromPartial<ServerWithId>(
|
const createServerMock = (value: string, autoConnect = false) => fromPartial<ServerWithId>(
|
||||||
{ id: value, name: value, url: value, autoConnect },
|
{ id: value, name: value, url: value, autoConnect },
|
||||||
);
|
);
|
||||||
const setUp = (servers: ServersMap = {}) => renderWithStore(
|
const setUp = (servers: ServersMap = {}) => renderWithEvents(
|
||||||
<MemoryRouter>
|
<MemoryRouter><ManageServers servers={servers} /></MemoryRouter>,
|
||||||
<ManageServers useTimeoutToggle={useTimeoutToggle} ServersExporter={serversExporter} />
|
|
||||||
</MemoryRouter>,
|
|
||||||
{
|
|
||||||
initialState: { servers },
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
it('passes a11y checks', () => checkAccessibility(setUp({
|
it('passes a11y checks', () => checkAccessibility(setUp({
|
||||||
@@ -39,22 +42,20 @@ describe('<ManageServers />', () => {
|
|||||||
await user.clear(screen.getByPlaceholderText('Search...'));
|
await user.clear(screen.getByPlaceholderText('Search...'));
|
||||||
await user.type(screen.getByPlaceholderText('Search...'), searchTerm);
|
await user.type(screen.getByPlaceholderText('Search...'), searchTerm);
|
||||||
};
|
};
|
||||||
// Add one for the header row
|
|
||||||
const expectRows = (amount: number) => expect(screen.getAllByRole('row')).toHaveLength(amount + 1);
|
|
||||||
|
|
||||||
expectRows(3);
|
expect(screen.getAllByText(/^ManageServersRow/)).toHaveLength(3);
|
||||||
expect(screen.queryByText('No servers found.')).not.toBeInTheDocument();
|
expect(screen.queryByText('No servers found.')).not.toBeInTheDocument();
|
||||||
|
|
||||||
await search('foo');
|
await search('foo');
|
||||||
await waitFor(() => expectRows(1));
|
await waitFor(() => expect(screen.getAllByText(/^ManageServersRow/)).toHaveLength(1));
|
||||||
expect(screen.queryByText('No servers found.')).not.toBeInTheDocument();
|
expect(screen.queryByText('No servers found.')).not.toBeInTheDocument();
|
||||||
|
|
||||||
await search('Ba');
|
await search('Ba');
|
||||||
await waitFor(() => expectRows(2));
|
await waitFor(() => expect(screen.getAllByText(/^ManageServersRow/)).toHaveLength(2));
|
||||||
expect(screen.queryByText('No servers found.')).not.toBeInTheDocument();
|
expect(screen.queryByText('No servers found.')).not.toBeInTheDocument();
|
||||||
|
|
||||||
await search('invalid');
|
await search('invalid');
|
||||||
await waitFor(() => expectRows(1));
|
await waitFor(() => expect(screen.queryByText(/^ManageServersRow/)).not.toBeInTheDocument());
|
||||||
expect(screen.getByText('No servers found.')).toBeInTheDocument();
|
expect(screen.getByText('No servers found.')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -66,9 +67,11 @@ describe('<ManageServers />', () => {
|
|||||||
|
|
||||||
expect(screen.getAllByRole('columnheader')).toHaveLength(expectedCols);
|
expect(screen.getAllByRole('columnheader')).toHaveLength(expectedCols);
|
||||||
if (server.autoConnect) {
|
if (server.autoConnect) {
|
||||||
expect(screen.getByTestId('auto-connect')).toBeInTheDocument();
|
expect(screen.getByText(/\[YES]/)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/\[NO]/)).not.toBeInTheDocument();
|
||||||
} else {
|
} else {
|
||||||
expect(screen.queryByTestId('auto-connect')).not.toBeInTheDocument();
|
expect(screen.queryByText(/\[YES]/)).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/\[NO]/)).toBeInTheDocument();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,22 @@
|
|||||||
import { Table } from '@shlinkio/shlink-frontend-kit';
|
import { Table } from '@shlinkio/shlink-frontend-kit';
|
||||||
import { screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { fromPartial } from '@total-typescript/shoehorn';
|
||||||
import { MemoryRouter } from 'react-router';
|
import { MemoryRouter } from 'react-router';
|
||||||
import type { ServerWithId } from '../../src/servers/data';
|
import type { ServerWithId } from '../../src/servers/data';
|
||||||
import { ManageServersRow } from '../../src/servers/ManageServersRow';
|
import { ManageServersRowFactory } from '../../src/servers/ManageServersRow';
|
||||||
import { checkAccessibility } from '../__helpers__/accessibility';
|
import { checkAccessibility } from '../__helpers__/accessibility';
|
||||||
import { renderWithStore } from '../__helpers__/setUpTest';
|
|
||||||
|
|
||||||
describe('<ManageServersRow />', () => {
|
describe('<ManageServersRow />', () => {
|
||||||
|
const ManageServersRow = ManageServersRowFactory(fromPartial({
|
||||||
|
ManageServersRowDropdown: () => <span>ManageServersRowDropdown</span>,
|
||||||
|
}));
|
||||||
const server: ServerWithId = {
|
const server: ServerWithId = {
|
||||||
name: 'My server',
|
name: 'My server',
|
||||||
url: 'https://example.com',
|
url: 'https://example.com',
|
||||||
apiKey: '123',
|
apiKey: '123',
|
||||||
id: 'abc',
|
id: 'abc',
|
||||||
};
|
};
|
||||||
const setUp = (hasAutoConnect = false, autoConnect = false) => renderWithStore(
|
const setUp = (hasAutoConnect = false, autoConnect = false) => render(
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
<Table header={<Table.Row />}>
|
<Table header={<Table.Row />}>
|
||||||
<ManageServersRow server={{ ...server, autoConnect }} hasAutoConnect={hasAutoConnect} />
|
<ManageServersRow server={{ ...server, autoConnect }} hasAutoConnect={hasAutoConnect} />
|
||||||
@@ -31,9 +34,9 @@ describe('<ManageServersRow />', () => {
|
|||||||
expect(screen.getAllByRole('cell')).toHaveLength(expectedCols);
|
expect(screen.getAllByRole('cell')).toHaveLength(expectedCols);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders an options dropdown', () => {
|
it('renders a dropdown', () => {
|
||||||
setUp();
|
setUp();
|
||||||
expect(screen.getByRole('button', { name: 'Options' })).toBeInTheDocument();
|
expect(screen.getByText('ManageServersRowDropdown')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
|
|||||||
@@ -3,22 +3,23 @@ import type { UserEvent } from '@testing-library/user-event';
|
|||||||
import { fromPartial } from '@total-typescript/shoehorn';
|
import { fromPartial } from '@total-typescript/shoehorn';
|
||||||
import { MemoryRouter } from 'react-router';
|
import { MemoryRouter } from 'react-router';
|
||||||
import type { ServerWithId } from '../../src/servers/data';
|
import type { ServerWithId } from '../../src/servers/data';
|
||||||
import { ManageServersRowDropdown } from '../../src/servers/ManageServersRowDropdown';
|
import { ManageServersRowDropdownFactory } from '../../src/servers/ManageServersRowDropdown';
|
||||||
import { checkAccessibility } from '../__helpers__/accessibility';
|
import { checkAccessibility } from '../__helpers__/accessibility';
|
||||||
import { renderWithStore } from '../__helpers__/setUpTest';
|
import { renderWithEvents } from '../__helpers__/setUpTest';
|
||||||
|
|
||||||
describe('<ManageServersRowDropdown />', () => {
|
describe('<ManageServersRowDropdown />', () => {
|
||||||
|
const ManageServersRowDropdown = ManageServersRowDropdownFactory(fromPartial({
|
||||||
|
DeleteServerModal: ({ open }: { open: boolean }) => (
|
||||||
|
<span>DeleteServerModal {open ? '[OPEN]' : '[CLOSED]'}</span>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
const setAutoConnect = vi.fn();
|
||||||
const setUp = (autoConnect = false) => {
|
const setUp = (autoConnect = false) => {
|
||||||
const server = fromPartial<ServerWithId>({ id: 'abc123', autoConnect });
|
const server = fromPartial<ServerWithId>({ id: 'abc123', autoConnect });
|
||||||
return renderWithStore(
|
return renderWithEvents(
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
<ManageServersRowDropdown server={server} />
|
<ManageServersRowDropdown setAutoConnect={setAutoConnect} server={server} />
|
||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
{
|
|
||||||
initialState: {
|
|
||||||
servers: { [server.id]: server },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
const toggleDropdown = (user: UserEvent) => user.click(screen.getByRole('button'));
|
const toggleDropdown = (user: UserEvent) => user.click(screen.getByRole('button'));
|
||||||
@@ -43,24 +44,26 @@ describe('<ManageServersRowDropdown />', () => {
|
|||||||
expect(screen.getByRole('menuitem', { name: 'Edit server' })).toHaveAttribute('href', '/server/abc123/edit');
|
expect(screen.getByRole('menuitem', { name: 'Edit server' })).toHaveAttribute('href', '/server/abc123/edit');
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([true, false])('allows toggling auto-connect', async (autoConnect) => {
|
it('allows toggling auto-connect', async () => {
|
||||||
const { user, store } = setUp(autoConnect);
|
const { user } = setUp();
|
||||||
|
|
||||||
|
expect(setAutoConnect).not.toHaveBeenCalled();
|
||||||
await toggleDropdown(user);
|
await toggleDropdown(user);
|
||||||
await user.click(screen.getByRole('menuitem', { name: autoConnect ? 'Do not auto-connect' : 'Auto-connect' }));
|
await user.click(screen.getByRole('menuitem', { name: 'Auto-connect' }));
|
||||||
|
expect(setAutoConnect).toHaveBeenCalledWith(expect.objectContaining({ id: 'abc123' }), true);
|
||||||
expect(Object.values(store.getState().servers)[0].autoConnect).toEqual(!autoConnect);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders deletion modal', async () => {
|
it('renders deletion modal', async () => {
|
||||||
const { user } = setUp();
|
const { user } = setUp();
|
||||||
|
|
||||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
expect(screen.queryByText('DeleteServerModal [OPEN]')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText('DeleteServerModal [CLOSED]')).toBeInTheDocument();
|
||||||
|
|
||||||
await toggleDropdown(user);
|
await toggleDropdown(user);
|
||||||
await user.click(screen.getByRole('menuitem', { name: 'Remove server' }));
|
await user.click(screen.getByRole('menuitem', { name: 'Remove server' }));
|
||||||
|
|
||||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
expect(screen.getByText('DeleteServerModal [OPEN]')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('DeleteServerModal [CLOSED]')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([[true], [false]])('renders expected size and icon', (autoConnect) => {
|
it.each([[true], [false]])('renders expected size and icon', (autoConnect) => {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { MemoryRouter } from 'react-router';
|
|||||||
import type { ServersMap } from '../../src/servers/data';
|
import type { ServersMap } from '../../src/servers/data';
|
||||||
import { ServersDropdown } from '../../src/servers/ServersDropdown';
|
import { ServersDropdown } from '../../src/servers/ServersDropdown';
|
||||||
import { checkAccessibility } from '../__helpers__/accessibility';
|
import { checkAccessibility } from '../__helpers__/accessibility';
|
||||||
import { renderWithStore } from '../__helpers__/setUpTest';
|
import { renderWithEvents } from '../__helpers__/setUpTest';
|
||||||
|
|
||||||
describe('<ServersDropdown />', () => {
|
describe('<ServersDropdown />', () => {
|
||||||
const fallbackServers: ServersMap = {
|
const fallbackServers: ServersMap = {
|
||||||
@@ -12,15 +12,12 @@ describe('<ServersDropdown />', () => {
|
|||||||
'2b': fromPartial({ name: 'bar', id: '2b' }),
|
'2b': fromPartial({ name: 'bar', id: '2b' }),
|
||||||
'3c': fromPartial({ name: 'baz', id: '3c' }),
|
'3c': fromPartial({ name: 'baz', id: '3c' }),
|
||||||
};
|
};
|
||||||
const setUp = (servers: ServersMap = fallbackServers) => renderWithStore(
|
const setUp = (servers: ServersMap = fallbackServers) => renderWithEvents(
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
<ul role="menu">
|
<ul role="menu">
|
||||||
<ServersDropdown />
|
<ServersDropdown servers={servers} selectedServer={null} />
|
||||||
</ul>
|
</ul>
|
||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
{
|
|
||||||
initialState: { selectedServer: null, servers },
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
it('passes a11y checks', async () => {
|
it('passes a11y checks', async () => {
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ exports[`<ManageServersRow /> > renders auto-connect icon only if server is auto
|
|||||||
class="svg-inline--fa fa-check text-lm-brand dark:text-dm-brand"
|
class="svg-inline--fa fa-check text-lm-brand dark:text-dm-brand"
|
||||||
data-icon="check"
|
data-icon="check"
|
||||||
data-prefix="fas"
|
data-prefix="fas"
|
||||||
data-testid="auto-connect"
|
|
||||||
role="img"
|
role="img"
|
||||||
viewBox="0 0 448 512"
|
viewBox="0 0 448 512"
|
||||||
>
|
>
|
||||||
@@ -57,32 +56,9 @@ exports[`<ManageServersRow /> > renders auto-connect icon only if server is auto
|
|||||||
<td
|
<td
|
||||||
class="border-lm-border dark:border-dm-border p-2 block lg:table-cell not-last:border-b-1 lg:border-b-1 before:lg:hidden before:content-[attr(data-column)] before:font-bold before:mr-1 text-right max-lg:absolute right-0 -top-1 mx-lg:pt-0"
|
class="border-lm-border dark:border-dm-border p-2 block lg:table-cell not-last:border-b-1 lg:border-b-1 before:lg:hidden before:content-[attr(data-column)] before:font-bold before:mr-1 text-right max-lg:absolute right-0 -top-1 mx-lg:pt-0"
|
||||||
>
|
>
|
||||||
<div
|
<span>
|
||||||
class="relative inline-block"
|
ManageServersRowDropdown
|
||||||
>
|
</span>
|
||||||
<button
|
|
||||||
aria-controls="_r_o_"
|
|
||||||
aria-expanded="false"
|
|
||||||
aria-haspopup="true"
|
|
||||||
aria-label="Options"
|
|
||||||
class="flex items-center rounded-md focus-ring cursor-pointer border border-lm-border dark:border-dm-border bg-lm-primary dark:bg-dm-primary group-[&]/card:bg-lm-input group-[&]/card:dark:bg-dm-input px-3 py-1.5 gap-x-2"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
aria-hidden="true"
|
|
||||||
class="svg-inline--fa fa-ellipsis-vertical fa-width-auto"
|
|
||||||
data-icon="ellipsis-vertical"
|
|
||||||
data-prefix="fas"
|
|
||||||
role="img"
|
|
||||||
viewBox="0 0 128 512"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M64 144a56 56 0 1 1 0-112 56 56 0 1 1 0 112zm0 224c30.9 0 56 25.1 56 56s-25.1 56-56 56-56-25.1-56-56 25.1-56 56-56zm56-112c0 30.9-25.1 56-56 56s-56-25.1-56-56 25.1-56 56-56 56 25.1 56 56z"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -132,32 +108,9 @@ exports[`<ManageServersRow /> > renders auto-connect icon only if server is auto
|
|||||||
<td
|
<td
|
||||||
class="border-lm-border dark:border-dm-border p-2 block lg:table-cell not-last:border-b-1 lg:border-b-1 before:lg:hidden before:content-[attr(data-column)] before:font-bold before:mr-1 text-right max-lg:absolute right-0 -top-1 mx-lg:pt-0"
|
class="border-lm-border dark:border-dm-border p-2 block lg:table-cell not-last:border-b-1 lg:border-b-1 before:lg:hidden before:content-[attr(data-column)] before:font-bold before:mr-1 text-right max-lg:absolute right-0 -top-1 mx-lg:pt-0"
|
||||||
>
|
>
|
||||||
<div
|
<span>
|
||||||
class="relative inline-block"
|
ManageServersRowDropdown
|
||||||
>
|
</span>
|
||||||
<button
|
|
||||||
aria-controls="_r_t_"
|
|
||||||
aria-expanded="false"
|
|
||||||
aria-haspopup="true"
|
|
||||||
aria-label="Options"
|
|
||||||
class="flex items-center rounded-md focus-ring cursor-pointer border border-lm-border dark:border-dm-border bg-lm-primary dark:bg-dm-primary group-[&]/card:bg-lm-input group-[&]/card:dark:bg-dm-input px-3 py-1.5 gap-x-2"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
aria-hidden="true"
|
|
||||||
class="svg-inline--fa fa-ellipsis-vertical fa-width-auto"
|
|
||||||
data-icon="ellipsis-vertical"
|
|
||||||
data-prefix="fas"
|
|
||||||
role="img"
|
|
||||||
viewBox="0 0 128 512"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M64 144a56 56 0 1 1 0-112 56 56 0 1 1 0 112zm0 224c30.9 0 56 25.1 56 56s-25.1 56-56 56-56-25.1-56-56 25.1-56 56-56zm56-112c0 30.9-25.1 56-56 56s-56-25.1-56-56 25.1-56 56-56 56 25.1 56 56z"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ exports[`<ManageServersRowDropdown /> > renders expected size and icon 1`] = `
|
|||||||
class="relative inline-block"
|
class="relative inline-block"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
aria-controls="_r_1v_"
|
aria-controls="«r9»"
|
||||||
aria-expanded="false"
|
aria-expanded="false"
|
||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
aria-label="Options"
|
aria-label="Options"
|
||||||
@@ -15,7 +15,7 @@ exports[`<ManageServersRowDropdown /> > renders expected size and icon 1`] = `
|
|||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
class="svg-inline--fa fa-ellipsis-vertical fa-width-auto"
|
class="svg-inline--fa fa-ellipsis-vertical fa-width-auto "
|
||||||
data-icon="ellipsis-vertical"
|
data-icon="ellipsis-vertical"
|
||||||
data-prefix="fas"
|
data-prefix="fas"
|
||||||
role="img"
|
role="img"
|
||||||
@@ -28,6 +28,10 @@ exports[`<ManageServersRowDropdown /> > renders expected size and icon 1`] = `
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<span>
|
||||||
|
DeleteServerModal
|
||||||
|
[CLOSED]
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -37,7 +41,7 @@ exports[`<ManageServersRowDropdown /> > renders expected size and icon 2`] = `
|
|||||||
class="relative inline-block"
|
class="relative inline-block"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
aria-controls="_r_23_"
|
aria-controls="«rb»"
|
||||||
aria-expanded="false"
|
aria-expanded="false"
|
||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
aria-label="Options"
|
aria-label="Options"
|
||||||
@@ -46,7 +50,7 @@ exports[`<ManageServersRowDropdown /> > renders expected size and icon 2`] = `
|
|||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
class="svg-inline--fa fa-ellipsis-vertical fa-width-auto"
|
class="svg-inline--fa fa-ellipsis-vertical fa-width-auto "
|
||||||
data-icon="ellipsis-vertical"
|
data-icon="ellipsis-vertical"
|
||||||
data-prefix="fas"
|
data-prefix="fas"
|
||||||
role="img"
|
role="img"
|
||||||
@@ -59,5 +63,9 @@ exports[`<ManageServersRowDropdown /> > renders expected size and icon 2`] = `
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<span>
|
||||||
|
DeleteServerModal
|
||||||
|
[CLOSED]
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -1,22 +1,27 @@
|
|||||||
import { screen, waitFor } from '@testing-library/react';
|
import { screen, waitFor } from '@testing-library/react';
|
||||||
import { fromPartial } from '@total-typescript/shoehorn';
|
import { fromPartial } from '@total-typescript/shoehorn';
|
||||||
import type { ServerData, ServersMap, ServerWithId } from '../../../src/servers/data';
|
import type { ServerData, ServersMap, ServerWithId } from '../../../src/servers/data';
|
||||||
import type { ImportServersBtnProps } from '../../../src/servers/helpers/ImportServersBtn';
|
import type {
|
||||||
import { ImportServersBtn } from '../../../src/servers/helpers/ImportServersBtn';
|
ImportServersBtnProps } from '../../../src/servers/helpers/ImportServersBtn';
|
||||||
|
import { ImportServersBtnFactory } from '../../../src/servers/helpers/ImportServersBtn';
|
||||||
import type { ServersImporter } from '../../../src/servers/services/ServersImporter';
|
import type { ServersImporter } from '../../../src/servers/services/ServersImporter';
|
||||||
import { checkAccessibility } from '../../__helpers__/accessibility';
|
import { checkAccessibility } from '../../__helpers__/accessibility';
|
||||||
import { renderWithStore } from '../../__helpers__/setUpTest';
|
import { renderWithEvents } from '../../__helpers__/setUpTest';
|
||||||
|
|
||||||
describe('<ImportServersBtn />', () => {
|
describe('<ImportServersBtn />', () => {
|
||||||
const csvFile = new File([''], 'servers.csv', { type: 'text/csv' });
|
const csvFile = new File([''], 'servers.csv', { type: 'text/csv' });
|
||||||
const onImportMock = vi.fn();
|
const onImportMock = vi.fn();
|
||||||
|
const createServersMock = vi.fn();
|
||||||
const importServersFromFile = vi.fn().mockResolvedValue([]);
|
const importServersFromFile = vi.fn().mockResolvedValue([]);
|
||||||
const serversImporterMock = fromPartial<ServersImporter>({ importServersFromFile });
|
const serversImporterMock = fromPartial<ServersImporter>({ importServersFromFile });
|
||||||
const setUp = (props: Partial<ImportServersBtnProps> = {}, servers: ServersMap = {}) => renderWithStore(
|
const ImportServersBtn = ImportServersBtnFactory(fromPartial({ ServersImporter: serversImporterMock }));
|
||||||
<ImportServersBtn {...props} onImport={onImportMock} ServersImporter={serversImporterMock} />,
|
const setUp = (props: Partial<ImportServersBtnProps> = {}, servers: ServersMap = {}) => renderWithEvents(
|
||||||
{
|
<ImportServersBtn
|
||||||
initialState: { servers },
|
servers={servers}
|
||||||
},
|
{...props}
|
||||||
|
createServers={createServersMock}
|
||||||
|
onImport={onImportMock}
|
||||||
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
it('passes a11y checks', () => checkAccessibility(setUp()));
|
it('passes a11y checks', () => checkAccessibility(setUp()));
|
||||||
@@ -52,8 +57,11 @@ describe('<ImportServersBtn />', () => {
|
|||||||
it('imports servers when file input changes', async () => {
|
it('imports servers when file input changes', async () => {
|
||||||
const { user } = setUp();
|
const { user } = setUp();
|
||||||
|
|
||||||
await user.upload(screen.getByTestId('csv-file-input'), csvFile);
|
const input = screen.getByTestId('csv-file-input');
|
||||||
|
await user.upload(input, csvFile);
|
||||||
|
|
||||||
expect(importServersFromFile).toHaveBeenCalledTimes(1);
|
expect(importServersFromFile).toHaveBeenCalledTimes(1);
|
||||||
|
expect(createServersMock).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
@@ -70,27 +78,26 @@ describe('<ImportServersBtn />', () => {
|
|||||||
id: 'existingserver-s.test',
|
id: 'existingserver-s.test',
|
||||||
};
|
};
|
||||||
const newServer: ServerData = { name: 'newServer', url: 'http://s.test/newUrl', apiKey: 'newApiKey' };
|
const newServer: ServerData = { name: 'newServer', url: 'http://s.test/newUrl', apiKey: 'newApiKey' };
|
||||||
const { user, store } = setUp({}, { [existingServer.id]: existingServer });
|
const { user } = setUp({}, { [existingServer.id]: existingServer });
|
||||||
|
|
||||||
importServersFromFile.mockResolvedValue([existingServerData, newServer]);
|
importServersFromFile.mockResolvedValue([existingServer, newServer]);
|
||||||
|
|
||||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||||
await user.upload(screen.getByTestId('csv-file-input'), csvFile);
|
await user.upload(screen.getByTestId('csv-file-input'), csvFile);
|
||||||
|
|
||||||
// Once the file is uploaded, non-duplicated servers are immediately created
|
// Once the file is uploaded, non-duplicated servers are immediately created
|
||||||
const { servers } = store.getState();
|
expect(createServersMock).toHaveBeenCalledExactlyOnceWith([expect.objectContaining(newServer)]);
|
||||||
expect(Object.keys(servers)).toHaveLength(2);
|
|
||||||
|
|
||||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||||
await user.click(screen.getByRole('button', { name: btnName }));
|
await user.click(screen.getByRole('button', { name: btnName }));
|
||||||
|
|
||||||
// If duplicated servers are saved, there's one extra server creation
|
// If duplicated servers are saved, there's one extra call
|
||||||
if (savesDuplicatedServers) {
|
if (savesDuplicatedServers) {
|
||||||
const { servers } = store.getState();
|
expect(createServersMock).toHaveBeenLastCalledWith([expect.objectContaining(existingServerData)]);
|
||||||
expect(Object.keys(servers)).toHaveLength(3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// On import is called only once, no matter what
|
// On import is called only once, no matter what
|
||||||
expect(onImportMock).toHaveBeenCalledOnce();
|
expect(onImportMock).toHaveBeenCalledOnce();
|
||||||
|
expect(createServersMock).toHaveBeenCalledTimes(savesDuplicatedServers ? 2 : 1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,19 +1,16 @@
|
|||||||
import { screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import { fromPartial } from '@total-typescript/shoehorn';
|
import { fromPartial } from '@total-typescript/shoehorn';
|
||||||
import { MemoryRouter } from 'react-router';
|
import { MemoryRouter } from 'react-router';
|
||||||
import type { NonReachableServer, NotFoundServer, SelectedServer } from '../../../src/servers/data';
|
import type { NonReachableServer, NotFoundServer, SelectedServer } from '../../../src/servers/data';
|
||||||
import { ServerError } from '../../../src/servers/helpers/ServerError';
|
import { ServerErrorFactory } from '../../../src/servers/helpers/ServerError';
|
||||||
import { checkAccessibility } from '../../__helpers__/accessibility';
|
import { checkAccessibility } from '../../__helpers__/accessibility';
|
||||||
import { renderWithStore } from '../../__helpers__/setUpTest';
|
|
||||||
|
|
||||||
describe('<ServerError />', () => {
|
describe('<ServerError />', () => {
|
||||||
const setUp = (selectedServer: SelectedServer) => renderWithStore(
|
const ServerError = ServerErrorFactory(fromPartial({ DeleteServerButton: () => null }));
|
||||||
|
const setUp = (selectedServer: SelectedServer) => render(
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
<ServerError />
|
<ServerError servers={{}} selectedServer={selectedServer} />
|
||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
{
|
|
||||||
initialState: { selectedServer, servers: {} },
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
|
|||||||
@@ -79,8 +79,9 @@ describe('remoteServersReducer', () => {
|
|||||||
},
|
},
|
||||||
])('tries to fetch servers from remote', async ({ serversArray, expectedNewServers }) => {
|
])('tries to fetch servers from remote', async ({ serversArray, expectedNewServers }) => {
|
||||||
jsonRequest.mockResolvedValue(serversArray);
|
jsonRequest.mockResolvedValue(serversArray);
|
||||||
|
const doFetchServers = fetchServers(httpClient);
|
||||||
|
|
||||||
await fetchServers(httpClient)(dispatch, vi.fn(), {});
|
await doFetchServers()(dispatch, vi.fn(), {});
|
||||||
|
|
||||||
expect(dispatch).toHaveBeenCalledTimes(3);
|
expect(dispatch).toHaveBeenCalledTimes(3);
|
||||||
expect(dispatch).toHaveBeenNthCalledWith(2, expect.objectContaining({ payload: expectedNewServers }));
|
expect(dispatch).toHaveBeenNthCalledWith(2, expect.objectContaining({ payload: expectedNewServers }));
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
import type { ShlinkApiClient } from '@shlinkio/shlink-js-sdk';
|
import type { ShlinkApiClient } from '@shlinkio/shlink-js-sdk';
|
||||||
import { fromPartial } from '@total-typescript/shoehorn';
|
import { fromPartial } from '@total-typescript/shoehorn';
|
||||||
|
import type { ShlinkState } from '../../../src/container/types';
|
||||||
import type { NonReachableServer, NotFoundServer, RegularServer } from '../../../src/servers/data';
|
import type { NonReachableServer, NotFoundServer, RegularServer } from '../../../src/servers/data';
|
||||||
import {
|
import {
|
||||||
MAX_FALLBACK_VERSION,
|
MAX_FALLBACK_VERSION,
|
||||||
MIN_FALLBACK_VERSION,
|
MIN_FALLBACK_VERSION,
|
||||||
resetSelectedServer,
|
resetSelectedServer,
|
||||||
selectedServerReducer as reducer,
|
selectedServerReducerCreator,
|
||||||
selectServer,
|
selectServer as selectServerCreator,
|
||||||
} from '../../../src/servers/reducers/selectedServer';
|
} from '../../../src/servers/reducers/selectedServer';
|
||||||
import type { RootState } from '../../../src/store';
|
|
||||||
|
|
||||||
describe('selectedServerReducer', () => {
|
describe('selectedServerReducer', () => {
|
||||||
const dispatch = vi.fn();
|
const dispatch = vi.fn();
|
||||||
const health = vi.fn();
|
const health = vi.fn();
|
||||||
const buildShlinkApiClient = vi.fn().mockReturnValue(fromPartial<ShlinkApiClient>({ health }));
|
const buildApiClient = vi.fn().mockReturnValue(fromPartial<ShlinkApiClient>({ health }));
|
||||||
|
const selectServer = selectServerCreator(buildApiClient);
|
||||||
|
const { reducer } = selectedServerReducerCreator(selectServer);
|
||||||
|
|
||||||
describe('reducer', () => {
|
describe('reducer', () => {
|
||||||
it('returns default when action is RESET_SELECTED_SERVER', () =>
|
it('returns default when action is RESET_SELECTED_SERVER', () =>
|
||||||
@@ -21,7 +23,7 @@ describe('selectedServerReducer', () => {
|
|||||||
|
|
||||||
it('returns selected server when action is SELECT_SERVER', () => {
|
it('returns selected server when action is SELECT_SERVER', () => {
|
||||||
const payload = fromPartial<RegularServer>({ id: 'abc123' });
|
const payload = fromPartial<RegularServer>({ id: 'abc123' });
|
||||||
expect(reducer(null, selectServer.fulfilled(payload, '', { serverId: '', buildShlinkApiClient }))).toEqual(payload);
|
expect(reducer(null, selectServer.fulfilled(payload, '', ''))).toEqual(payload);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -48,10 +50,10 @@ describe('selectedServerReducer', () => {
|
|||||||
|
|
||||||
health.mockResolvedValue({ version: serverVersion });
|
health.mockResolvedValue({ version: serverVersion });
|
||||||
|
|
||||||
await selectServer({ serverId: id, buildShlinkApiClient })(dispatch, getState, {});
|
await selectServer(id)(dispatch, getState, {});
|
||||||
|
|
||||||
expect(getState).toHaveBeenCalledTimes(1);
|
expect(getState).toHaveBeenCalledTimes(1);
|
||||||
expect(buildShlinkApiClient).toHaveBeenCalledTimes(1);
|
expect(buildApiClient).toHaveBeenCalledTimes(1);
|
||||||
expect(dispatch).toHaveBeenCalledTimes(3); // "Pending", "reset" and "fulfilled"
|
expect(dispatch).toHaveBeenCalledTimes(3); // "Pending", "reset" and "fulfilled"
|
||||||
expect(dispatch).toHaveBeenLastCalledWith(expect.objectContaining({ payload: expectedSelectedServer }));
|
expect(dispatch).toHaveBeenLastCalledWith(expect.objectContaining({ payload: expectedSelectedServer }));
|
||||||
});
|
});
|
||||||
@@ -63,7 +65,7 @@ describe('selectedServerReducer', () => {
|
|||||||
|
|
||||||
health.mockRejectedValue({});
|
health.mockRejectedValue({});
|
||||||
|
|
||||||
await selectServer({ serverId: id, buildShlinkApiClient })(dispatch, getState, {});
|
await selectServer(id)(dispatch, getState, {});
|
||||||
|
|
||||||
expect(health).toHaveBeenCalled();
|
expect(health).toHaveBeenCalled();
|
||||||
expect(dispatch).toHaveBeenLastCalledWith(expect.objectContaining({ payload: expectedSelectedServer }));
|
expect(dispatch).toHaveBeenLastCalledWith(expect.objectContaining({ payload: expectedSelectedServer }));
|
||||||
@@ -71,10 +73,10 @@ describe('selectedServerReducer', () => {
|
|||||||
|
|
||||||
it('dispatches error when server is not found', async () => {
|
it('dispatches error when server is not found', async () => {
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
const getState = vi.fn(() => fromPartial<RootState>({ servers: {} }));
|
const getState = vi.fn(() => fromPartial<ShlinkState>({ servers: {} }));
|
||||||
const expectedSelectedServer: NotFoundServer = { serverNotFound: true };
|
const expectedSelectedServer: NotFoundServer = { serverNotFound: true };
|
||||||
|
|
||||||
await selectServer({ serverId: id, buildShlinkApiClient })(dispatch, getState, {});
|
await selectServer(id)(dispatch, getState, {});
|
||||||
|
|
||||||
expect(getState).toHaveBeenCalled();
|
expect(getState).toHaveBeenCalled();
|
||||||
expect(health).not.toHaveBeenCalled();
|
expect(health).not.toHaveBeenCalled();
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
|
import { render } from '@testing-library/react';
|
||||||
import { MemoryRouter } from 'react-router';
|
import { MemoryRouter } from 'react-router';
|
||||||
import { Settings } from '../../src/settings/Settings';
|
import { Settings } from '../../src/settings/Settings';
|
||||||
import { checkAccessibility } from '../__helpers__/accessibility';
|
import { checkAccessibility } from '../__helpers__/accessibility';
|
||||||
import { renderWithStore } from '../__helpers__/setUpTest';
|
|
||||||
|
|
||||||
describe('<Settings />', () => {
|
describe('<Settings />', () => {
|
||||||
const setUp = () => renderWithStore(
|
const setUp = () => render(
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
<Settings />
|
<Settings settings={{}} setSettings={vi.fn()} />
|
||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { fromPartial } from '@total-typescript/shoehorn';
|
import { fromPartial } from '@total-typescript/shoehorn';
|
||||||
|
import type { ShlinkState } from '../../../src/container/types';
|
||||||
import { migrateDeprecatedSettings } from '../../../src/settings/helpers';
|
import { migrateDeprecatedSettings } from '../../../src/settings/helpers';
|
||||||
import type { RootState } from '../../../src/store';
|
|
||||||
|
|
||||||
describe('settings-helpers', () => {
|
describe('settings-helpers', () => {
|
||||||
describe('migrateDeprecatedSettings', () => {
|
describe('migrateDeprecatedSettings', () => {
|
||||||
@@ -9,7 +9,7 @@ describe('settings-helpers', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('updates settings as expected', () => {
|
it('updates settings as expected', () => {
|
||||||
const state = fromPartial<RootState>({
|
const state = fromPartial<ShlinkState>({
|
||||||
settings: {
|
settings: {
|
||||||
visits: {
|
visits: {
|
||||||
defaultInterval: 'last180days' as any,
|
defaultInterval: 'last180days' as any,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { DEFAULT_SHORT_URLS_ORDERING, setSettings, settingsReducer } from '../..
|
|||||||
|
|
||||||
describe('settingsReducer', () => {
|
describe('settingsReducer', () => {
|
||||||
const realTimeUpdates = { enabled: true };
|
const realTimeUpdates = { enabled: true };
|
||||||
const shortUrlCreation = {};
|
const shortUrlCreation = { validateUrls: false };
|
||||||
const ui = { theme: 'light' as const };
|
const ui = { theme: 'light' as const };
|
||||||
const visits = { defaultInterval: 'last30Days' as const };
|
const visits = { defaultInterval: 'last30Days' as const };
|
||||||
const shortUrlsList = { defaultOrdering: DEFAULT_SHORT_URLS_ORDERING };
|
const shortUrlsList = { defaultOrdering: DEFAULT_SHORT_URLS_ORDERING };
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import tailwindcss from '@tailwindcss/vite';
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
import { playwright } from '@vitest/browser-playwright';
|
|
||||||
import { resolve } from 'path';
|
import { resolve } from 'path';
|
||||||
import { VitePWA } from 'vite-plugin-pwa';
|
import { VitePWA } from 'vite-plugin-pwa';
|
||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig } from 'vitest/config';
|
||||||
@@ -41,7 +40,7 @@ export default defineConfig({
|
|||||||
test: {
|
test: {
|
||||||
// Run tests in an actual browser
|
// Run tests in an actual browser
|
||||||
browser: {
|
browser: {
|
||||||
provider: playwright(),
|
provider: 'playwright',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
headless: true,
|
headless: true,
|
||||||
screenshotFailures: false,
|
screenshotFailures: false,
|
||||||
@@ -64,8 +63,8 @@ export default defineConfig({
|
|||||||
// Required code coverage. Lower than this will make the check fail
|
// Required code coverage. Lower than this will make the check fail
|
||||||
thresholds: {
|
thresholds: {
|
||||||
statements: 95,
|
statements: 95,
|
||||||
branches: 89, // FIXME Increase to 95 again. It dropped after updating to vitest 4
|
branches: 95,
|
||||||
functions: 93,
|
functions: 95,
|
||||||
lines: 95,
|
lines: 95,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user