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