mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2026-08-06 19:11:53 +00:00
Merge pull request #1955 from acelaya-forks/oxlint-oxfmt
Migrate from ESLint to Oxlint and Oxfmt
This commit is contained in:
8
.github/dependabot.yml
vendored
8
.github/dependabot.yml
vendored
@@ -12,12 +12,12 @@ updates:
|
|||||||
fontawesome:
|
fontawesome:
|
||||||
patterns:
|
patterns:
|
||||||
- '@fortawesome/*'
|
- '@fortawesome/*'
|
||||||
eslint:
|
coding-styles:
|
||||||
patterns:
|
patterns:
|
||||||
- '@shlinkio/eslint-config-js-coding-standard'
|
- '@shlinkio/eslint-config-js-coding-standard'
|
||||||
- 'typescript-eslint'
|
- 'oxfmt'
|
||||||
- '*eslint-plugin*'
|
- 'oxlint'
|
||||||
- 'eslint'
|
- 'oxlint-tsgolint'
|
||||||
shlink:
|
shlink:
|
||||||
patterns:
|
patterns:
|
||||||
- '@shlinkio/*'
|
- '@shlinkio/*'
|
||||||
|
|||||||
1
.github/workflows/ci.yml
vendored
1
.github/workflows/ci.yml
vendored
@@ -14,3 +14,4 @@ jobs:
|
|||||||
node-version: 24.x
|
node-version: 24.x
|
||||||
publish-coverage: true
|
publish-coverage: true
|
||||||
install-playwright: true
|
install-playwright: true
|
||||||
|
cs-script: true
|
||||||
|
|||||||
17
CHANGELOG.md
17
CHANGELOG.md
@@ -4,6 +4,23 @@ 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).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
### Added
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
* Migrate from ESLint to Oxlint and Oxfmt.
|
||||||
|
|
||||||
|
### Deprecated
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
|
||||||
## [4.8.0] - 2026-06-20
|
## [4.8.0] - 2026-06-20
|
||||||
### Added
|
### Added
|
||||||
* [shlink-web-client] Add support for Shlink 5.1.0, by supporting browser redirect conditions.
|
* [shlink-web-client] Add support for Shlink 5.1.0, by supporting browser redirect conditions.
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
FROM mcr.microsoft.com/playwright:v1.61.1-noble
|
FROM mcr.microsoft.com/playwright:v1.61.1-noble
|
||||||
|
|
||||||
ENV NODE_VERSION 24.14
|
|
||||||
ENV TINI_VERSION v0.19.0
|
ENV TINI_VERSION v0.19.0
|
||||||
|
|
||||||
# Install Node.js
|
|
||||||
RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.2/install.sh | bash && \
|
|
||||||
\. "$HOME/.nvm/nvm.sh" && \
|
|
||||||
nvm install ${NODE_VERSION}
|
|
||||||
|
|
||||||
# Install tini
|
# Install tini
|
||||||
ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini /sbin/tini
|
ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini /sbin/tini
|
||||||
RUN chmod +x /sbin/tini
|
RUN chmod +x /sbin/tini
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
import shlink from '@shlinkio/eslint-config-js-coding-standard';
|
|
||||||
|
|
||||||
/* eslint-disable-next-line no-restricted-exports */
|
|
||||||
export default shlink;
|
|
||||||
4
oxfmt.config.ts
Normal file
4
oxfmt.config.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
// @ts-expect-error This lib does not expose type definitions
|
||||||
|
import shlink from '@shlinkio/eslint-config-js-coding-standard/oxc/oxfmt';
|
||||||
|
|
||||||
|
export default shlink;
|
||||||
24
oxlint.config.ts
Normal file
24
oxlint.config.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
// @ts-expect-error This lib does not expose type definitions
|
||||||
|
import shlink from '@shlinkio/eslint-config-js-coding-standard/oxc/oxlint';
|
||||||
|
import { defineConfig } from 'oxlint';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
extends: [shlink],
|
||||||
|
rules: {
|
||||||
|
'typescript/no-floating-promises': 'off', // FIXME Enable this
|
||||||
|
},
|
||||||
|
overrides: [
|
||||||
|
{
|
||||||
|
files: ['./*.config.ts'],
|
||||||
|
rules: {
|
||||||
|
'eslint/no-restricted-exports': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['**/*.test.*'],
|
||||||
|
rules: {
|
||||||
|
'typescript/unbound-method': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
4509
package-lock.json
generated
4509
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
20
package.json
20
package.json
@@ -7,8 +7,12 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"lint": "eslint src test config/test *.config.{js,ts}",
|
"cs": "node --run lint && node --run format:check",
|
||||||
|
"cs:fix": "node --run lint:fix && node --run format",
|
||||||
|
"lint": "oxlint src test config/test ./*.config.ts",
|
||||||
"lint:fix": "node --run lint -- --fix",
|
"lint:fix": "node --run lint -- --fix",
|
||||||
|
"format": "oxfmt src test config/test ./*.config.ts",
|
||||||
|
"format:check": "node --run format -- --check",
|
||||||
"types": "tsc",
|
"types": "tsc",
|
||||||
"start": "vite serve --host=0.0.0.0",
|
"start": "vite serve --host=0.0.0.0",
|
||||||
"preview": "vite preview --host=0.0.0.0",
|
"preview": "vite preview --host=0.0.0.0",
|
||||||
@@ -51,8 +55,7 @@
|
|||||||
"workbox-strategies": "^7.4.0"
|
"workbox-strategies": "^7.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@shlinkio/eslint-config-js-coding-standard": "~3.7.0",
|
"@shlinkio/eslint-config-js-coding-standard": "~3.9.2",
|
||||||
"@stylistic/eslint-plugin": "^5.7.1",
|
|
||||||
"@tailwindcss/vite": "^4.3.3",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
"@testing-library/jest-dom": "^7.0.0",
|
"@testing-library/jest-dom": "^7.0.0",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
@@ -67,18 +70,13 @@
|
|||||||
"adm-zip": "^0.6.0",
|
"adm-zip": "^0.6.0",
|
||||||
"axe-core": "^4.12.1",
|
"axe-core": "^4.12.1",
|
||||||
"chalk": "^5.6.2",
|
"chalk": "^5.6.2",
|
||||||
"eslint": "^9.39.2",
|
|
||||||
"eslint-plugin-import": "^2.32.0",
|
|
||||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
|
||||||
"eslint-plugin-react": "^7.37.5",
|
|
||||||
"eslint-plugin-react-compiler": "^19.0.0-beta-714736e-20250131",
|
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
|
||||||
"eslint-plugin-simple-import-sort": "^12.1.1",
|
|
||||||
"history": "^5.3.0",
|
"history": "^5.3.0",
|
||||||
|
"oxfmt": "^0.60.0",
|
||||||
|
"oxlint": "^1.75.0",
|
||||||
|
"oxlint-tsgolint": "^7.0.2001",
|
||||||
"playwright": "^1.62.0",
|
"playwright": "^1.62.0",
|
||||||
"tailwindcss": "^4.1.3",
|
"tailwindcss": "^4.1.3",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"typescript-eslint": "^8.54.0",
|
|
||||||
"vite": "^8.1.5",
|
"vite": "^8.1.5",
|
||||||
"vite-plugin-pwa": "^1.3.0",
|
"vite-plugin-pwa": "^1.3.0",
|
||||||
"vitest": "^4.0.3"
|
"vitest": "^4.0.3"
|
||||||
|
|||||||
@@ -9,14 +9,18 @@ const apiClients: Map<string, ShlinkApiClient> = new Map();
|
|||||||
const getSelectedServerFromState = (getState: GetState): ServerWithId => {
|
const getSelectedServerFromState = (getState: GetState): ServerWithId => {
|
||||||
const { selectedServer } = getState();
|
const { selectedServer } = getState();
|
||||||
if (!hasServerData(selectedServer)) {
|
if (!hasServerData(selectedServer)) {
|
||||||
throw new Error('There\'s no selected server or it is not found');
|
throw new Error("There's no selected server or it is not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
return selectedServer;
|
return selectedServer;
|
||||||
};
|
};
|
||||||
|
|
||||||
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,
|
||||||
|
} = typeof getStateOrSelectedServer === 'function'
|
||||||
? getSelectedServerFromState(getStateOrSelectedServer)
|
? getSelectedServerFromState(getStateOrSelectedServer)
|
||||||
: getStateOrSelectedServer;
|
: getStateOrSelectedServer;
|
||||||
const serverKey = `${apiKey}_${baseUrl}_${forwardCredentials ? 'forward' : 'no-forward'}`;
|
const serverKey = `${apiKey}_${baseUrl}_${forwardCredentials ? 'forward' : 'no-forward'}`;
|
||||||
|
|||||||
@@ -47,13 +47,17 @@ export const App: FC = () => {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route index element={<Home />} />
|
<Route index element={<Home />} />
|
||||||
<Route path="/settings">
|
<Route path="/settings">
|
||||||
{['', '*'].map((path) => <Route key={path} path={path} element={<Settings />} />)}
|
{['', '*'].map((path) => (
|
||||||
|
<Route key={path} path={path} element={<Settings />} />
|
||||||
|
))}
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/manage-servers" element={<ManageServers />} />
|
<Route path="/manage-servers" element={<ManageServers />} />
|
||||||
<Route path="/server/create" element={<CreateServer />} />
|
<Route path="/server/create" element={<CreateServer />} />
|
||||||
<Route path="/server/:serverId/edit" element={<EditServer />} />
|
<Route path="/server/:serverId/edit" element={<EditServer />} />
|
||||||
<Route path="/server/:serverId">
|
<Route path="/server/:serverId">
|
||||||
{['', '*'].map((path) => <Route key={path} path={path} element={<ShlinkWebComponentContainer />} />)}
|
{['', '*'].map((path) => (
|
||||||
|
<Route key={path} path={path} element={<ShlinkWebComponentContainer />} />
|
||||||
|
))}
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { faSyncAlt as reloadIcon } from '@fortawesome/free-solid-svg-icons';
|
import { faSyncAlt as reloadIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { Button, Card, CloseButton,useToggle } from '@shlinkio/shlink-frontend-kit';
|
import { Button, Card, CloseButton, useToggle } from '@shlinkio/shlink-frontend-kit';
|
||||||
import { clsx } from 'clsx';
|
import { clsx } from 'clsx';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
@@ -25,10 +25,7 @@ export const AppUpdateBanner: FC<AppUpdateBannerProps> = ({ isOpen, onClose, for
|
|||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
role="alert"
|
role="alert"
|
||||||
className={clsx(
|
className={clsx('w-[700px] max-w-[calc(100%-30px)]', 'fixed top-[35px] left-[50%] translate-x-[-50%] z-[1040]')}
|
||||||
'w-[700px] max-w-[calc(100%-30px)]',
|
|
||||||
'fixed top-[35px] left-[50%] translate-x-[-50%] z-[1040]',
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<Card.Header className="flex items-center justify-between">
|
<Card.Header className="flex items-center justify-between">
|
||||||
<h5>This app has just been updated!</h5>
|
<h5>This app has just been updated!</h5>
|
||||||
@@ -37,7 +34,11 @@ export const AppUpdateBanner: FC<AppUpdateBannerProps> = ({ isOpen, onClose, for
|
|||||||
<Card.Body className="flex gap-4 items-center justify-between max-md:flex-col">
|
<Card.Body className="flex gap-4 items-center justify-between max-md:flex-col">
|
||||||
Restart it to enjoy the new features.
|
Restart it to enjoy the new features.
|
||||||
<Button disabled={isUpdating} variant="secondary" solid onClick={update}>
|
<Button disabled={isUpdating} variant="secondary" solid onClick={update}>
|
||||||
{!isUpdating && <>Restart now <FontAwesomeIcon icon={reloadIcon} /></>}
|
{!isUpdating && (
|
||||||
|
<>
|
||||||
|
Restart now <FontAwesomeIcon icon={reloadIcon} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{isUpdating && <>Restarting...</>}
|
{isUpdating && <>Restarting...</>}
|
||||||
</Button>
|
</Button>
|
||||||
</Card.Body>
|
</Card.Body>
|
||||||
|
|||||||
@@ -36,7 +36,9 @@ export class ErrorHandler extends Component<ErrorHandlerProps, ErrorHandlerState
|
|||||||
<ErrorLayout title="Oops! This is awkward :S">
|
<ErrorLayout title="Oops! This is awkward :S">
|
||||||
<p>It seems that something went wrong. Try refreshing the page or just click this button.</p>
|
<p>It seems that something went wrong. Try refreshing the page or just click this button.</p>
|
||||||
<br />
|
<br />
|
||||||
<Button size="lg" onClick={() => location.reload()}>Take me back</Button>
|
<Button size="lg" onClick={() => location.reload()}>
|
||||||
|
Take me back
|
||||||
|
</Button>
|
||||||
</ErrorLayout>
|
</ErrorLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,15 +36,12 @@ export const Home: FC = withoutSelectedServer(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="md:border-l border-lm-border dark:border-dm-border flex-grow">
|
<div className="md:border-l border-lm-border dark:border-dm-border flex-grow">
|
||||||
<h1
|
<h1 className={clsx('p-4 text-center border-lm-border dark:border-dm-border', { 'border-b': !hasServers })}>
|
||||||
className={clsx(
|
|
||||||
'p-4 text-center border-lm-border dark:border-dm-border',
|
|
||||||
{ 'border-b': !hasServers },
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Welcome!
|
Welcome!
|
||||||
</h1>
|
</h1>
|
||||||
{hasServers ? <ServersListGroup servers={serversList} /> : (
|
{hasServers ? (
|
||||||
|
<ServersListGroup servers={serversList} />
|
||||||
|
) : (
|
||||||
<div className="p-6 text-center flex flex-col gap-12 text-xl">
|
<div className="p-6 text-center flex flex-col gap-12 text-xl">
|
||||||
<p>This application will help you manage your Shlink servers.</p>
|
<p>This application will help you manage your Shlink servers.</p>
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
@@ -14,11 +14,11 @@ export const MainHeader: FC = () => {
|
|||||||
return (
|
return (
|
||||||
<NavBar
|
<NavBar
|
||||||
className="[&]:fixed top-0 z-900"
|
className="[&]:fixed top-0 z-900"
|
||||||
brand={(
|
brand={
|
||||||
<Link to="/" className="[&]:text-white no-underline flex items-center gap-2">
|
<Link to="/" className="[&]:text-white no-underline flex items-center gap-2">
|
||||||
<ShlinkLogo className="w-7" color="white" /> <small className="font-normal">Shlink</small>
|
<ShlinkLogo className="w-7" color="white" /> <small className="font-normal">Shlink</small>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
}
|
||||||
>
|
>
|
||||||
<NavBar.MenuItem
|
<NavBar.MenuItem
|
||||||
to={settingsPath}
|
to={settingsPath}
|
||||||
|
|||||||
@@ -6,7 +6,5 @@ export type NoMenuLayoutProps = PropsWithChildren & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const NoMenuLayout: FC<NoMenuLayoutProps> = ({ children, className }) => (
|
export const NoMenuLayout: FC<NoMenuLayoutProps> = ({ children, className }) => (
|
||||||
<div className={clsx('container mx-auto p-5 pt-8 max-md:p-3 max-md:py-4', className)}>
|
<div className={clsx('container mx-auto p-5 pt-8 max-md:p-3 max-md:py-4', className)}>{children}</div>
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export const NotFound: FC<NotFoundProps> = ({ to = '/', children = 'Home' }) =>
|
|||||||
button.
|
button.
|
||||||
</p>
|
</p>
|
||||||
<br />
|
<br />
|
||||||
<Button inline to={to} size="lg">{children}</Button>
|
<Button inline to={to} size="lg">
|
||||||
|
{children}
|
||||||
|
</Button>
|
||||||
</ErrorLayout>
|
</ErrorLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ export const ShlinkVersions = ({ selectedServer, clientVersion = SHLINK_WEB_CLIE
|
|||||||
return (
|
return (
|
||||||
<small className="text-gray-500">
|
<small className="text-gray-500">
|
||||||
{isReachableServer(selectedServer) && (
|
{isReachableServer(selectedServer) && (
|
||||||
<>Server: <VersionLink project="shlink" version={selectedServer.printableVersion} /> - </>
|
<>
|
||||||
|
Server: <VersionLink project="shlink" version={selectedServer.printableVersion} /> -{' '}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
Client: <VersionLink project="shlink-web-client" version={normalizedClientVersion} />
|
Client: <VersionLink project="shlink-web-client" version={normalizedClientVersion} />
|
||||||
</small>
|
</small>
|
||||||
|
|||||||
@@ -6,9 +6,7 @@ import { ShlinkVersions } from './ShlinkVersions';
|
|||||||
export const ShlinkVersionsContainer = () => {
|
export const ShlinkVersionsContainer = () => {
|
||||||
const { selectedServer } = useSelectedServer();
|
const { selectedServer } = useSelectedServer();
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={clsx('text-center', { 'md:ml-(--aside-menu-width)': isReachableServer(selectedServer) })}>
|
||||||
className={clsx('text-center', { 'md:ml-(--aside-menu-width)': isReachableServer(selectedServer) })}
|
|
||||||
>
|
|
||||||
<ShlinkVersions selectedServer={selectedServer} />
|
<ShlinkVersions selectedServer={selectedServer} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -20,16 +20,13 @@ export type ShlinkWebComponentContainerProps = {
|
|||||||
buildShlinkApiClient: ShlinkApiClientBuilder;
|
buildShlinkApiClient: ShlinkApiClientBuilder;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ShlinkWebComponentContainerBase: FC<
|
const ShlinkWebComponentContainerBase: FC<ShlinkWebComponentContainerProps> =
|
||||||
ShlinkWebComponentContainerProps
|
// 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(
|
||||||
> = withSelectedServer(memo(({
|
memo(({ buildShlinkApiClient, TagColorsStorage: tagColorsStorage }) => {
|
||||||
buildShlinkApiClient,
|
|
||||||
TagColorsStorage: tagColorsStorage,
|
|
||||||
}) => {
|
|
||||||
const { selectedServer } = useSelectedServer();
|
const { selectedServer } = useSelectedServer();
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
|
|
||||||
@@ -54,7 +51,8 @@ const ShlinkWebComponentContainerBase: FC<
|
|||||||
/>
|
/>
|
||||||
</ShlinkSidebarVisibilityProvider>
|
</ShlinkSidebarVisibilityProvider>
|
||||||
);
|
);
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
export const ShlinkWebComponentContainer = withDependencies(ShlinkWebComponentContainerBase, [
|
export const ShlinkWebComponentContainer = withDependencies(ShlinkWebComponentContainerBase, [
|
||||||
'buildShlinkApiClient',
|
'buildShlinkApiClient',
|
||||||
|
|||||||
@@ -8,18 +8,10 @@ export interface ShlinkLogoProps {
|
|||||||
export const ShlinkLogo = ({ color = brandColor(), className }: ShlinkLogoProps) => (
|
export const ShlinkLogo = ({ color = brandColor(), className }: ShlinkLogoProps) => (
|
||||||
<svg className={className} viewBox="0 0 512 512" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
<svg className={className} viewBox="0 0 512 512" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||||
<g fill={color}>
|
<g fill={color}>
|
||||||
<path
|
<path d=" M 23.71 85.08 C 17.22 49.81 49.44 14.86 85.08 18.12 C 118.83 19.21 145.72 53.33 139.45 86.37 C 155.64 102.30 171.32 118.83 187.87 134.36 C 198.32 111.73 208.84 89.12 219.57 66.62 C 226.05 53.84 243.47 48.74 255.73 56.27 C 263.76 62.10 270.34 69.69 277.25 76.75 C 286.28 86.61 285.72 102.89 276.31 112.31 C 223.38 165.37 170.38 218.37 117.35 271.34 C 107.72 280.99 91.01 281.25 81.11 271.86 C 74.39 264.94 66.82 258.69 61.24 250.77 C 53.72 238.52 58.85 221.07 71.64 214.62 C 94.11 203.87 116.72 193.38 139.33 182.91 C 123.81 166.36 107.30 150.68 91.37 134.49 C 60.20 140.28 27.37 116.78 23.71 85.08 Z" />
|
||||||
d=" M 23.71 85.08 C 17.22 49.81 49.44 14.86 85.08 18.12 C 118.83 19.21 145.72 53.33 139.45 86.37 C 155.64 102.30 171.32 118.83 187.87 134.36 C 198.32 111.73 208.84 89.12 219.57 66.62 C 226.05 53.84 243.47 48.74 255.73 56.27 C 263.76 62.10 270.34 69.69 277.25 76.75 C 286.28 86.61 285.72 102.89 276.31 112.31 C 223.38 165.37 170.38 218.37 117.35 271.34 C 107.72 280.99 91.01 281.25 81.11 271.86 C 74.39 264.94 66.82 258.69 61.24 250.77 C 53.72 238.52 58.85 221.07 71.64 214.62 C 94.11 203.87 116.72 193.38 139.33 182.91 C 123.81 166.36 107.30 150.68 91.37 134.49 C 60.20 140.28 27.37 116.78 23.71 85.08 Z"
|
<path d=" M 205.21 201.23 C 225.32 181.36 260.88 181.11 281.14 200.86 C 299.25 218.75 317.37 236.65 335.10 254.93 C 356.73 278.01 352.01 318.70 326.03 336.56 C 320.07 330.47 313.73 324.65 308.12 318.28 C 323.86 309.39 328.76 286.18 316.63 272.39 C 301.73 256.95 286.30 242.03 271.24 226.75 C 264.49 219.65 256.80 212.00 246.37 211.52 C 224.65 208.64 205.52 233.36 214.49 253.58 C 221.09 266.81 234.22 275.12 243.62 286.24 C 240.43 295.96 238.09 306.13 238.29 316.46 C 225.55 304.29 213.16 291.73 200.89 279.09 C 180.97 257.57 183.10 220.45 205.21 201.23 Z" />
|
||||||
/>
|
<path d=" M 273.90 352.07 C 252.28 328.99 256.98 288.31 282.96 270.46 C 288.93 276.54 295.26 282.36 300.88 288.72 C 285.14 297.62 280.23 320.82 292.38 334.61 C 307.27 350.05 322.70 364.96 337.75 380.25 C 344.51 387.35 352.20 395.00 362.64 395.48 C 384.35 398.37 403.49 373.64 394.51 353.42 C 387.92 340.18 374.78 331.88 365.38 320.76 C 368.56 311.04 370.91 300.86 370.71 290.54 C 383.45 302.70 395.84 315.27 408.11 327.91 C 428.03 349.43 425.90 386.55 403.78 405.77 C 383.68 425.64 348.13 425.89 327.86 406.14 C 309.75 388.25 291.60 370.37 273.90 352.07 Z" />
|
||||||
<path
|
<path d=" M 422.11 403.83 C 431.96 394.07 441.60 384.06 451.66 374.51 C 460.90 383.74 471.89 392.70 474.89 406.11 C 480.16 429.97 484.08 454.13 488.76 478.12 C 490.00 483.41 484.47 488.29 479.35 486.63 C 454.66 481.52 429.55 478.12 405.14 471.84 C 393.17 467.97 385.20 457.75 376.55 449.27 C 386.39 439.49 396.13 429.60 406.06 419.91 C 416.37 433.45 435.74 414.00 422.11 403.83 Z" />
|
||||||
d=" M 205.21 201.23 C 225.32 181.36 260.88 181.11 281.14 200.86 C 299.25 218.75 317.37 236.65 335.10 254.93 C 356.73 278.01 352.01 318.70 326.03 336.56 C 320.07 330.47 313.73 324.65 308.12 318.28 C 323.86 309.39 328.76 286.18 316.63 272.39 C 301.73 256.95 286.30 242.03 271.24 226.75 C 264.49 219.65 256.80 212.00 246.37 211.52 C 224.65 208.64 205.52 233.36 214.49 253.58 C 221.09 266.81 234.22 275.12 243.62 286.24 C 240.43 295.96 238.09 306.13 238.29 316.46 C 225.55 304.29 213.16 291.73 200.89 279.09 C 180.97 257.57 183.10 220.45 205.21 201.23 Z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d=" M 273.90 352.07 C 252.28 328.99 256.98 288.31 282.96 270.46 C 288.93 276.54 295.26 282.36 300.88 288.72 C 285.14 297.62 280.23 320.82 292.38 334.61 C 307.27 350.05 322.70 364.96 337.75 380.25 C 344.51 387.35 352.20 395.00 362.64 395.48 C 384.35 398.37 403.49 373.64 394.51 353.42 C 387.92 340.18 374.78 331.88 365.38 320.76 C 368.56 311.04 370.91 300.86 370.71 290.54 C 383.45 302.70 395.84 315.27 408.11 327.91 C 428.03 349.43 425.90 386.55 403.78 405.77 C 383.68 425.64 348.13 425.89 327.86 406.14 C 309.75 388.25 291.60 370.37 273.90 352.07 Z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d=" M 422.11 403.83 C 431.96 394.07 441.60 384.06 451.66 374.51 C 460.90 383.74 471.89 392.70 474.89 406.11 C 480.16 429.97 484.08 454.13 488.76 478.12 C 490.00 483.41 484.47 488.29 479.35 486.63 C 454.66 481.52 429.55 478.12 405.14 471.84 C 393.17 467.97 385.20 457.75 376.55 449.27 C 386.39 439.49 396.13 429.60 406.06 419.91 C 416.37 433.45 435.74 414.00 422.11 403.83 Z"
|
|
||||||
/>
|
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -36,10 +36,7 @@ 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.
|
* 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.
|
* All dependencies become optional props so that they can still be explicitly set in tests if desired.
|
||||||
*/
|
*/
|
||||||
export function withDependencies<
|
export function withDependencies<Props extends Record<string, unknown>, DependencyName extends string & keyof Props>(
|
||||||
Props extends Record<string, unknown>,
|
|
||||||
DependencyName extends string & keyof Props,
|
|
||||||
>(
|
|
||||||
Component: ComponentType<Props>,
|
Component: ComponentType<Props>,
|
||||||
dependencyNames: DependencyName[],
|
dependencyNames: DependencyName[],
|
||||||
): ComponentType<Optionalize<Props, DependencyName>> {
|
): ComponentType<Optionalize<Props, DependencyName>> {
|
||||||
|
|||||||
@@ -38,13 +38,17 @@ const CreateServerBase: FC<CreateServerProps> = withoutSelectedServer(({ useTime
|
|||||||
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>();
|
||||||
const saveNewServer = useCallback((newServerData: ServerData) => {
|
const saveNewServer = useCallback(
|
||||||
|
(newServerData: ServerData) => {
|
||||||
const [newServerWithUniqueId] = ensureUniqueIds(servers, [newServerData]);
|
const [newServerWithUniqueId] = ensureUniqueIds(servers, [newServerData]);
|
||||||
|
|
||||||
createServers([newServerWithUniqueId]);
|
createServers([newServerWithUniqueId]);
|
||||||
navigate(`/server/${newServerWithUniqueId.id}`);
|
navigate(`/server/${newServerWithUniqueId.id}`);
|
||||||
}, [createServers, navigate, servers]);
|
},
|
||||||
const onSubmit = useCallback((newServerData: ServerData) => {
|
[createServers, navigate, servers],
|
||||||
|
);
|
||||||
|
const onSubmit = useCallback(
|
||||||
|
(newServerData: ServerData) => {
|
||||||
setServerData(newServerData);
|
setServerData(newServerData);
|
||||||
|
|
||||||
const serverExists = Object.values(servers).some(
|
const serverExists = Object.values(servers).some(
|
||||||
@@ -56,7 +60,9 @@ const CreateServerBase: FC<CreateServerProps> = withoutSelectedServer(({ useTime
|
|||||||
} else {
|
} else {
|
||||||
saveNewServer(newServerData);
|
saveNewServer(newServerData);
|
||||||
}
|
}
|
||||||
}, [saveNewServer, servers, toggleConfirmModal]);
|
},
|
||||||
|
[saveNewServer, servers, toggleConfirmModal],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NoMenuLayout>
|
<NoMenuLayout>
|
||||||
@@ -64,7 +70,11 @@ const CreateServerBase: FC<CreateServerProps> = withoutSelectedServer(({ useTime
|
|||||||
{!hasServers && (
|
{!hasServers && (
|
||||||
<ImportServersBtn tooltipPlacement="top" onImport={setServersImported} onError={setErrorImporting} />
|
<ImportServersBtn tooltipPlacement="top" onImport={setServersImported} onError={setErrorImporting} />
|
||||||
)}
|
)}
|
||||||
{hasServers && <Button variant="secondary" onClick={goBack}>Cancel</Button>}
|
{hasServers && (
|
||||||
|
<Button variant="secondary" onClick={goBack}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button type="submit">Create server</Button>
|
<Button type="submit">Create server</Button>
|
||||||
</ServerForm>
|
</ServerForm>
|
||||||
|
|
||||||
|
|||||||
@@ -12,12 +12,15 @@ export type DeleteServerButtonProps = PropsWithChildren<{
|
|||||||
export const DeleteServerButton: FC<DeleteServerButtonProps> = ({ server, children }) => {
|
export const DeleteServerButton: FC<DeleteServerButtonProps> = ({ server, children }) => {
|
||||||
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) => {
|
||||||
hideModal();
|
hideModal();
|
||||||
if (confirmed) {
|
if (confirmed) {
|
||||||
navigate('/');
|
navigate('/');
|
||||||
}
|
}
|
||||||
}, [hideModal, navigate]);
|
},
|
||||||
|
[hideModal, navigate],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -13,11 +13,14 @@ export type DeleteServerModalProps = {
|
|||||||
|
|
||||||
export const DeleteServerModal: FC<DeleteServerModalProps> = ({ server, onClose, open }) => {
|
export const DeleteServerModal: FC<DeleteServerModalProps> = ({ server, onClose, open }) => {
|
||||||
const { deleteServer } = useServers();
|
const { deleteServer } = useServers();
|
||||||
const onClosed = useCallback((exitAction: ExitAction) => {
|
const onClosed = useCallback(
|
||||||
|
(exitAction: ExitAction) => {
|
||||||
if (exitAction === 'confirm') {
|
if (exitAction === 'confirm') {
|
||||||
deleteServer(server);
|
deleteServer(server);
|
||||||
}
|
}
|
||||||
}, [deleteServer, server]);
|
},
|
||||||
|
[deleteServer, server],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CardModal
|
<CardModal
|
||||||
@@ -30,11 +33,13 @@ export const DeleteServerModal: FC<DeleteServerModalProps> = ({ server, onClose,
|
|||||||
confirmText="Delete"
|
confirmText="Delete"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-y-4">
|
<div className="flex flex-col gap-y-4">
|
||||||
<p>Are you sure you want to remove <b>{server ? server.name : ''}</b>?</p>
|
<p>
|
||||||
|
Are you sure you want to remove <b>{server ? server.name : ''}</b>?
|
||||||
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<i>
|
<i>
|
||||||
No data will be deleted, only the access to this server will be removed from this device.
|
No data will be deleted, only the access to this server will be removed from this device. You can create it
|
||||||
You can create it again at any moment.
|
again at any moment.
|
||||||
</i>
|
</i>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -34,7 +34,9 @@ export const EditServer: FC = withSelectedServer(() => {
|
|||||||
initialValues={selectedServer}
|
initialValues={selectedServer}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
>
|
>
|
||||||
<Button variant="secondary" onClick={goBack}>Cancel</Button>
|
<Button variant="secondary" onClick={goBack}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
<Button type="submit">Save</Button>
|
<Button type="submit">Save</Button>
|
||||||
</ServerForm>
|
</ServerForm>
|
||||||
</NoMenuLayout>
|
</NoMenuLayout>
|
||||||
|
|||||||
@@ -19,10 +19,8 @@ export type ManageServersProps = {
|
|||||||
|
|
||||||
const SHOW_IMPORT_MSG_TIME = 4000;
|
const SHOW_IMPORT_MSG_TIME = 4000;
|
||||||
|
|
||||||
const ManageServersBase: FC<ManageServersProps> = withoutSelectedServer(({
|
const ManageServersBase: FC<ManageServersProps> = withoutSelectedServer(
|
||||||
ServersExporter: serversExporter,
|
({ ServersExporter: serversExporter, useTimeoutToggle }) => {
|
||||||
useTimeoutToggle,
|
|
||||||
}) => {
|
|
||||||
const { servers } = useServers();
|
const { servers } = useServers();
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const allServers = useMemo(() => Object.values(servers), [servers]);
|
const allServers = useMemo(() => Object.values(servers), [servers]);
|
||||||
@@ -40,7 +38,9 @@ const ManageServersBase: FC<ManageServersProps> = withoutSelectedServer(({
|
|||||||
|
|
||||||
<div className="flex flex-col md:flex-row gap-2">
|
<div className="flex flex-col md:flex-row gap-2">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<ImportServersBtn className="flex-grow" onError={setErrorImporting}>Import servers</ImportServersBtn>
|
<ImportServersBtn className="flex-grow" onError={setErrorImporting}>
|
||||||
|
Import servers
|
||||||
|
</ImportServersBtn>
|
||||||
{filteredServers.length > 0 && (
|
{filteredServers.length > 0 && (
|
||||||
<Button variant="secondary" className="flex-grow" onClick={async () => serversExporter.exportServers()}>
|
<Button variant="secondary" className="flex-grow" onClick={async () => serversExporter.exportServers()}>
|
||||||
<FontAwesomeIcon icon={exportIcon} widthAuto /> Export servers
|
<FontAwesomeIcon icon={exportIcon} widthAuto /> Export servers
|
||||||
@@ -53,18 +53,26 @@ const ManageServersBase: FC<ManageServersProps> = withoutSelectedServer(({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SimpleCard className="card">
|
<SimpleCard className="card">
|
||||||
<Table header={(
|
<Table
|
||||||
|
header={
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
{hasAutoConnect && (
|
{hasAutoConnect && (
|
||||||
<Table.Cell className="w-[35px]"><span className="sr-only">Auto-connect</span></Table.Cell>
|
<Table.Cell className="w-[35px]">
|
||||||
|
<span className="sr-only">Auto-connect</span>
|
||||||
|
</Table.Cell>
|
||||||
)}
|
)}
|
||||||
<Table.Cell>Name</Table.Cell>
|
<Table.Cell>Name</Table.Cell>
|
||||||
<Table.Cell>Base URL</Table.Cell>
|
<Table.Cell>Base URL</Table.Cell>
|
||||||
<Table.Cell><span className="sr-only">Options</span></Table.Cell>
|
<Table.Cell>
|
||||||
|
<span className="sr-only">Options</span>
|
||||||
|
</Table.Cell>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
)}>
|
}
|
||||||
|
>
|
||||||
{!filteredServers.length && (
|
{!filteredServers.length && (
|
||||||
<Table.Row className="text-center"><Table.Cell colSpan={4}>No servers found.</Table.Cell></Table.Row>
|
<Table.Row className="text-center">
|
||||||
|
<Table.Cell colSpan={4}>No servers found.</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
)}
|
)}
|
||||||
{filteredServers.map((server) => (
|
{filteredServers.map((server) => (
|
||||||
<ManageServersRow key={server.id} server={server} hasAutoConnect={hasAutoConnect} />
|
<ManageServersRow key={server.id} server={server} hasAutoConnect={hasAutoConnect} />
|
||||||
@@ -79,6 +87,7 @@ const ManageServersBase: FC<ManageServersProps> = withoutSelectedServer(({
|
|||||||
)}
|
)}
|
||||||
</NoMenuLayout>
|
</NoMenuLayout>
|
||||||
);
|
);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export const ManageServers = withDependencies(ManageServersBase, ['ServersExporter', 'useTimeoutToggle']);
|
export const ManageServers = withDependencies(ManageServersBase, ['ServersExporter', 'useTimeoutToggle']);
|
||||||
|
|||||||
@@ -34,7 +34,9 @@ export const ManageServersRow: FC<ManageServersRowProps> = ({ server, hasAutoCon
|
|||||||
<Table.Cell className="font-bold" columnName="Name">
|
<Table.Cell className="font-bold" columnName="Name">
|
||||||
<Link to={`/server/${server.id}`}>{server.name}</Link>
|
<Link to={`/server/${server.id}`}>{server.name}</Link>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell columnName="Base URL" className="max-lg:border-b-0">{server.url}</Table.Cell>
|
<Table.Cell columnName="Base URL" className="max-lg:border-b-0">
|
||||||
|
{server.url}
|
||||||
|
</Table.Cell>
|
||||||
<Table.Cell className="text-right max-lg:absolute right-0 -top-1 mx-lg:pt-0">
|
<Table.Cell className="text-right max-lg:absolute right-0 -top-1 mx-lg:pt-0">
|
||||||
<ManageServersRowDropdown server={server} />
|
<ManageServersRowDropdown server={server} />
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
|
|||||||
@@ -12,11 +12,13 @@ export const ServersDropdown: FC = () => {
|
|||||||
const { selectedServer } = useSelectedServer();
|
const { selectedServer } = useSelectedServer();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NavBar.Dropdown buttonContent={(
|
<NavBar.Dropdown
|
||||||
|
buttonContent={
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
<FontAwesomeIcon icon={serverIcon} /> Servers
|
<FontAwesomeIcon icon={serverIcon} /> Servers
|
||||||
</span>
|
</span>
|
||||||
)}>
|
}
|
||||||
|
>
|
||||||
{serversList.length === 0 ? (
|
{serversList.length === 0 ? (
|
||||||
<Dropdown.Item to="/server/create">
|
<Dropdown.Item to="/server/create">
|
||||||
<FontAwesomeIcon icon={plusIcon} /> Add a server
|
<FontAwesomeIcon icon={plusIcon} /> Add a server
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const ServerListItem = ({ id, name }: { id: string; name: string }) => (
|
|||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const ServersListGroup: FC<ServersListGroupProps> = ({ servers, borderless }) => (
|
export const ServersListGroup: FC<ServersListGroupProps> = ({ servers, borderless }) =>
|
||||||
servers.length > 0 && (
|
servers.length > 0 && (
|
||||||
<div
|
<div
|
||||||
data-testid="list"
|
data-testid="list"
|
||||||
@@ -35,7 +35,8 @@ export const ServersListGroup: FC<ServersListGroupProps> = ({ servers, borderles
|
|||||||
{ 'border-y': !borderless },
|
{ 'border-y': !borderless },
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{servers.map(({ id, name }) => <ServerListItem key={id} id={id} name={name} />)}
|
{servers.map(({ id, name }) => (
|
||||||
|
<ServerListItem key={id} id={id} name={name} />
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
);
|
|
||||||
|
|||||||
@@ -10,9 +10,12 @@ export type DuplicatedServersModalProps = {
|
|||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DuplicatedServersModal: FC<DuplicatedServersModalProps> = (
|
export const DuplicatedServersModal: FC<DuplicatedServersModalProps> = ({
|
||||||
{ open, duplicatedServers, onClose, onConfirm },
|
open,
|
||||||
) => {
|
duplicatedServers,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
}) => {
|
||||||
const hasMultipleServers = duplicatedServers.length > 1;
|
const hasMultipleServers = duplicatedServers.length > 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -27,16 +30,24 @@ export const DuplicatedServersModal: FC<DuplicatedServersModalProps> = (
|
|||||||
>
|
>
|
||||||
<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 my-4 pl-5">
|
||||||
{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>
|
||||||
<li>API key: <b>{apiKey}</b></li>
|
URL: <b>{url}</b>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
API key: <b>{apiKey}</b>
|
||||||
|
</li>
|
||||||
</Fragment>
|
</Fragment>
|
||||||
) : <li key={index}><b>{url}</b> - <b>{apiKey}</b></li>))}
|
) : (
|
||||||
|
<li key={index}>
|
||||||
|
<b>{url}</b> - <b>{apiKey}</b>
|
||||||
|
</li>
|
||||||
|
),
|
||||||
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
<span>
|
<span>{hasMultipleServers ? 'Do you want to save duplicated servers' : 'Do you want to save this server'}?</span>
|
||||||
{hasMultipleServers ? 'Do you want to save duplicated servers' : 'Do you want to save this server'}?
|
|
||||||
</span>
|
|
||||||
</CardModal>
|
</CardModal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export type ImportServersBtnProps = PropsWithChildren<{
|
|||||||
className?: string;
|
className?: string;
|
||||||
|
|
||||||
// Injected
|
// Injected
|
||||||
ServersImporter: ServersImporter
|
ServersImporter: ServersImporter;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
const ImportServersBtnBase: FC<ImportServersBtnProps> = ({
|
const ImportServersBtnBase: FC<ImportServersBtnProps> = ({
|
||||||
@@ -37,7 +37,8 @@ const ImportServersBtnBase: FC<ImportServersBtnProps> = ({
|
|||||||
|
|
||||||
const onFile = useCallback(
|
const onFile = useCallback(
|
||||||
async ({ target }: ChangeEvent<HTMLInputElement>) =>
|
async ({ target }: ChangeEvent<HTMLInputElement>) =>
|
||||||
serversImporter.importServersFromFile(target.files?.[0])
|
serversImporter
|
||||||
|
.importServersFromFile(target.files?.[0])
|
||||||
.then((importedServers) => {
|
.then((importedServers) => {
|
||||||
const { duplicatedServers, newServers } = dedupServers(servers, importedServers);
|
const { duplicatedServers, newServers } = dedupServers(servers, importedServers);
|
||||||
|
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ export const ServerError: FC = () => {
|
|||||||
</Message>
|
</Message>
|
||||||
|
|
||||||
<p className="text-xl">
|
<p className="text-xl">
|
||||||
These are the Shlink servers currently configured. Choose one of
|
These are the Shlink servers currently configured. Choose one of them or{' '}
|
||||||
them or <Link to="/server/create">add a new one</Link>.
|
<Link to="/server/create">add a new one</Link>.
|
||||||
</p>
|
</p>
|
||||||
<Card className="w-full max-w-100 overflow-hidden">
|
<Card className="w-full max-w-100 overflow-hidden">
|
||||||
<ServersListGroup borderless servers={Object.values(servers)} />
|
<ServersListGroup borderless servers={Object.values(servers)} />
|
||||||
@@ -35,9 +35,8 @@ export const ServerError: FC = () => {
|
|||||||
|
|
||||||
{isServerWithId(selectedServer) && (
|
{isServerWithId(selectedServer) && (
|
||||||
<p className="text-xl">
|
<p className="text-xl">
|
||||||
Alternatively, if you think you may have misconfigured this server, you
|
Alternatively, if you think you may have misconfigured this server, you can{' '}
|
||||||
can <DeleteServerButton server={selectedServer}>remove
|
<DeleteServerButton server={selectedServer}>remove it</DeleteServerButton> or
|
||||||
it</DeleteServerButton> or
|
|
||||||
<Link to={`/server/${selectedServer.id}/edit?reconnect=true`}>edit it</Link>.
|
<Link to={`/server/${selectedServer.id}/edit?reconnect=true`}>edit it</Link>.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -45,13 +45,13 @@ export const ServerForm: FC<ServerFormProps> = ({ onSubmit, initialValues, child
|
|||||||
Forward credentials to this server on every request.
|
Forward credentials to this server on every request.
|
||||||
</Label>
|
</Label>
|
||||||
<small className="pl-5.5 text-gray-600 dark:text-gray-400 mt-0.5">
|
<small className="pl-5.5 text-gray-600 dark:text-gray-400 mt-0.5">
|
||||||
{'"'}Credentials{'"'} here means cookies, TLS client certificates, or authentication headers containing a username
|
{'"'}Credentials{'"'} here means cookies, TLS client certificates, or authentication headers containing a
|
||||||
and password.
|
username and password.
|
||||||
</small>
|
</small>
|
||||||
<small className="pl-5.5 text-gray-600 dark:text-gray-400">
|
<small className="pl-5.5 text-gray-600 dark:text-gray-400">
|
||||||
<b>Important!</b> If you are not sure what this means, leave it unchecked. Enabling this option will
|
<b>Important!</b> If you are not sure what this means, leave it unchecked. Enabling this option will make
|
||||||
make all requests fail for Shlink older than v4.5.0, as it requires the server to set a more strict
|
all requests fail for Shlink older than v4.5.0, as it requires the server to set a more strict value for{' '}
|
||||||
value for <code className="whitespace-nowrap">Access-Control-Allow-Origin</code> than <code>*</code>.
|
<code className="whitespace-nowrap">Access-Control-Allow-Origin</code> than <code>*</code>.
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
</Details>
|
</Details>
|
||||||
|
|||||||
@@ -49,9 +49,9 @@ export type DedupServersResult = {
|
|||||||
*/
|
*/
|
||||||
export function dedupServers(servers: ServersMap, serversToAdd: ServerData[]): DedupServersResult {
|
export function dedupServers(servers: ServersMap, serversToAdd: ServerData[]): DedupServersResult {
|
||||||
const serversList = Object.values(servers);
|
const serversList = Object.values(servers);
|
||||||
const { duplicatedServers = [], newServers = [] } = groupBy(
|
// oxlint-disable-next-line typescript/no-useless-default-assignment - Type definition is wrong
|
||||||
serversToAdd,
|
const { duplicatedServers = [], newServers = [] } = groupBy(serversToAdd, (server) =>
|
||||||
(server) => serversInclude(serversList, server) ? 'duplicatedServers' : 'newServers',
|
serversInclude(serversList, server) ? 'duplicatedServers' : 'newServers',
|
||||||
);
|
);
|
||||||
|
|
||||||
return { duplicatedServers, newServers };
|
return { duplicatedServers, newServers };
|
||||||
|
|||||||
@@ -8,10 +8,7 @@ import { hasServerData } from '../data';
|
|||||||
import { ensureUniqueIds } from '../helpers';
|
import { ensureUniqueIds } from '../helpers';
|
||||||
import { createServers, useServers } from './servers';
|
import { createServers, useServers } 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 = createAsyncThunk(
|
||||||
'shlink/remoteServers/fetchServers',
|
'shlink/remoteServers/fetchServers',
|
||||||
|
|||||||
@@ -15,14 +15,13 @@ export const MIN_FALLBACK_VERSION = '1.0.0';
|
|||||||
export const MAX_FALLBACK_VERSION = '999.999.999';
|
export const MAX_FALLBACK_VERSION = '999.999.999';
|
||||||
export const LATEST_VERSION_CONSTRAINT = 'latest';
|
export const LATEST_VERSION_CONSTRAINT = 'latest';
|
||||||
|
|
||||||
const versionToSemVer = (version: string) => toSemVer(
|
const versionToSemVer = (version: string) =>
|
||||||
version === LATEST_VERSION_CONSTRAINT ? MAX_FALLBACK_VERSION : version,
|
toSemVer(version === LATEST_VERSION_CONSTRAINT ? MAX_FALLBACK_VERSION : version, MIN_FALLBACK_VERSION);
|
||||||
MIN_FALLBACK_VERSION,
|
|
||||||
);
|
|
||||||
|
|
||||||
const getServerVersion = memoizeWith(
|
const getServerVersion = memoizeWith(
|
||||||
(server: ServerWithId) => `${server.id}_${server.url}_${server.apiKey}`,
|
(server: ServerWithId) => `${server.id}_${server.url}_${server.apiKey}`,
|
||||||
async (_server: ServerWithId, health: () => Promise<ShlinkHealth>) => health().then(({ version }) => ({
|
async (_server: ServerWithId, health: () => Promise<ShlinkHealth>) =>
|
||||||
|
health().then(({ version }) => ({
|
||||||
version: versionToSemVer(version),
|
version: versionToSemVer(version),
|
||||||
printableVersion: versionToPrintable(version),
|
printableVersion: versionToPrintable(version),
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -27,9 +27,7 @@ export const { actions, reducer: serversReducer } = createSlice({
|
|||||||
}),
|
}),
|
||||||
reducer: (state, { payload }: PayloadAction<EditServer>) => {
|
reducer: (state, { payload }: PayloadAction<EditServer>) => {
|
||||||
const { serverId, serverData } = payload;
|
const { serverId, serverData } = payload;
|
||||||
return (
|
return !state[serverId] ? state : { ...state, [serverId]: { ...state[serverId], ...serverData } };
|
||||||
!state[serverId] ? state : { ...state, [serverId]: { ...state[serverId], ...serverData } }
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
deleteServer: (state, { payload }) => {
|
deleteServer: (state, { payload }) => {
|
||||||
|
|||||||
@@ -110,10 +110,7 @@ function checkValidServiceWorker(swUrl: string, config?: Config) {
|
|||||||
.then((response) => {
|
.then((response) => {
|
||||||
// Ensure service worker exists, and that we really are getting a JS file.
|
// Ensure service worker exists, and that we really are getting a JS file.
|
||||||
const contentType = response.headers.get('content-type');
|
const contentType = response.headers.get('content-type');
|
||||||
if (
|
if (response.status === 404 || (contentType != null && contentType.indexOf('javascript') === -1)) {
|
||||||
response.status === 404 ||
|
|
||||||
(contentType != null && contentType.indexOf('javascript') === -1)
|
|
||||||
) {
|
|
||||||
// No service worker found. Probably a different app. Reload the page.
|
// No service worker found. Probably a different app. Reload the page.
|
||||||
navigator.serviceWorker.ready.then((registration) => {
|
navigator.serviceWorker.ready.then((registration) => {
|
||||||
registration.unregister().then(() => {
|
registration.unregister().then(() => {
|
||||||
|
|||||||
@@ -4,9 +4,5 @@ import type { RootState } from '.';
|
|||||||
|
|
||||||
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: RootState; serializedErrorType: any }>,
|
||||||
) => baseCreateAsyncThunk(
|
) => baseCreateAsyncThunk(typePrefix, payloadCreator, { serializeError: (e) => e });
|
||||||
typePrefix,
|
|
||||||
payloadCreator,
|
|
||||||
{ serializeError: (e) => e },
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -14,13 +14,14 @@ const localStorageConfig: RLSOptions = {
|
|||||||
const getStateFromLocalStorage = () => migrateDeprecatedSettings(load(localStorageConfig));
|
const getStateFromLocalStorage = () => migrateDeprecatedSettings(load(localStorageConfig));
|
||||||
|
|
||||||
const isProduction = process.env.NODE_ENV === 'production';
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
export const setUpStore = (preloadedState = getStateFromLocalStorage()) => configureStore({
|
export const setUpStore = (preloadedState = getStateFromLocalStorage()) =>
|
||||||
|
configureStore({
|
||||||
devTools: !isProduction,
|
devTools: !isProduction,
|
||||||
reducer: initReducers(),
|
reducer: initReducers(),
|
||||||
preloadedState,
|
preloadedState,
|
||||||
middleware: (defaultMiddlewaresIncludingReduxThunk) =>
|
middleware: (defaultMiddlewaresIncludingReduxThunk) =>
|
||||||
defaultMiddlewaresIncludingReduxThunk().concat(save(localStorageConfig)),
|
defaultMiddlewaresIncludingReduxThunk().concat(save(localStorageConfig)),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type StoreType = ReturnType<typeof setUpStore>;
|
export type StoreType = ReturnType<typeof setUpStore>;
|
||||||
export type AppDispatch = StoreType['dispatch'];
|
export type AppDispatch = StoreType['dispatch'];
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ 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 = () =>
|
||||||
|
combineReducers({
|
||||||
appUpdated: appUpdatesReducer,
|
appUpdated: appUpdatesReducer,
|
||||||
servers: serversReducer,
|
servers: serversReducer,
|
||||||
selectedServer: selectedServerReducer,
|
selectedServer: selectedServerReducer,
|
||||||
settings: settingsReducer,
|
settings: settingsReducer,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,6 @@
|
|||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--footer-height: 2.3rem;
|
--footer-height: 2.3rem;
|
||||||
--footer-margin: .8rem;
|
--footer-margin: 0.8rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Parser } from '@json2csv/plainjs';
|
import { Parser } from '@json2csv/plainjs';
|
||||||
import csv from 'csvtojson';
|
import csv from 'csvtojson';
|
||||||
|
|
||||||
export const csvToJson = <T>(csvContent: string) => new Promise<T[]>((resolve) => {
|
export const csvToJson = <T>(csvContent: string) =>
|
||||||
|
new Promise<T[]>((resolve) => {
|
||||||
csv().fromString(csvContent).then(resolve);
|
csv().fromString(csvContent).then(resolve);
|
||||||
});
|
});
|
||||||
|
|
||||||
export type CsvToJson = typeof csvToJson;
|
export type CsvToJson = typeof csvToJson;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export const forceUpdate = async () => {
|
export const forceUpdate = async () => {
|
||||||
const registrations = await navigator.serviceWorker?.getRegistrations() ?? [];
|
const registrations = (await navigator.serviceWorker?.getRegistrations()) ?? [];
|
||||||
|
|
||||||
registrations.forEach(({ waiting }) => {
|
registrations.forEach(({ waiting }) => {
|
||||||
waiting?.addEventListener('statechange', (event) => {
|
waiting?.addEventListener('statechange', (event) => {
|
||||||
|
|||||||
@@ -3,19 +3,19 @@ import { compare } from 'compare-versions';
|
|||||||
|
|
||||||
export type Empty = null | undefined | '' | never[];
|
export type Empty = null | undefined | '' | never[];
|
||||||
|
|
||||||
const isEmpty = (value: Exclude<any, undefined | null>): boolean => (
|
const isEmpty = (value: Exclude<any, undefined | null>): boolean =>
|
||||||
(Array.isArray(value) && value.length === 0)
|
(Array.isArray(value) && value.length === 0) ||
|
||||||
|| (typeof value === 'string' && value === '')
|
(typeof value === 'string' && value === '') ||
|
||||||
|| (typeof value === 'object' && Object.keys(value).length === 0)
|
(typeof value === 'object' && Object.keys(value).length === 0);
|
||||||
);
|
|
||||||
|
|
||||||
export const hasValue = <T>(value: T | Empty): value is T => value !== undefined && value !== null && !isEmpty(value);
|
export const hasValue = <T>(value: T | Empty): value is T => value !== undefined && value !== null && !isEmpty(value);
|
||||||
|
|
||||||
type SemVerPatternFragment = `${bigint | '*'}`;
|
type SemVerPatternFragment = `${bigint | '*'}`;
|
||||||
|
|
||||||
type SemVerPattern = SemVerPatternFragment
|
type SemVerPattern =
|
||||||
| `${SemVerPatternFragment}.${SemVerPatternFragment}`
|
| SemVerPatternFragment
|
||||||
| `${SemVerPatternFragment}.${SemVerPatternFragment}.${SemVerPatternFragment}`;
|
| `${SemVerPatternFragment}.${SemVerPatternFragment}`
|
||||||
|
| `${SemVerPatternFragment}.${SemVerPatternFragment}.${SemVerPatternFragment}`;
|
||||||
|
|
||||||
export type Versions = {
|
export type Versions = {
|
||||||
maxVersion?: SemVerPattern;
|
maxVersion?: SemVerPattern;
|
||||||
@@ -35,16 +35,18 @@ export const versionMatch = (versionToMatch: SemVer | Empty, { maxVersion, minVe
|
|||||||
return matchesMaxVersion && matchesMinVersion;
|
return matchesMaxVersion && matchesMinVersion;
|
||||||
};
|
};
|
||||||
|
|
||||||
const versionIsValidSemVer = memoizeWith((v) => v, (version: string): version is SemVer => {
|
const versionIsValidSemVer = memoizeWith(
|
||||||
|
(v) => v,
|
||||||
|
(version: string): version is SemVer => {
|
||||||
try {
|
try {
|
||||||
return compare(version, version, '=');
|
return compare(version, version, '=');
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export const versionToPrintable = (version: string) => (!versionIsValidSemVer(version) ? version : `v${version}`);
|
export const versionToPrintable = (version: string) => (!versionIsValidSemVer(version) ? version : `v${version}`);
|
||||||
|
|
||||||
export const versionToSemVer = (version: string, fallback: SemVer = 'latest'): SemVer => (
|
export const versionToSemVer = (version: string, fallback: SemVer = 'latest'): SemVer =>
|
||||||
versionIsValidSemVer(version) ? version : fallback
|
versionIsValidSemVer(version) ? version : fallback;
|
||||||
);
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export class LocalStorage {
|
|||||||
|
|
||||||
public readonly get = <T>(key: string): T | undefined => {
|
public readonly get = <T>(key: string): T | undefined => {
|
||||||
const item = this.localStorage.getItem(buildPath(key));
|
const item = this.localStorage.getItem(buildPath(key));
|
||||||
return item ? JSON.parse(item) as T : undefined;
|
return item ? (JSON.parse(item) as T) : undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
public readonly set = (key: string, value: any) => this.localStorage.setItem(buildPath(key), JSON.stringify(value));
|
public readonly set = (key: string, value: any) => this.localStorage.setItem(buildPath(key), JSON.stringify(value));
|
||||||
|
|||||||
@@ -2,8 +2,7 @@ import type { TagColorsStorage as BaseTagColorsStorage } from '@shlinkio/shlink-
|
|||||||
import type { LocalStorage } from './LocalStorage';
|
import type { LocalStorage } from './LocalStorage';
|
||||||
|
|
||||||
export class TagColorsStorage implements BaseTagColorsStorage {
|
export class TagColorsStorage implements BaseTagColorsStorage {
|
||||||
constructor(private readonly storage: LocalStorage) {
|
constructor(private readonly storage: LocalStorage) {}
|
||||||
}
|
|
||||||
|
|
||||||
getTagColors(): Record<string, string> {
|
getTagColors(): Record<string, string> {
|
||||||
return this.storage.get<Record<string, string>>('colors') ?? {};
|
return this.storage.get<Record<string, string>>('colors') ?? {};
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ import { useCallback } from 'react';
|
|||||||
* Wraps an event handler so that it calls e.preventDefault() before invoking the event handler
|
* Wraps an event handler so that it calls e.preventDefault() before invoking the event handler
|
||||||
*/
|
*/
|
||||||
export const usePreventDefault = <Event extends SyntheticEvent = SyntheticEvent>(handler: (e: Event) => void) =>
|
export const usePreventDefault = <Event extends SyntheticEvent = SyntheticEvent>(handler: (e: Event) => void) =>
|
||||||
useCallback((e: Event) => {
|
useCallback(
|
||||||
|
(e: Event) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handler(e);
|
handler(e);
|
||||||
}, [handler]);
|
},
|
||||||
|
[handler],
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import type { FC, ReactElement } from 'react';
|
import type { FC, ReactElement } from 'react';
|
||||||
import { useCallback, useEffect , useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
export type RenderModalArgs = {
|
export type RenderModalArgs = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TestModalWrapper: FC<{ renderModal: (args: RenderModalArgs) => ReactElement }> = (
|
export const TestModalWrapper: FC<{ renderModal: (args: RenderModalArgs) => ReactElement }> = ({ renderModal }) => {
|
||||||
{ renderModal },
|
|
||||||
) => {
|
|
||||||
const [open, setOpen] = useState(true);
|
const [open, setOpen] = useState(true);
|
||||||
const onClose = useCallback(() => setOpen(false), []);
|
const onClose = useCallback(() => setOpen(false), []);
|
||||||
|
|
||||||
|
|||||||
@@ -44,12 +44,15 @@ describe('ShlinkApiClientBuilder', () => {
|
|||||||
|
|
||||||
await apiClient.health();
|
await apiClient.health();
|
||||||
|
|
||||||
expect(jsonRequest).toHaveBeenCalledWith(expect.stringMatching(new RegExp(`^${url}`)), expect.objectContaining({
|
expect(jsonRequest).toHaveBeenCalledWith(
|
||||||
|
expect.stringMatching(new RegExp(`^${url}`)),
|
||||||
|
expect.objectContaining({
|
||||||
credentials: undefined,
|
credentials: undefined,
|
||||||
headers: {
|
headers: {
|
||||||
'X-Api-Key': apiKey,
|
'X-Api-Key': apiKey,
|
||||||
},
|
},
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('includes credentials when forwarding is enabled', async () => {
|
it('includes credentials when forwarding is enabled', async () => {
|
||||||
@@ -61,11 +64,14 @@ describe('ShlinkApiClientBuilder', () => {
|
|||||||
|
|
||||||
await apiClient.health();
|
await apiClient.health();
|
||||||
|
|
||||||
expect(jsonRequest).toHaveBeenCalledWith(expect.stringMatching(new RegExp(`^${url}`)), expect.objectContaining({
|
expect(jsonRequest).toHaveBeenCalledWith(
|
||||||
|
expect.stringMatching(new RegExp(`^${url}`)),
|
||||||
|
expect.objectContaining({
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
'X-Api-Key': apiKey,
|
'X-Api-Key': apiKey,
|
||||||
},
|
},
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ vi.mock(import('../../src/common/ShlinkWebComponentContainer'), () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
describe('<App />', () => {
|
describe('<App />', () => {
|
||||||
const setUp = async (activeRoute = '/') => act(() => renderWithStore(
|
const setUp = async (activeRoute = '/') =>
|
||||||
|
act(() =>
|
||||||
|
renderWithStore(
|
||||||
<MemoryRouter initialEntries={[{ pathname: activeRoute }]}>
|
<MemoryRouter initialEntries={[{ pathname: activeRoute }]}>
|
||||||
<ContainerProvider
|
<ContainerProvider
|
||||||
value={fromPartial({
|
value={fromPartial({
|
||||||
@@ -35,7 +37,8 @@ describe('<App />', () => {
|
|||||||
appUpdated: false,
|
appUpdated: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
));
|
),
|
||||||
|
);
|
||||||
|
|
||||||
it('passes a11y checks', () => checkAccessibility(setUp()));
|
it('passes a11y checks', () => checkAccessibility(setUp()));
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ describe('<AppUpdateBanner />', () => {
|
|||||||
const onClose = vi.fn();
|
const onClose = vi.fn();
|
||||||
const forceUpdate = vi.fn();
|
const forceUpdate = vi.fn();
|
||||||
const setUp = async () => {
|
const setUp = async () => {
|
||||||
const result = await act(
|
const result = await act(() =>
|
||||||
() => renderWithEvents(<AppUpdateBanner isOpen onClose={onClose} forceUpdate={forceUpdate} />),
|
renderWithEvents(<AppUpdateBanner isOpen onClose={onClose} forceUpdate={forceUpdate} />),
|
||||||
);
|
);
|
||||||
await waitFor(() => screen.getByRole('alert'));
|
await waitFor(() => screen.getByRole('alert'));
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import { checkAccessibility } from '../__helpers__/accessibility';
|
|||||||
import { renderWithStore } from '../__helpers__/setUpTest';
|
import { renderWithStore } from '../__helpers__/setUpTest';
|
||||||
|
|
||||||
describe('<Home />', () => {
|
describe('<Home />', () => {
|
||||||
const setUp = (servers: ServersMap = {}) => renderWithStore(
|
const setUp = (servers: ServersMap = {}) =>
|
||||||
|
renderWithStore(
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
<Home />
|
<Home />
|
||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
@@ -16,9 +17,8 @@ describe('<Home />', () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
it('passes a11y checks', () => checkAccessibility(
|
it('passes a11y checks', () =>
|
||||||
setUp({ '1a': fromPartial<ServerWithId>({ name: 'foo', id: '1' }) }),
|
checkAccessibility(setUp({ '1a': fromPartial<ServerWithId>({ name: 'foo', id: '1' }) })));
|
||||||
));
|
|
||||||
|
|
||||||
it('renders title', () => {
|
it('renders title', () => {
|
||||||
setUp();
|
setUp();
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ describe('<MainHeader />', () => {
|
|||||||
])('sets link to settings as active only when current path is settings', (currentPath, isActive) => {
|
])('sets link to settings as active only when current path is settings', (currentPath, isActive) => {
|
||||||
setUp(currentPath);
|
setUp(currentPath);
|
||||||
expect(screen.getByRole('menuitem', { name: /Settings$/ })).toHaveAttribute(
|
expect(screen.getByRole('menuitem', { name: /Settings$/ })).toHaveAttribute(
|
||||||
'data-active', isActive ? 'true' : 'false',
|
'data-active',
|
||||||
|
isActive ? 'true' : 'false',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,12 @@ import { NotFound } from '../../src/common/NotFound';
|
|||||||
import { checkAccessibility } from '../__helpers__/accessibility';
|
import { checkAccessibility } from '../__helpers__/accessibility';
|
||||||
|
|
||||||
describe('<NotFound />', () => {
|
describe('<NotFound />', () => {
|
||||||
const setUp = (props = {}) => render(<MemoryRouter><NotFound {...props} /></MemoryRouter>);
|
const setUp = (props = {}) =>
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<NotFound {...props} />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
it('passes a11y checks', () => checkAccessibility(setUp()));
|
it('passes a11y checks', () => checkAccessibility(setUp()));
|
||||||
|
|
||||||
@@ -15,9 +20,11 @@ describe('<NotFound />', () => {
|
|||||||
|
|
||||||
it('shows expected error message', () => {
|
it('shows expected error message', () => {
|
||||||
setUp();
|
setUp();
|
||||||
expect(screen.getByText(
|
expect(
|
||||||
'Use your browser\'s back button to navigate to the page you have previously come from, or just press this button.',
|
screen.getByText(
|
||||||
)).toBeInTheDocument();
|
"Use your browser's back button to navigate to the page you have previously come from, or just press this button.",
|
||||||
|
),
|
||||||
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
|
|||||||
@@ -4,15 +4,16 @@ import { ScrollToTop } from '../../src/common/ScrollToTop';
|
|||||||
import { checkAccessibility } from '../__helpers__/accessibility';
|
import { checkAccessibility } from '../__helpers__/accessibility';
|
||||||
|
|
||||||
describe('<ScrollToTop />', () => {
|
describe('<ScrollToTop />', () => {
|
||||||
const setUp = (children = 'Foo') => render(<MemoryRouter><ScrollToTop>{children}</ScrollToTop></MemoryRouter>);
|
const setUp = (children = 'Foo') =>
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ScrollToTop>{children}</ScrollToTop>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
it('passes a11y checks', () => checkAccessibility(setUp()));
|
it('passes a11y checks', () => checkAccessibility(setUp()));
|
||||||
|
|
||||||
it.each([
|
it.each([['Foobar'], ['Barfoo'], ['Something']])('just renders children', (children) => {
|
||||||
['Foobar'],
|
|
||||||
['Barfoo'],
|
|
||||||
['Something'],
|
|
||||||
])('just renders children', (children) => {
|
|
||||||
setUp(children);
|
setUp(children);
|
||||||
expect(screen.getByText(children)).toBeInTheDocument();
|
expect(screen.getByText(children)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,9 +8,8 @@ import { checkAccessibility } from '../__helpers__/accessibility';
|
|||||||
describe('<ShlinkVersions />', () => {
|
describe('<ShlinkVersions />', () => {
|
||||||
const setUp = (props: ShlinkVersionsProps) => render(<ShlinkVersions {...props} />);
|
const setUp = (props: ShlinkVersionsProps) => render(<ShlinkVersions {...props} />);
|
||||||
|
|
||||||
it('passes a11y checks', () => checkAccessibility(
|
it('passes a11y checks', () =>
|
||||||
setUp({ selectedServer: fromPartial({ version: '1.0.0', printableVersion: '1.0.0' }) }),
|
checkAccessibility(setUp({ selectedServer: fromPartial({ version: '1.0.0', printableVersion: '1.0.0' }) })));
|
||||||
));
|
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
['1.2.3', fromPartial<ReachableServer>({ version: '1.0.0', printableVersion: 'foo' }), 'v1.2.3', 'foo'],
|
['1.2.3', fromPartial<ReachableServer>({ version: '1.0.0', printableVersion: 'foo' }), 'v1.2.3', 'foo'],
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import { checkAccessibility } from '../__helpers__/accessibility';
|
|||||||
import { renderWithStore } from '../__helpers__/setUpTest';
|
import { renderWithStore } from '../__helpers__/setUpTest';
|
||||||
|
|
||||||
describe('<ShlinkVersionsContainer />', () => {
|
describe('<ShlinkVersionsContainer />', () => {
|
||||||
const setUp = (selectedServer: SelectedServer = null) => renderWithStore(<ShlinkVersionsContainer />, {
|
const setUp = (selectedServer: SelectedServer = null) =>
|
||||||
|
renderWithStore(<ShlinkVersionsContainer />, {
|
||||||
initialState: { selectedServer },
|
initialState: { selectedServer },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ vi.mock('@shlinkio/shlink-web-component', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
describe('<ShlinkWebComponentContainer />', () => {
|
describe('<ShlinkWebComponentContainer />', () => {
|
||||||
const setUp = (selectedServer: SelectedServer) => renderWithStore(
|
const setUp = (selectedServer: SelectedServer) =>
|
||||||
|
renderWithStore(
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
<ShlinkWebComponentContainer TagColorsStorage={fromPartial({})} />
|
<ShlinkWebComponentContainer TagColorsStorage={fromPartial({})} />
|
||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ describe('context', () => {
|
|||||||
describe('useDependencies', () => {
|
describe('useDependencies', () => {
|
||||||
let lastDependencies: unknown[];
|
let lastDependencies: unknown[];
|
||||||
|
|
||||||
function TestComponent({ name}: { name: string }) {
|
function TestComponent({ name }: { name: string }) {
|
||||||
// eslint-disable-next-line react-compiler/react-compiler
|
// eslint-disable-next-line react-compiler/react-compiler
|
||||||
lastDependencies = useDependencies(name);
|
lastDependencies = useDependencies(name);
|
||||||
return null;
|
return null;
|
||||||
@@ -19,11 +19,13 @@ describe('context', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('throws when requested dependency is not found in container', () => {
|
it('throws when requested dependency is not found in container', () => {
|
||||||
expect(() => render(
|
expect(() =>
|
||||||
|
render(
|
||||||
<ContainerProvider value={fromPartial({})}>
|
<ContainerProvider value={fromPartial({})}>
|
||||||
<TestComponent name="foo" />
|
<TestComponent name="foo" />
|
||||||
</ContainerProvider>,
|
</ContainerProvider>,
|
||||||
)).toThrowError('Dependency with name "foo" not found in container');
|
),
|
||||||
|
).toThrowError('Dependency with name "foo" not found in container');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('gets dependency from container', () => {
|
it('gets dependency from container', () => {
|
||||||
|
|||||||
@@ -70,12 +70,14 @@ describe('<CreateServer />', () => {
|
|||||||
|
|
||||||
expect(store.getState().servers[expectedServerId]).not.toBeDefined();
|
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({
|
expect(store.getState().servers[expectedServerId]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
id: expectedServerId,
|
id: expectedServerId,
|
||||||
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(`/server/${expectedServerId}`);
|
expect(history.location.pathname).toEqual(`/server/${expectedServerId}`);
|
||||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||||
|
|||||||
@@ -21,11 +21,7 @@ describe('<DeleteServerButton />', () => {
|
|||||||
|
|
||||||
it('passes a11y checks', () => checkAccessibility(setUp('Delete me')));
|
it('passes a11y checks', () => checkAccessibility(setUp('Delete me')));
|
||||||
|
|
||||||
it.each([
|
it.each([['Foo bar'], ['baz'], ['something']])('renders expected content', (children) => {
|
||||||
['Foo bar'],
|
|
||||||
['baz'],
|
|
||||||
['something'],
|
|
||||||
])('renders expected content', (children) => {
|
|
||||||
const { container } = setUp(children);
|
const { container } = setUp(children);
|
||||||
expect(container.firstChild).toBeTruthy();
|
expect(container.firstChild).toBeTruthy();
|
||||||
expect(container.firstChild).toMatchSnapshot();
|
expect(container.firstChild).toMatchSnapshot();
|
||||||
|
|||||||
@@ -9,16 +9,12 @@ import { TestModalWrapper } from '../__helpers__/TestModalWrapper';
|
|||||||
describe('<DeleteServerModal />', () => {
|
describe('<DeleteServerModal />', () => {
|
||||||
const serverName = 'the_server_name';
|
const serverName = 'the_server_name';
|
||||||
const server = fromPartial<ServerWithId>({ id: 'foo', name: serverName });
|
const server = fromPartial<ServerWithId>({ id: 'foo', name: serverName });
|
||||||
const setUp = () => renderWithStore(
|
const setUp = () =>
|
||||||
<TestModalWrapper
|
renderWithStore(<TestModalWrapper renderModal={(args) => <DeleteServerModal {...args} server={server} />} />, {
|
||||||
renderModal={(args) => <DeleteServerModal {...args} server={server} />}
|
|
||||||
/>,
|
|
||||||
{
|
|
||||||
initialState: {
|
initialState: {
|
||||||
servers: { foo: server },
|
servers: { foo: server },
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
it('passes a11y checks', () => checkAccessibility(setUp()));
|
it('passes a11y checks', () => checkAccessibility(setUp()));
|
||||||
|
|
||||||
@@ -36,10 +32,9 @@ describe('<DeleteServerModal />', () => {
|
|||||||
expect(screen.getByText(serverName)).toBeInTheDocument();
|
expect(screen.getByText(serverName)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([[() => screen.getByRole('button', { name: 'Cancel' })], [() => screen.getByLabelText('Close dialog')]])(
|
||||||
[() => screen.getByRole('button', { name: 'Cancel' })],
|
'closes dialog when clicking cancel button',
|
||||||
[() => screen.getByLabelText('Close dialog')],
|
async (getButton) => {
|
||||||
])('closes dialog when clicking cancel button', async (getButton) => {
|
|
||||||
const { user, store } = setUp();
|
const { user, store } = setUp();
|
||||||
|
|
||||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||||
@@ -48,7 +43,8 @@ describe('<DeleteServerModal />', () => {
|
|||||||
|
|
||||||
// No server has been deleted
|
// No server has been deleted
|
||||||
expect(Object.keys(store.getState().servers)).toHaveLength(1);
|
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, store } = setUp();
|
||||||
|
|||||||
@@ -65,27 +65,33 @@ 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(store.getState().servers[defaultSelectedServer.id]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
name: 'the_name edited',
|
name: 'the_name edited',
|
||||||
url: 'the_url edited',
|
url: 'the_url edited',
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
// After saving we go back, to the first route from history's initialEntries
|
// After saving we go back, to the first route from history's initialEntries
|
||||||
expect(history.location.pathname).toEqual('/foo');
|
expect(history.location.pathname).toEqual('/foo');
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([{ forwardCredentials: true }, { forwardCredentials: false }])(
|
||||||
{ forwardCredentials: true },
|
'edits advanced options - forward credentials',
|
||||||
{ forwardCredentials: false },
|
async ({ forwardCredentials }) => {
|
||||||
])('edits advanced options - forward credentials', async ({ forwardCredentials }) => {
|
|
||||||
const { user, store } = setUp({ ...defaultSelectedServer, forwardCredentials });
|
const { user, store } = setUp({ ...defaultSelectedServer, forwardCredentials });
|
||||||
|
|
||||||
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({
|
await waitFor(() =>
|
||||||
|
expect(store.getState().servers[defaultSelectedServer.id]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
forwardCredentials: !forwardCredentials,
|
forwardCredentials: !forwardCredentials,
|
||||||
})));
|
}),
|
||||||
});
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ 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 createServerMock = (value: string, autoConnect = false) => fromPartial<ServerWithId>(
|
const createServerMock = (value: string, autoConnect = false) =>
|
||||||
{ id: value, name: value, url: value, autoConnect },
|
fromPartial<ServerWithId>({ id: value, name: value, url: value, autoConnect });
|
||||||
);
|
const setUp = (servers: ServersMap = {}) =>
|
||||||
const setUp = (servers: ServersMap = {}) => renderWithStore(
|
renderWithStore(
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
<ManageServers useTimeoutToggle={useTimeoutToggle} ServersExporter={serversExporter} />
|
<ManageServers useTimeoutToggle={useTimeoutToggle} ServersExporter={serversExporter} />
|
||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
@@ -23,11 +23,14 @@ describe('<ManageServers />', () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
it('passes a11y checks', () => checkAccessibility(setUp({
|
it('passes a11y checks', () =>
|
||||||
|
checkAccessibility(
|
||||||
|
setUp({
|
||||||
foo: createServerMock('foo'),
|
foo: createServerMock('foo'),
|
||||||
bar: createServerMock('bar'),
|
bar: createServerMock('bar'),
|
||||||
baz: createServerMock('baz'),
|
baz: createServerMock('baz'),
|
||||||
})));
|
}),
|
||||||
|
));
|
||||||
|
|
||||||
it('shows search field which allows searching servers, affecting te amount of rendered rows', async () => {
|
it('shows search field which allows searching servers, affecting te amount of rendered rows', async () => {
|
||||||
const { user } = setUp({
|
const { user } = setUp({
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ describe('<ManageServersRow />', () => {
|
|||||||
apiKey: '123',
|
apiKey: '123',
|
||||||
id: 'abc',
|
id: 'abc',
|
||||||
};
|
};
|
||||||
const setUp = (hasAutoConnect = false, autoConnect = false) => renderWithStore(
|
const setUp = (hasAutoConnect = false, autoConnect = false) =>
|
||||||
|
renderWithStore(
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
<Table header={<Table.Row />}>
|
<Table header={<Table.Row />}>
|
||||||
<ManageServersRow server={{ ...server, autoConnect }} hasAutoConnect={hasAutoConnect} />
|
<ManageServersRow server={{ ...server, autoConnect }} hasAutoConnect={hasAutoConnect} />
|
||||||
@@ -36,10 +37,7 @@ describe('<ManageServersRow />', () => {
|
|||||||
expect(screen.getByRole('button', { name: 'Options' })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: 'Options' })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([[true], [false]])('renders auto-connect icon only if server is autoConnect', (autoConnect) => {
|
||||||
[true],
|
|
||||||
[false],
|
|
||||||
])('renders auto-connect icon only if server is autoConnect', (autoConnect) => {
|
|
||||||
const { container } = setUp(true, autoConnect);
|
const { container } = setUp(true, autoConnect);
|
||||||
expect(container).toMatchSnapshot();
|
expect(container).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ 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) =>
|
||||||
|
renderWithStore(
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
<ul role="menu">
|
<ul role="menu">
|
||||||
<ServersDropdown />
|
<ServersDropdown />
|
||||||
|
|||||||
@@ -6,10 +6,7 @@ import { ServersListGroup } from '../../src/servers/ServersListGroup';
|
|||||||
import { checkAccessibility } from '../__helpers__/accessibility';
|
import { checkAccessibility } from '../__helpers__/accessibility';
|
||||||
|
|
||||||
describe('<ServersListGroup />', () => {
|
describe('<ServersListGroup />', () => {
|
||||||
const servers: ServerWithId[] = [
|
const servers: ServerWithId[] = [fromPartial({ name: 'foo', id: '123' }), fromPartial({ name: 'bar', id: '456' })];
|
||||||
fromPartial({ name: 'foo', id: '123' }),
|
|
||||||
fromPartial({ name: 'bar', id: '456' }),
|
|
||||||
];
|
|
||||||
const setUp = (params: { servers?: ServerWithId[]; borderless?: boolean } = {}) => {
|
const setUp = (params: { servers?: ServerWithId[]; borderless?: boolean } = {}) => {
|
||||||
const { servers = [], borderless } = params;
|
const { servers = [], borderless } = params;
|
||||||
|
|
||||||
@@ -22,21 +19,14 @@ describe('<ServersListGroup />', () => {
|
|||||||
|
|
||||||
it('passes a11y checks', () => checkAccessibility(setUp()));
|
it('passes a11y checks', () => checkAccessibility(setUp()));
|
||||||
|
|
||||||
it.each([
|
it.each([[servers], [[]]])('shows servers list', (servers) => {
|
||||||
[servers],
|
|
||||||
[[]],
|
|
||||||
])('shows servers list', (servers) => {
|
|
||||||
setUp({ servers });
|
setUp({ servers });
|
||||||
|
|
||||||
expect(screen.queryAllByTestId('list')).toHaveLength(servers.length ? 1 : 0);
|
expect(screen.queryAllByTestId('list')).toHaveLength(servers.length ? 1 : 0);
|
||||||
expect(screen.queryAllByRole('link')).toHaveLength(servers.length);
|
expect(screen.queryAllByRole('link')).toHaveLength(servers.length);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([[true], [false], [undefined]])('renders proper classes for embedded', (borderless) => {
|
||||||
[true],
|
|
||||||
[false],
|
|
||||||
[undefined],
|
|
||||||
])('renders proper classes for embedded', (borderless) => {
|
|
||||||
setUp({ servers, borderless });
|
setUp({ servers, borderless });
|
||||||
const list = screen.getByTestId('list');
|
const list = screen.getByTestId('list');
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,12 @@ import { renderWithEvents } from '../../__helpers__/setUpTest';
|
|||||||
describe('<DuplicatedServersModal />', () => {
|
describe('<DuplicatedServersModal />', () => {
|
||||||
const onClose = vi.fn();
|
const onClose = vi.fn();
|
||||||
const onConfirm = vi.fn();
|
const onConfirm = vi.fn();
|
||||||
const setUp = (duplicatedServers: ServerData[] = []) => act(() => renderWithEvents(
|
const setUp = (duplicatedServers: ServerData[] = []) =>
|
||||||
|
act(() =>
|
||||||
|
renderWithEvents(
|
||||||
<DuplicatedServersModal open duplicatedServers={duplicatedServers} onClose={onClose} onConfirm={onConfirm} />,
|
<DuplicatedServersModal open duplicatedServers={duplicatedServers} onClose={onClose} onConfirm={onConfirm} />,
|
||||||
));
|
),
|
||||||
|
);
|
||||||
const mockServer = (data: Partial<ServerData> = {}) => fromPartial<ServerData>(data);
|
const mockServer = (data: Partial<ServerData> = {}) => fromPartial<ServerData>(data);
|
||||||
|
|
||||||
it('passes a11y checks', () => checkAccessibility(setUp()));
|
it('passes a11y checks', () => checkAccessibility(setUp()));
|
||||||
@@ -60,10 +63,7 @@ describe('<DuplicatedServersModal />', () => {
|
|||||||
it.each([
|
it.each([
|
||||||
[[]],
|
[[]],
|
||||||
[[mockServer({ url: 'url', apiKey: 'apiKey' })]],
|
[[mockServer({ url: 'url', apiKey: 'apiKey' })]],
|
||||||
[[
|
[[mockServer({ url: 'url_1', apiKey: 'apiKey_1' }), mockServer({ url: 'url_2', apiKey: 'apiKey_2' })]],
|
||||||
mockServer({ url: 'url_1', apiKey: 'apiKey_1' }),
|
|
||||||
mockServer({ url: 'url_2', apiKey: 'apiKey_2' }),
|
|
||||||
]],
|
|
||||||
])('displays provided server data', async (duplicatedServers) => {
|
])('displays provided server data', async (duplicatedServers) => {
|
||||||
await setUp(duplicatedServers);
|
await setUp(duplicatedServers);
|
||||||
|
|
||||||
|
|||||||
@@ -12,12 +12,10 @@ describe('<ImportServersBtn />', () => {
|
|||||||
const onImportMock = vi.fn();
|
const onImportMock = 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 setUp = (props: Partial<ImportServersBtnProps> = {}, servers: ServersMap = {}) =>
|
||||||
<ImportServersBtn {...props} onImport={onImportMock} ServersImporter={serversImporterMock} />,
|
renderWithStore(<ImportServersBtn {...props} onImport={onImportMock} ServersImporter={serversImporterMock} />, {
|
||||||
{
|
|
||||||
initialState: { servers },
|
initialState: { servers },
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
it('passes a11y checks', () => checkAccessibility(setUp()));
|
it('passes a11y checks', () => checkAccessibility(setUp()));
|
||||||
|
|
||||||
@@ -26,8 +24,8 @@ describe('<ImportServersBtn />', () => {
|
|||||||
|
|
||||||
expect(screen.queryByText(/^You can create servers by importing a CSV file/)).not.toBeInTheDocument();
|
expect(screen.queryByText(/^You can create servers by importing a CSV file/)).not.toBeInTheDocument();
|
||||||
await user.hover(screen.getByRole('button'));
|
await user.hover(screen.getByRole('button'));
|
||||||
await waitFor(
|
await waitFor(() =>
|
||||||
() => expect(screen.getByText(/^You can create servers by importing a CSV file/)).toBeInTheDocument(),
|
expect(screen.getByText(/^You can create servers by importing a CSV file/)).toBeInTheDocument(),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -59,7 +57,9 @@ describe('<ImportServersBtn />', () => {
|
|||||||
it.each([
|
it.each([
|
||||||
{ btnName: 'Save duplicate', savesDuplicatedServers: true },
|
{ btnName: 'Save duplicate', savesDuplicatedServers: true },
|
||||||
{ btnName: 'Discard', savesDuplicatedServers: false },
|
{ btnName: 'Discard', savesDuplicatedServers: false },
|
||||||
])('creates duplicated servers depending on selected option in modal', async ({ btnName, savesDuplicatedServers }) => {
|
])(
|
||||||
|
'creates duplicated servers depending on selected option in modal',
|
||||||
|
async ({ btnName, savesDuplicatedServers }) => {
|
||||||
const existingServerData: ServerData = {
|
const existingServerData: ServerData = {
|
||||||
name: 'existingServer',
|
name: 'existingServer',
|
||||||
url: 'http://s.test/existingUrl',
|
url: 'http://s.test/existingUrl',
|
||||||
@@ -92,5 +92,6 @@ describe('<ImportServersBtn />', () => {
|
|||||||
|
|
||||||
// On import is called only once, no matter what
|
// On import is called only once, no matter what
|
||||||
expect(onImportMock).toHaveBeenCalledOnce();
|
expect(onImportMock).toHaveBeenCalledOnce();
|
||||||
});
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import { checkAccessibility } from '../../__helpers__/accessibility';
|
|||||||
import { renderWithStore } from '../../__helpers__/setUpTest';
|
import { renderWithStore } from '../../__helpers__/setUpTest';
|
||||||
|
|
||||||
describe('<ServerError />', () => {
|
describe('<ServerError />', () => {
|
||||||
const setUp = (selectedServer: SelectedServer) => renderWithStore(
|
const setUp = (selectedServer: SelectedServer) =>
|
||||||
|
renderWithStore(
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
<ServerError />
|
<ServerError />
|
||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
@@ -16,10 +17,10 @@ describe('<ServerError />', () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
it.each([
|
it.each([[fromPartial<NotFoundServer>({})], [fromPartial<NonReachableServer>({ id: 'abc123' })]])(
|
||||||
[fromPartial<NotFoundServer>({})],
|
'passes a11y checks',
|
||||||
[fromPartial<NonReachableServer>({ id: 'abc123' })],
|
(selectedServer) => checkAccessibility(setUp(selectedServer)),
|
||||||
])('passes a11y checks', (selectedServer) => checkAccessibility(setUp(selectedServer)));
|
);
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -66,7 +66,6 @@ describe('remoteServersReducer', () => {
|
|||||||
url: 'http://localhost:8000',
|
url: 'http://localhost:8000',
|
||||||
apiKey: '7a531c75-134e-4d5c-86e0-a71b7167b57a',
|
apiKey: '7a531c75-134e-4d5c-86e0-a71b7167b57a',
|
||||||
},
|
},
|
||||||
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -21,13 +21,16 @@ 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, '', { serverId: '', buildShlinkApiClient }))).toEqual(
|
||||||
|
payload,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('selectServer', () => {
|
describe('selectServer', () => {
|
||||||
const version = '1.19.0';
|
const version = '1.19.0';
|
||||||
const createGetStateMock = (id: string) => vi.fn().mockReturnValue({
|
const createGetStateMock = (id: string) =>
|
||||||
|
vi.fn().mockReturnValue({
|
||||||
servers: {
|
servers: {
|
||||||
[id]: { id },
|
[id]: { id },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -39,14 +39,16 @@ describe('serversReducer', () => {
|
|||||||
ghi789: { id: 'ghi789' },
|
ghi789: { id: 'ghi789' },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
it.each([
|
it.each([[true], [false]])(
|
||||||
[true],
|
'returns state as it is when trying to set auto-connect on invalid server',
|
||||||
[false],
|
(autoConnect) =>
|
||||||
])('returns state as it is when trying to set auto-connect on invalid server', (autoConnect) =>
|
expect(serversReducer(list, setAutoConnect(fromPartial<ServerWithId>({ id: 'invalid' }), autoConnect))).toEqual(
|
||||||
expect(serversReducer(list, setAutoConnect(fromPartial<ServerWithId>({ id: 'invalid' }), autoConnect))).toEqual({
|
{
|
||||||
abc123: { id: 'abc123' },
|
abc123: { id: 'abc123' },
|
||||||
def456: { id: 'def456' },
|
def456: { id: 'def456' },
|
||||||
}));
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
it('disables auto-connect on a server which is already set to auto-connect', () => {
|
it('disables auto-connect on a server which is already set to auto-connect', () => {
|
||||||
const listWithDisabledAutoConnect = {
|
const listWithDisabledAutoConnect = {
|
||||||
@@ -54,10 +56,9 @@ describe('serversReducer', () => {
|
|||||||
abc123: { ...list.abc123, autoConnect: true },
|
abc123: { ...list.abc123, autoConnect: true },
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(serversReducer(
|
expect(
|
||||||
listWithDisabledAutoConnect,
|
serversReducer(listWithDisabledAutoConnect, setAutoConnect(fromPartial<ServerWithId>({ id: 'abc123' }), false)),
|
||||||
setAutoConnect(fromPartial<ServerWithId>({ id: 'abc123' }), false),
|
).toEqual({
|
||||||
)).toEqual({
|
|
||||||
abc123: { id: 'abc123', autoConnect: false },
|
abc123: { id: 'abc123', autoConnect: false },
|
||||||
def456: { id: 'def456' },
|
def456: { id: 'def456' },
|
||||||
});
|
});
|
||||||
@@ -69,10 +70,9 @@ describe('serversReducer', () => {
|
|||||||
abc123: { ...list.abc123, autoConnect: true },
|
abc123: { ...list.abc123, autoConnect: true },
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(serversReducer(
|
expect(
|
||||||
listWithEnabledAutoConnect,
|
serversReducer(listWithEnabledAutoConnect, setAutoConnect(fromPartial<ServerWithId>({ id: 'def456' }), true)),
|
||||||
setAutoConnect(fromPartial<ServerWithId>({ id: 'def456' }), true),
|
).toEqual({
|
||||||
)).toEqual({
|
|
||||||
abc123: { id: 'abc123', autoConnect: false },
|
abc123: { id: 'abc123', autoConnect: false },
|
||||||
def456: { id: 'def456', autoConnect: true },
|
def456: { id: 'def456', autoConnect: true },
|
||||||
});
|
});
|
||||||
@@ -108,10 +108,7 @@ describe('serversReducer', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('setAutoConnect', () => {
|
describe('setAutoConnect', () => {
|
||||||
it.each([
|
it.each([[true], [false]])('returns expected action', (autoConnect) => {
|
||||||
[true],
|
|
||||||
[false],
|
|
||||||
])('returns expected action', (autoConnect) => {
|
|
||||||
const serverToEdit = fromPartial<RegularServer>({ id: 'abc123' });
|
const serverToEdit = fromPartial<RegularServer>({ id: 'abc123' });
|
||||||
const { payload } = setAutoConnect(serverToEdit, autoConnect);
|
const { payload } = setAutoConnect(serverToEdit, autoConnect);
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,9 @@ describe('ServersExporter', () => {
|
|||||||
it('makes use of download link API', () => {
|
it('makes use of download link API', () => {
|
||||||
const jsonToCsvMock = createJsonToCsvMock();
|
const jsonToCsvMock = createJsonToCsvMock();
|
||||||
const exporter = new ServersExporter(storageMock, windowMock, jsonToCsvMock);
|
const exporter = new ServersExporter(storageMock, windowMock, jsonToCsvMock);
|
||||||
const { document: { createElement } } = windowMock;
|
const {
|
||||||
|
document: { createElement },
|
||||||
|
} = windowMock;
|
||||||
|
|
||||||
exporter.exportServers();
|
exporter.exportServers();
|
||||||
|
|
||||||
|
|||||||
@@ -11,9 +11,7 @@ describe('ServersImporter', () => {
|
|||||||
|
|
||||||
describe('importServersFromFile', () => {
|
describe('importServersFromFile', () => {
|
||||||
it.each([[null], [undefined]])('rejects with error if no file was provided', async (file) => {
|
it.each([[null], [undefined]])('rejects with error if no file was provided', async (file) => {
|
||||||
await expect(importer.importServersFromFile(file)).rejects.toEqual(
|
await expect(importer.importServersFromFile(file)).rejects.toEqual(new Error('No file provided'));
|
||||||
new Error('No file provided'),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects with error if parsing the file fails', async () => {
|
it('rejects with error if parsing the file fails', async () => {
|
||||||
@@ -52,13 +50,13 @@ describe('ServersImporter', () => {
|
|||||||
],
|
],
|
||||||
expectedError: 'Server is missing required "url", "apiKey" and/or "name" properties',
|
expectedError: 'Server is missing required "url", "apiKey" and/or "name" properties',
|
||||||
},
|
},
|
||||||
])('rejects with error if provided file does not parse to valid list of servers', async ({
|
])(
|
||||||
parsedObject,
|
'rejects with error if provided file does not parse to valid list of servers',
|
||||||
expectedError,
|
async ({ parsedObject, expectedError }) => {
|
||||||
}) => {
|
|
||||||
csvjsonMock.mockResolvedValue(parsedObject);
|
csvjsonMock.mockResolvedValue(parsedObject);
|
||||||
await expect(importer.importServersFromFile(fileMock())).rejects.toEqual(new Error(expectedError));
|
await expect(importer.importServersFromFile(fileMock())).rejects.toEqual(new Error(expectedError));
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
it('reads file when a CSV containing valid servers is provided', async () => {
|
it('reads file when a CSV containing valid servers is provided', async () => {
|
||||||
const expectedServers: Required<ServerData>[] = [
|
const expectedServers: Required<ServerData>[] = [
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { checkAccessibility } from '../__helpers__/accessibility';
|
|||||||
import { renderWithStore } from '../__helpers__/setUpTest';
|
import { renderWithStore } from '../__helpers__/setUpTest';
|
||||||
|
|
||||||
describe('<Settings />', () => {
|
describe('<Settings />', () => {
|
||||||
const setUp = () => renderWithStore(
|
const setUp = () =>
|
||||||
|
renderWithStore(
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
<Settings />
|
<Settings />
|
||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
|
|||||||
@@ -17,13 +17,15 @@ describe('settings-helpers', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(migrateDeprecatedSettings(state)).toEqual(expect.objectContaining({
|
expect(migrateDeprecatedSettings(state)).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
settings: expect.objectContaining({
|
settings: expect.objectContaining({
|
||||||
visits: {
|
visits: {
|
||||||
defaultInterval: 'last180Days',
|
defaultInterval: 'last180Days',
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ describe('TagColorsStorage', () => {
|
|||||||
describe('getTagColors', () => {
|
describe('getTagColors', () => {
|
||||||
it.each([
|
it.each([
|
||||||
[undefined, {}],
|
[undefined, {}],
|
||||||
[{ foo: 'red', var: 'green' }, { foo: 'red', var: 'green' }],
|
[
|
||||||
|
{ foo: 'red', var: 'green' },
|
||||||
|
{ foo: 'red', var: 'green' },
|
||||||
|
],
|
||||||
])('returns colors from local storage', (colorsFromStorage, expectedValue) => {
|
])('returns colors from local storage', (colorsFromStorage, expectedValue) => {
|
||||||
get.mockReturnValue(colorsFromStorage);
|
get.mockReturnValue(colorsFromStorage);
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ import pack from './package.json' with { type: 'json' };
|
|||||||
|
|
||||||
const homepage = pack.homepage?.trim();
|
const homepage = pack.homepage?.trim();
|
||||||
|
|
||||||
/* eslint-disable-next-line no-restricted-exports */
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), tailwindcss(), VitePWA({
|
plugins: [
|
||||||
|
react(),
|
||||||
|
tailwindcss(),
|
||||||
|
VitePWA({
|
||||||
mode: process.env.NODE_ENV === 'development' ? 'development' : 'production',
|
mode: process.env.NODE_ENV === 'development' ? 'development' : 'production',
|
||||||
strategies: 'injectManifest',
|
strategies: 'injectManifest',
|
||||||
srcDir: './src',
|
srcDir: './src',
|
||||||
@@ -18,7 +20,8 @@ export default defineConfig({
|
|||||||
injectRegister: false,
|
injectRegister: false,
|
||||||
manifestFilename: 'manifest.json',
|
manifestFilename: 'manifest.json',
|
||||||
manifest,
|
manifest,
|
||||||
})],
|
}),
|
||||||
|
],
|
||||||
|
|
||||||
build: {
|
build: {
|
||||||
outDir: 'build',
|
outDir: 'build',
|
||||||
|
|||||||
Reference in New Issue
Block a user