mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2025-01-09 09:47:28 +03:00
Merge pull request #1361 from acelaya-forks/feature/predictable-server-ids
Feature/predictable server ids
This commit is contained in:
commit
238cefde73
18 changed files with 281 additions and 136 deletions
21
CHANGELOG.md
21
CHANGELOG.md
|
@ -4,6 +4,27 @@ 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
|
||||||
|
* [#1360](https://github.com/shlinkio/shlink-web-client/issues/1360) Added ability for server IDs to be generated based on the server name and URL, instead of generating a random UUID.
|
||||||
|
|
||||||
|
This can improve sharing a predefined set of servers cia servers.json, env vars, or simply export and import your servers in some other device, and then be able to share server URLs which continue working.
|
||||||
|
|
||||||
|
All existing servers will keep their generated IDs in existing devices for backwards compatibility, but newly created servers will use the new approach.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
### Deprecated
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
|
||||||
## [4.2.2] - 2024-10-19
|
## [4.2.2] - 2024-10-19
|
||||||
### Added
|
### Added
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
|
@ -7,7 +7,7 @@
|
||||||
[![GitHub license](https://img.shields.io/github/license/shlinkio/shlink-web-client.svg?style=flat-square)](https://github.com/shlinkio/shlink-web-client/blob/main/LICENSE)
|
[![GitHub license](https://img.shields.io/github/license/shlinkio/shlink-web-client.svg?style=flat-square)](https://github.com/shlinkio/shlink-web-client/blob/main/LICENSE)
|
||||||
|
|
||||||
[![Mastodon](https://img.shields.io/mastodon/follow/109329425426175098?color=%236364ff&domain=https%3A%2F%2Ffosstodon.org&label=follow&logo=mastodon&logoColor=white&style=flat-square)](https://fosstodon.org/@shlinkio)
|
[![Mastodon](https://img.shields.io/mastodon/follow/109329425426175098?color=%236364ff&domain=https%3A%2F%2Ffosstodon.org&label=follow&logo=mastodon&logoColor=white&style=flat-square)](https://fosstodon.org/@shlinkio)
|
||||||
[![Bluesky](https://img.shields.io/badge/follow-shlinkio-0285FF.svg?style=flat-square&logo=bluesky&logoColor=white)](https://bsky.app/profile/shlinkio.bsky.social)
|
[![Bluesky](https://img.shields.io/badge/follow-shlinkio-0285FF.svg?style=flat-square&logo=bluesky&logoColor=white)](https://bsky.app/profile/shlink.io)
|
||||||
[![Paypal Donate](https://img.shields.io/badge/Donate-paypal-blue.svg?style=flat-square&logo=paypal&colorA=cccccc)](https://slnk.to/donate)
|
[![Paypal Donate](https://img.shields.io/badge/Donate-paypal-blue.svg?style=flat-square&logo=paypal&colorA=cccccc)](https://slnk.to/donate)
|
||||||
|
|
||||||
A ReactJS-based progressive web application for [Shlink](https://shlink.io).
|
A ReactJS-based progressive web application for [Shlink](https://shlink.io).
|
||||||
|
|
19
package-lock.json
generated
19
package-lock.json
generated
|
@ -32,7 +32,6 @@
|
||||||
"react-router-dom": "^6.27.0",
|
"react-router-dom": "^6.27.0",
|
||||||
"reactstrap": "^9.2.3",
|
"reactstrap": "^9.2.3",
|
||||||
"redux-localstorage-simple": "^2.5.1",
|
"redux-localstorage-simple": "^2.5.1",
|
||||||
"uuid": "^10.0.0",
|
|
||||||
"workbox-core": "^7.1.0",
|
"workbox-core": "^7.1.0",
|
||||||
"workbox-expiration": "^7.1.0",
|
"workbox-expiration": "^7.1.0",
|
||||||
"workbox-precaching": "^7.1.0",
|
"workbox-precaching": "^7.1.0",
|
||||||
|
@ -11580,19 +11579,6 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/uuid": {
|
|
||||||
"version": "10.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
|
|
||||||
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
|
|
||||||
"funding": [
|
|
||||||
"https://github.com/sponsors/broofa",
|
|
||||||
"https://github.com/sponsors/ctavan"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"bin": {
|
|
||||||
"uuid": "dist/bin/uuid"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/validate-npm-package-license": {
|
"node_modules/validate-npm-package-license": {
|
||||||
"version": "3.0.4",
|
"version": "3.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz",
|
||||||
|
@ -20190,11 +20176,6 @@
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"uuid": {
|
|
||||||
"version": "10.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
|
|
||||||
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="
|
|
||||||
},
|
|
||||||
"validate-npm-package-license": {
|
"validate-npm-package-license": {
|
||||||
"version": "3.0.4",
|
"version": "3.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz",
|
||||||
|
|
|
@ -49,7 +49,6 @@
|
||||||
"react-router-dom": "^6.27.0",
|
"react-router-dom": "^6.27.0",
|
||||||
"reactstrap": "^9.2.3",
|
"reactstrap": "^9.2.3",
|
||||||
"redux-localstorage-simple": "^2.5.1",
|
"redux-localstorage-simple": "^2.5.1",
|
||||||
"uuid": "^10.0.0",
|
|
||||||
"workbox-core": "^7.1.0",
|
"workbox-core": "^7.1.0",
|
||||||
"workbox-expiration": "^7.1.0",
|
"workbox-expiration": "^7.1.0",
|
||||||
"workbox-precaching": "^7.1.0",
|
"workbox-precaching": "^7.1.0",
|
||||||
|
|
|
@ -4,6 +4,8 @@ set -e
|
||||||
|
|
||||||
ME=$(basename $0)
|
ME=$(basename $0)
|
||||||
|
|
||||||
|
# In order to allow people to pre-configure a server in their shlink-web-client instance via env vars, this function
|
||||||
|
# dumps a servers.json file based on the values provided via env vars
|
||||||
setup_single_shlink_server() {
|
setup_single_shlink_server() {
|
||||||
[ -n "$SHLINK_SERVER_URL" ] || return 0
|
[ -n "$SHLINK_SERVER_URL" ] || return 0
|
||||||
[ -n "$SHLINK_SERVER_API_KEY" ] || return 0
|
[ -n "$SHLINK_SERVER_API_KEY" ] || return 0
|
||||||
|
|
|
@ -50,8 +50,8 @@ const App: FCWithDeps<AppProps, AppDeps> = (
|
||||||
const isHome = location.pathname === '/';
|
const isHome = location.pathname === '/';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Try to fetch the remote servers if the list is empty at first
|
// Try to fetch the remote servers if the list is empty during first render.
|
||||||
// We use a ref because we don't care if the servers list becomes empty later
|
// We use a ref because we don't care if the servers list becomes empty later.
|
||||||
if (Object.keys(initialServers.current).length === 0) {
|
if (Object.keys(initialServers.current).length === 0) {
|
||||||
fetchServers();
|
fetchServers();
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,8 +8,8 @@ import { NoMenuLayout } from '../common/NoMenuLayout';
|
||||||
import type { FCWithDeps } from '../container/utils';
|
import type { FCWithDeps } from '../container/utils';
|
||||||
import { componentFactory, useDependencies } from '../container/utils';
|
import { componentFactory, useDependencies } from '../container/utils';
|
||||||
import { useGoBack } from '../utils/helpers/hooks';
|
import { useGoBack } from '../utils/helpers/hooks';
|
||||||
import { randomUUID } from '../utils/utils';
|
|
||||||
import type { ServerData, ServersMap, ServerWithId } from './data';
|
import type { ServerData, ServersMap, ServerWithId } from './data';
|
||||||
|
import { ensureUniqueIds } from './helpers';
|
||||||
import { DuplicatedServersModal } from './helpers/DuplicatedServersModal';
|
import { DuplicatedServersModal } from './helpers/DuplicatedServersModal';
|
||||||
import type { ImportServersBtnProps } from './helpers/ImportServersBtn';
|
import type { ImportServersBtnProps } from './helpers/ImportServersBtn';
|
||||||
import { ServerForm } from './helpers/ServerForm';
|
import { ServerForm } from './helpers/ServerForm';
|
||||||
|
@ -44,12 +44,12 @@ const CreateServer: FCWithDeps<CreateServerProps, CreateServerDeps> = ({ servers
|
||||||
const [errorImporting, setErrorImporting] = useTimeoutToggle(false, SHOW_IMPORT_MSG_TIME);
|
const [errorImporting, setErrorImporting] = useTimeoutToggle(false, SHOW_IMPORT_MSG_TIME);
|
||||||
const [isConfirmModalOpen, toggleConfirmModal] = useToggle();
|
const [isConfirmModalOpen, toggleConfirmModal] = useToggle();
|
||||||
const [serverData, setServerData] = useState<ServerData>();
|
const [serverData, setServerData] = useState<ServerData>();
|
||||||
const saveNewServer = useCallback((theServerData: ServerData) => {
|
const saveNewServer = useCallback((newServerData: ServerData) => {
|
||||||
const id = randomUUID();
|
const [newServerWithUniqueId] = ensureUniqueIds(servers, [newServerData]);
|
||||||
|
|
||||||
createServers([{ ...theServerData, id }]);
|
createServers([newServerWithUniqueId]);
|
||||||
navigate(`/server/${id}`);
|
navigate(`/server/${newServerWithUniqueId.id}`);
|
||||||
}, [createServers, navigate]);
|
}, [createServers, navigate, servers]);
|
||||||
const onSubmit = useCallback((newServerData: ServerData) => {
|
const onSubmit = useCallback((newServerData: ServerData) => {
|
||||||
setServerData(newServerData);
|
setServerData(newServerData);
|
||||||
|
|
||||||
|
|
|
@ -6,9 +6,10 @@ import { useCallback, useRef, useState } from 'react';
|
||||||
import { Button, UncontrolledTooltip } from 'reactstrap';
|
import { Button, UncontrolledTooltip } from 'reactstrap';
|
||||||
import type { FCWithDeps } from '../../container/utils';
|
import type { FCWithDeps } from '../../container/utils';
|
||||||
import { componentFactory, useDependencies } from '../../container/utils';
|
import { componentFactory, useDependencies } from '../../container/utils';
|
||||||
import type { ServerData, ServersMap } from '../data';
|
import type { ServerData, ServersMap, ServerWithId } from '../data';
|
||||||
import type { ServersImporter } from '../services/ServersImporter';
|
import type { ServersImporter } from '../services/ServersImporter';
|
||||||
import { DuplicatedServersModal } from './DuplicatedServersModal';
|
import { DuplicatedServersModal } from './DuplicatedServersModal';
|
||||||
|
import { dedupServers, ensureUniqueIds } from './index';
|
||||||
|
|
||||||
export type ImportServersBtnProps = PropsWithChildren<{
|
export type ImportServersBtnProps = PropsWithChildren<{
|
||||||
onImport?: () => void;
|
onImport?: () => void;
|
||||||
|
@ -18,7 +19,7 @@ export type ImportServersBtnProps = PropsWithChildren<{
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
type ImportServersBtnConnectProps = ImportServersBtnProps & {
|
type ImportServersBtnConnectProps = ImportServersBtnProps & {
|
||||||
createServers: (servers: ServerData[]) => void;
|
createServers: (servers: ServerWithId[]) => void;
|
||||||
servers: ServersMap;
|
servers: ServersMap;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -26,9 +27,6 @@ type ImportServersBtnDeps = {
|
||||||
ServersImporter: ServersImporter
|
ServersImporter: ServersImporter
|
||||||
};
|
};
|
||||||
|
|
||||||
const serversInclude = (servers: ServerData[], { url, apiKey }: ServerData) =>
|
|
||||||
servers.some((server) => server.url === url && server.apiKey === apiKey);
|
|
||||||
|
|
||||||
const ImportServersBtn: FCWithDeps<ImportServersBtnConnectProps, ImportServersBtnDeps> = ({
|
const ImportServersBtn: FCWithDeps<ImportServersBtnConnectProps, ImportServersBtnDeps> = ({
|
||||||
createServers,
|
createServers,
|
||||||
servers,
|
servers,
|
||||||
|
@ -43,30 +41,31 @@ const ImportServersBtn: FCWithDeps<ImportServersBtnConnectProps, ImportServersBt
|
||||||
const [duplicatedServers, setDuplicatedServers] = useState<ServerData[]>([]);
|
const [duplicatedServers, setDuplicatedServers] = useState<ServerData[]>([]);
|
||||||
const [isModalOpen,, showModal, hideModal] = useToggle();
|
const [isModalOpen,, showModal, hideModal] = useToggle();
|
||||||
|
|
||||||
const serversToCreate = useRef<ServerData[]>([]);
|
const importedServersRef = useRef<ServerWithId[]>([]);
|
||||||
const create = useCallback((serversData: ServerData[]) => {
|
const newServersRef = useRef<ServerWithId[]>([]);
|
||||||
|
|
||||||
|
const create = useCallback((serversData: ServerWithId[]) => {
|
||||||
createServers(serversData);
|
createServers(serversData);
|
||||||
onImport();
|
onImport();
|
||||||
}, [createServers, onImport]);
|
}, [createServers, onImport]);
|
||||||
const onFile = useCallback(
|
const onFile = useCallback(
|
||||||
async ({ target }: ChangeEvent<HTMLInputElement>) =>
|
async ({ target }: ChangeEvent<HTMLInputElement>) =>
|
||||||
serversImporter.importServersFromFile(target.files?.[0])
|
serversImporter.importServersFromFile(target.files?.[0])
|
||||||
.then((newServers) => {
|
.then((importedServers) => {
|
||||||
serversToCreate.current = newServers;
|
const { duplicatedServers, newServers } = dedupServers(servers, importedServers);
|
||||||
|
|
||||||
const existingServers = Object.values(servers);
|
importedServersRef.current = ensureUniqueIds(servers, importedServers);
|
||||||
const dupServers = newServers.filter((server) => serversInclude(existingServers, server));
|
newServersRef.current = ensureUniqueIds(servers, newServers);
|
||||||
const hasDuplicatedServers = !!dupServers.length;
|
|
||||||
|
|
||||||
if (!hasDuplicatedServers) {
|
if (duplicatedServers.length === 0) {
|
||||||
create(newServers);
|
create(importedServersRef.current);
|
||||||
} else {
|
} else {
|
||||||
setDuplicatedServers(dupServers);
|
setDuplicatedServers(duplicatedServers);
|
||||||
showModal();
|
showModal();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
// Reset input after processing file
|
// Reset file input after processing file
|
||||||
(target as { value: string | null }).value = null;
|
(target as { value: string | null }).value = null;
|
||||||
})
|
})
|
||||||
.catch(onImportError),
|
.catch(onImportError),
|
||||||
|
@ -74,13 +73,13 @@ const ImportServersBtn: FCWithDeps<ImportServersBtnConnectProps, ImportServersBt
|
||||||
);
|
);
|
||||||
|
|
||||||
const createAllServers = useCallback(() => {
|
const createAllServers = useCallback(() => {
|
||||||
create(serversToCreate.current);
|
create(importedServersRef.current);
|
||||||
hideModal();
|
hideModal();
|
||||||
}, [create, hideModal, serversToCreate]);
|
}, [create, hideModal]);
|
||||||
const createNonDuplicatedServers = useCallback(() => {
|
const createNonDuplicatedServers = useCallback(() => {
|
||||||
create(serversToCreate.current.filter((server) => !serversInclude(duplicatedServers, server)));
|
create(newServersRef.current);
|
||||||
hideModal();
|
hideModal();
|
||||||
}, [create, duplicatedServers, hideModal]);
|
}, [create, hideModal]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -91,7 +90,15 @@ const ImportServersBtn: FCWithDeps<ImportServersBtnConnectProps, ImportServersBt
|
||||||
You can create servers by importing a CSV file with <b>name</b>, <b>apiKey</b> and <b>url</b> columns.
|
You can create servers by importing a CSV file with <b>name</b>, <b>apiKey</b> and <b>url</b> columns.
|
||||||
</UncontrolledTooltip>
|
</UncontrolledTooltip>
|
||||||
|
|
||||||
<input type="file" accept=".csv" className="d-none" ref={ref} onChange={onFile} aria-hidden />
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".csv"
|
||||||
|
className="d-none"
|
||||||
|
aria-hidden
|
||||||
|
ref={ref}
|
||||||
|
onChange={onFile}
|
||||||
|
data-testid="csv-file-input"
|
||||||
|
/>
|
||||||
|
|
||||||
<DuplicatedServersModal
|
<DuplicatedServersModal
|
||||||
isOpen={isModalOpen}
|
isOpen={isModalOpen}
|
||||||
|
|
85
src/servers/helpers/index.ts
Normal file
85
src/servers/helpers/index.ts
Normal file
|
@ -0,0 +1,85 @@
|
||||||
|
import { groupBy } from '@shlinkio/data-manipulation';
|
||||||
|
import type { ServerData, ServersMap, ServerWithId } from '../data';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a potentially unique ID for a server, based on concatenating their name and the hostname of their domain, all
|
||||||
|
* in lowercase and replacing invalid URL characters with hyphens.
|
||||||
|
*/
|
||||||
|
function idForServer(server: ServerData): string {
|
||||||
|
let urlSegment = server.url;
|
||||||
|
try {
|
||||||
|
const { host, pathname } = new URL(urlSegment);
|
||||||
|
urlSegment = host;
|
||||||
|
|
||||||
|
// Remove leading slash from pathname
|
||||||
|
const normalizedPathname = pathname.substring(1);
|
||||||
|
|
||||||
|
// Include pathname in the ID, if not empty
|
||||||
|
if (normalizedPathname.length > 0) {
|
||||||
|
urlSegment = `${urlSegment} ${normalizedPathname}`;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// If the server URL is not valid, use the value as is
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${server.name} ${urlSegment}`.toLowerCase().replace(/[^a-zA-Z0-9-_.~]/g, '-');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serversListToMap(servers: ServerWithId[]): ServersMap {
|
||||||
|
const serversMap: ServersMap = {};
|
||||||
|
servers.forEach((server) => {
|
||||||
|
serversMap[server.id] = server;
|
||||||
|
});
|
||||||
|
|
||||||
|
return serversMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serversInclude = (serversList: ServerData[], { url, apiKey }: ServerData) =>
|
||||||
|
serversList.some((server) => server.url === url && server.apiKey === apiKey);
|
||||||
|
|
||||||
|
export type DedupServersResult = {
|
||||||
|
/** Servers which already exist in the reference list */
|
||||||
|
duplicatedServers: ServerData[];
|
||||||
|
/** Servers which are new based on a reference list */
|
||||||
|
newServers: ServerData[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a list of new servers, checks which of them already exist in a servers map, and which don't
|
||||||
|
*/
|
||||||
|
export function dedupServers(servers: ServersMap, serversToAdd: ServerData[]): DedupServersResult {
|
||||||
|
const serversList = Object.values(servers);
|
||||||
|
const { duplicatedServers = [], newServers = [] } = groupBy(
|
||||||
|
serversToAdd,
|
||||||
|
(server) => serversInclude(serversList, server) ? 'duplicatedServers' : 'newServers',
|
||||||
|
);
|
||||||
|
|
||||||
|
return { duplicatedServers, newServers };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a servers map and a list of servers, return the same list of servers but all with an ID, ensuring the ID is
|
||||||
|
* unique both among all those servers and existing ones
|
||||||
|
*/
|
||||||
|
export function ensureUniqueIds(existingServers: ServersMap, serversList: ServerData[]): ServerWithId[] {
|
||||||
|
const existingIds = new Set(Object.keys(existingServers));
|
||||||
|
const serversWithId: ServerWithId[] = [];
|
||||||
|
|
||||||
|
serversList.forEach((server) => {
|
||||||
|
const baseId = idForServer(server);
|
||||||
|
|
||||||
|
let id = baseId;
|
||||||
|
let iterations = 1;
|
||||||
|
while (existingIds.has(id)) {
|
||||||
|
id = `${baseId}-${iterations}`;
|
||||||
|
iterations++;
|
||||||
|
}
|
||||||
|
|
||||||
|
serversWithId.push({ ...server, id });
|
||||||
|
|
||||||
|
// Add this server's ID to the list, so that it is taken into consideration for the next ones
|
||||||
|
existingIds.add(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
return serversWithId;
|
||||||
|
}
|
|
@ -1,11 +1,14 @@
|
||||||
import type { HttpClient } from '@shlinkio/shlink-js-sdk';
|
import type { HttpClient } from '@shlinkio/shlink-js-sdk';
|
||||||
import pack from '../../../package.json';
|
import pack from '../../../package.json';
|
||||||
import { createAsyncThunk } from '../../utils/helpers/redux';
|
import { createAsyncThunk } from '../../utils/helpers/redux';
|
||||||
import type { ServerData } from '../data';
|
|
||||||
import { hasServerData } from '../data';
|
import { hasServerData } from '../data';
|
||||||
|
import { ensureUniqueIds } from '../helpers';
|
||||||
import { createServers } from './servers';
|
import { createServers } from './servers';
|
||||||
|
|
||||||
const responseToServersList = (data: any): ServerData[] => (Array.isArray(data) ? data.filter(hasServerData) : []);
|
const responseToServersList = (data: any) => ensureUniqueIds(
|
||||||
|
{},
|
||||||
|
(Array.isArray(data) ? data.filter(hasServerData) : []),
|
||||||
|
);
|
||||||
|
|
||||||
export const fetchServers = (httpClient: HttpClient) => createAsyncThunk(
|
export const fetchServers = (httpClient: HttpClient) => createAsyncThunk(
|
||||||
'shlink/remoteServers/fetchServers',
|
'shlink/remoteServers/fetchServers',
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import type { PayloadAction } from '@reduxjs/toolkit';
|
import type { PayloadAction } from '@reduxjs/toolkit';
|
||||||
import { createSlice } from '@reduxjs/toolkit';
|
import { createSlice } from '@reduxjs/toolkit';
|
||||||
import { randomUUID } from '../../utils/utils';
|
|
||||||
import type { ServerData, ServersMap, ServerWithId } from '../data';
|
import type { ServerData, ServersMap, ServerWithId } from '../data';
|
||||||
|
import { serversListToMap } from '../helpers';
|
||||||
|
|
||||||
interface EditServer {
|
interface EditServer {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
|
@ -15,19 +15,6 @@ interface SetAutoConnect {
|
||||||
|
|
||||||
const initialState: ServersMap = {};
|
const initialState: ServersMap = {};
|
||||||
|
|
||||||
const serverWithId = (server: ServerWithId | ServerData): ServerWithId => {
|
|
||||||
if ('id' in server) {
|
|
||||||
return server;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ...server, id: randomUUID() };
|
|
||||||
};
|
|
||||||
|
|
||||||
const serversListToMap = (servers: ServerWithId[]): ServersMap => servers.reduce<ServersMap>(
|
|
||||||
(acc, server) => ({ ...acc, [server.id]: server }),
|
|
||||||
{},
|
|
||||||
);
|
|
||||||
|
|
||||||
export const { actions, reducer } = createSlice({
|
export const { actions, reducer } = createSlice({
|
||||||
name: 'shlink/servers',
|
name: 'shlink/servers',
|
||||||
initialState,
|
initialState,
|
||||||
|
@ -70,10 +57,7 @@ export const { actions, reducer } = createSlice({
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
createServers: {
|
createServers: {
|
||||||
prepare: (servers: ServerData[]) => {
|
prepare: (servers: ServerWithId[]) => ({ payload: serversListToMap(servers) }),
|
||||||
const payload = serversListToMap(servers.map(serverWithId));
|
|
||||||
return { payload };
|
|
||||||
},
|
|
||||||
reducer: (state, { payload: newServers }: PayloadAction<ServersMap>) => ({ ...state, ...newServers }),
|
reducer: (state, { payload: newServers }: PayloadAction<ServersMap>) => ({ ...state, ...newServers }),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,5 +0,0 @@
|
||||||
import type { FC, PropsWithChildren } from 'react';
|
|
||||||
|
|
||||||
export const FormText: FC<PropsWithChildren<unknown>> = ({ children }) => (
|
|
||||||
<small className="form-text text-muted d-block">{children}</small>
|
|
||||||
);
|
|
|
@ -1,9 +1,6 @@
|
||||||
import type { SyntheticEvent } from 'react';
|
import type { SyntheticEvent } from 'react';
|
||||||
import { v4 } from 'uuid';
|
|
||||||
|
|
||||||
export const handleEventPreventingDefault = <T>(handler: () => T) => (e: SyntheticEvent) => {
|
export const handleEventPreventingDefault = <T>(handler: () => T) => (e: SyntheticEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handler();
|
handler();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const randomUUID = () => v4();
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { fireEvent, screen, waitFor } from '@testing-library/react';
|
import { screen, waitFor } from '@testing-library/react';
|
||||||
import { fromPartial } from '@total-typescript/shoehorn';
|
import { fromPartial } from '@total-typescript/shoehorn';
|
||||||
import type { ServersMap, ServerWithId } from '../../../src/servers/data';
|
import type { ServerData, ServersMap, ServerWithId } from '../../../src/servers/data';
|
||||||
import type {
|
import type {
|
||||||
ImportServersBtnProps } from '../../../src/servers/helpers/ImportServersBtn';
|
ImportServersBtnProps } from '../../../src/servers/helpers/ImportServersBtn';
|
||||||
import { ImportServersBtnFactory } from '../../../src/servers/helpers/ImportServersBtn';
|
import { ImportServersBtnFactory } from '../../../src/servers/helpers/ImportServersBtn';
|
||||||
|
@ -9,6 +9,7 @@ import { checkAccessibility } from '../../__helpers__/accessibility';
|
||||||
import { renderWithEvents } from '../../__helpers__/setUpTest';
|
import { renderWithEvents } from '../../__helpers__/setUpTest';
|
||||||
|
|
||||||
describe('<ImportServersBtn />', () => {
|
describe('<ImportServersBtn />', () => {
|
||||||
|
const csvFile = new File([''], 'servers.csv', { type: 'text/csv' });
|
||||||
const onImportMock = vi.fn();
|
const onImportMock = vi.fn();
|
||||||
const createServersMock = vi.fn();
|
const createServersMock = vi.fn();
|
||||||
const importServersFromFile = vi.fn().mockResolvedValue([]);
|
const importServersFromFile = vi.fn().mockResolvedValue([]);
|
||||||
|
@ -54,34 +55,43 @@ describe('<ImportServersBtn />', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('imports servers when file input changes', async () => {
|
it('imports servers when file input changes', async () => {
|
||||||
const { container } = setUp();
|
const { user } = setUp();
|
||||||
const input = container.querySelector('[type=file]');
|
|
||||||
|
const input = screen.getByTestId('csv-file-input');
|
||||||
|
await user.upload(input, csvFile);
|
||||||
|
|
||||||
if (input) {
|
|
||||||
fireEvent.change(input, { target: { files: [''] } });
|
|
||||||
}
|
|
||||||
expect(importServersFromFile).toHaveBeenCalledTimes(1);
|
expect(importServersFromFile).toHaveBeenCalledTimes(1);
|
||||||
await waitFor(() => expect(createServersMock).toHaveBeenCalledTimes(1));
|
expect(createServersMock).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
['Save anyway', true],
|
{ btnName: 'Save anyway',savesDuplicatedServers: true },
|
||||||
['Discard', false],
|
{ btnName: 'Discard', savesDuplicatedServers: false },
|
||||||
])('creates expected servers depending on selected option in modal', async (btnName, savesDuplicatedServers) => {
|
])('creates expected servers depending on selected option in modal', async ({ btnName, savesDuplicatedServers }) => {
|
||||||
const existingServer = fromPartial<ServerWithId>({ id: 'abc', url: 'existingUrl', apiKey: 'existingApiKey' });
|
const existingServerData: ServerData = {
|
||||||
const newServer = fromPartial<ServerWithId>({ url: 'newUrl', apiKey: 'newApiKey' });
|
name: 'existingServer',
|
||||||
const { container, user } = setUp({}, { abc: existingServer });
|
url: 'http://s.test/existingUrl',
|
||||||
const input = container.querySelector('[type=file]');
|
apiKey: 'existingApiKey',
|
||||||
|
};
|
||||||
|
const existingServer: ServerWithId = {
|
||||||
|
...existingServerData,
|
||||||
|
id: 'existingserver-s.test',
|
||||||
|
};
|
||||||
|
const newServer: ServerData = { name: 'newServer', url: 'http://s.test/newUrl', apiKey: 'newApiKey' };
|
||||||
|
const { user } = setUp({}, { [existingServer.id]: existingServer });
|
||||||
|
|
||||||
importServersFromFile.mockResolvedValue([existingServer, newServer]);
|
importServersFromFile.mockResolvedValue([existingServer, newServer]);
|
||||||
|
|
||||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||||
if (input) {
|
await user.upload(screen.getByTestId('csv-file-input'), csvFile);
|
||||||
fireEvent.change(input, { target: { files: [''] } });
|
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||||
}
|
|
||||||
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument());
|
|
||||||
await user.click(screen.getByRole('button', { name: btnName }));
|
await user.click(screen.getByRole('button', { name: btnName }));
|
||||||
|
|
||||||
expect(createServersMock).toHaveBeenCalledWith(savesDuplicatedServers ? [existingServer, newServer] : [newServer]);
|
expect(createServersMock).toHaveBeenCalledWith(
|
||||||
|
savesDuplicatedServers
|
||||||
|
? [expect.objectContaining(existingServerData), expect.objectContaining(newServer)]
|
||||||
|
: [expect.objectContaining(newServer)],
|
||||||
|
);
|
||||||
expect(onImportMock).toHaveBeenCalledTimes(1);
|
expect(onImportMock).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
69
test/servers/helpers/index.test.ts
Normal file
69
test/servers/helpers/index.test.ts
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
import { fromPartial } from '@total-typescript/shoehorn';
|
||||||
|
import type { ServersMap } from '../../../src/servers/data';
|
||||||
|
import { ensureUniqueIds } from '../../../src/servers/helpers';
|
||||||
|
|
||||||
|
describe('index', () => {
|
||||||
|
describe('ensureUniqueIds', () => {
|
||||||
|
const servers: ServersMap = {
|
||||||
|
'the-name-example.com': fromPartial({}),
|
||||||
|
'another-name-example.com': fromPartial({}),
|
||||||
|
'short-domain-s.test': fromPartial({}),
|
||||||
|
};
|
||||||
|
|
||||||
|
it('returns expected list of servers when existing IDs conflict', () => {
|
||||||
|
const result = ensureUniqueIds(servers, [
|
||||||
|
fromPartial({ name: 'The name', url: 'https://example.com' }),
|
||||||
|
fromPartial({ name: 'Short domain', url: 'https://s.test' }),
|
||||||
|
fromPartial({ name: 'The name', url: 'https://example.com' }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
expect.objectContaining({ id: 'the-name-example.com-1' }),
|
||||||
|
expect.objectContaining({ id: 'short-domain-s.test-1' }),
|
||||||
|
expect.objectContaining({ id: 'the-name-example.com-2' }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns expected list of servers when IDs conflict in provided list of servers', () => {
|
||||||
|
const result = ensureUniqueIds(servers, [
|
||||||
|
fromPartial({ name: 'Foo', url: 'https://example.com' }),
|
||||||
|
fromPartial({ name: 'Bar', url: 'https://s.test' }),
|
||||||
|
fromPartial({ name: 'Foo', url: 'https://example.com' }),
|
||||||
|
fromPartial({ name: 'Baz', url: 'https://s.test' }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
expect.objectContaining({ id: 'foo-example.com' }),
|
||||||
|
expect.objectContaining({ id: 'bar-s.test' }),
|
||||||
|
expect.objectContaining({ id: 'foo-example.com-1' }),
|
||||||
|
expect.objectContaining({ id: 'baz-s.test' }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes server paths when not empty', () => {
|
||||||
|
const result = ensureUniqueIds({}, [
|
||||||
|
fromPartial({ name: 'Foo', url: 'https://example.com' }),
|
||||||
|
fromPartial({ name: 'Bar', url: 'https://s.test/some/path' }),
|
||||||
|
fromPartial({ name: 'Baz', url: 'https://s.test/some/other-path-here/123' }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
expect.objectContaining({ id: 'foo-example.com' }),
|
||||||
|
expect.objectContaining({ id: 'bar-s.test-some-path' }),
|
||||||
|
expect.objectContaining({ id: 'baz-s.test-some-other-path-here-123' }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses server URL verbatim when it is not a valid URL', () => {
|
||||||
|
const result = ensureUniqueIds({}, [
|
||||||
|
fromPartial({ name: 'Foo', url: 'invalid' }),
|
||||||
|
fromPartial({ name: 'Bar', url: 'this is not a URL' }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
expect.objectContaining({ id: 'foo-invalid' }),
|
||||||
|
expect.objectContaining({ id: 'bar-this-is-not-a-url' }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -9,74 +9,76 @@ describe('remoteServersReducer', () => {
|
||||||
const httpClient = fromPartial<HttpClient>({ jsonRequest });
|
const httpClient = fromPartial<HttpClient>({ jsonRequest });
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
[
|
{
|
||||||
[
|
serversArray: [
|
||||||
{
|
{
|
||||||
id: '111',
|
|
||||||
name: 'acel.me from servers.json',
|
name: 'acel.me from servers.json',
|
||||||
url: 'https://acel.me',
|
url: 'https://acel.me',
|
||||||
apiKey: '07fb8a96-8059-4094-a24c-80a7d5e7e9b0',
|
apiKey: '07fb8a96-8059-4094-a24c-80a7d5e7e9b0',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '222',
|
|
||||||
name: 'Local from servers.json',
|
name: 'Local from servers.json',
|
||||||
url: 'http://localhost:8000',
|
url: 'http://localhost:8000',
|
||||||
apiKey: '7a531c75-134e-4d5c-86e0-a71b7167b57a',
|
apiKey: '7a531c75-134e-4d5c-86e0-a71b7167b57a',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
{
|
expectedNewServers: {
|
||||||
111: {
|
'acel.me-from-servers.json-acel.me': {
|
||||||
id: '111',
|
id: 'acel.me-from-servers.json-acel.me',
|
||||||
name: 'acel.me from servers.json',
|
name: 'acel.me from servers.json',
|
||||||
url: 'https://acel.me',
|
url: 'https://acel.me',
|
||||||
apiKey: '07fb8a96-8059-4094-a24c-80a7d5e7e9b0',
|
apiKey: '07fb8a96-8059-4094-a24c-80a7d5e7e9b0',
|
||||||
},
|
},
|
||||||
222: {
|
'local-from-servers.json-localhost-8000': {
|
||||||
id: '222',
|
id: 'local-from-servers.json-localhost-8000',
|
||||||
name: 'Local from servers.json',
|
name: 'Local from servers.json',
|
||||||
url: 'http://localhost:8000',
|
url: 'http://localhost:8000',
|
||||||
apiKey: '7a531c75-134e-4d5c-86e0-a71b7167b57a',
|
apiKey: '7a531c75-134e-4d5c-86e0-a71b7167b57a',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
},
|
||||||
[
|
{
|
||||||
[
|
serversArray: [
|
||||||
{
|
{
|
||||||
id: '111',
|
|
||||||
name: 'acel.me from servers.json',
|
name: 'acel.me from servers.json',
|
||||||
url: 'https://acel.me',
|
url: 'https://acel.me',
|
||||||
apiKey: '07fb8a96-8059-4094-a24c-80a7d5e7e9b0',
|
apiKey: '07fb8a96-8059-4094-a24c-80a7d5e7e9b0',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '222',
|
|
||||||
name: 'Invalid',
|
name: 'Invalid',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '333',
|
|
||||||
name: 'Local from servers.json',
|
name: 'Local from servers.json',
|
||||||
url: 'http://localhost:8000',
|
url: 'http://localhost:8000',
|
||||||
apiKey: '7a531c75-134e-4d5c-86e0-a71b7167b57a',
|
apiKey: '7a531c75-134e-4d5c-86e0-a71b7167b57a',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
{
|
expectedNewServers: {
|
||||||
111: {
|
'acel.me-from-servers.json-acel.me': {
|
||||||
id: '111',
|
id: 'acel.me-from-servers.json-acel.me',
|
||||||
name: 'acel.me from servers.json',
|
name: 'acel.me from servers.json',
|
||||||
url: 'https://acel.me',
|
url: 'https://acel.me',
|
||||||
apiKey: '07fb8a96-8059-4094-a24c-80a7d5e7e9b0',
|
apiKey: '07fb8a96-8059-4094-a24c-80a7d5e7e9b0',
|
||||||
},
|
},
|
||||||
333: {
|
'local-from-servers.json-localhost-8000': {
|
||||||
id: '333',
|
id: 'local-from-servers.json-localhost-8000',
|
||||||
name: 'Local from servers.json',
|
name: 'Local from servers.json',
|
||||||
url: 'http://localhost:8000',
|
url: 'http://localhost:8000',
|
||||||
apiKey: '7a531c75-134e-4d5c-86e0-a71b7167b57a',
|
apiKey: '7a531c75-134e-4d5c-86e0-a71b7167b57a',
|
||||||
},
|
},
|
||||||
|
|
||||||
},
|
},
|
||||||
],
|
},
|
||||||
['<html></html>', {}],
|
{
|
||||||
[{}, {}],
|
serversArray: '<html></html>',
|
||||||
])('tries to fetch servers from remote', async (mockedValue, expectedNewServers) => {
|
expectedNewServers: {},
|
||||||
jsonRequest.mockResolvedValue(mockedValue);
|
},
|
||||||
|
{
|
||||||
|
serversArray: {},
|
||||||
|
expectedNewServers: {},
|
||||||
|
},
|
||||||
|
])('tries to fetch servers from remote', async ({ serversArray, expectedNewServers }) => {
|
||||||
|
jsonRequest.mockResolvedValue(serversArray);
|
||||||
const doFetchServers = fetchServers(httpClient);
|
const doFetchServers = fetchServers(httpClient);
|
||||||
|
|
||||||
await doFetchServers()(dispatch, vi.fn(), {});
|
await doFetchServers()(dispatch, vi.fn(), {});
|
||||||
|
|
|
@ -9,7 +9,6 @@ import {
|
||||||
selectedServerReducerCreator,
|
selectedServerReducerCreator,
|
||||||
selectServer as selectServerCreator,
|
selectServer as selectServerCreator,
|
||||||
} from '../../../src/servers/reducers/selectedServer';
|
} from '../../../src/servers/reducers/selectedServer';
|
||||||
import { randomUUID } from '../../../src/utils/utils';
|
|
||||||
|
|
||||||
describe('selectedServerReducer', () => {
|
describe('selectedServerReducer', () => {
|
||||||
const dispatch = vi.fn();
|
const dispatch = vi.fn();
|
||||||
|
@ -41,7 +40,7 @@ describe('selectedServerReducer', () => {
|
||||||
['latest', MAX_FALLBACK_VERSION, 'latest'],
|
['latest', MAX_FALLBACK_VERSION, 'latest'],
|
||||||
['%invalid_semver%', MIN_FALLBACK_VERSION, '%invalid_semver%'],
|
['%invalid_semver%', MIN_FALLBACK_VERSION, '%invalid_semver%'],
|
||||||
])('dispatches proper actions', async (serverVersion, expectedVersion, expectedPrintableVersion) => {
|
])('dispatches proper actions', async (serverVersion, expectedVersion, expectedPrintableVersion) => {
|
||||||
const id = randomUUID();
|
const id = crypto.randomUUID();
|
||||||
const getState = createGetStateMock(id);
|
const getState = createGetStateMock(id);
|
||||||
const expectedSelectedServer = {
|
const expectedSelectedServer = {
|
||||||
id,
|
id,
|
||||||
|
@ -60,7 +59,7 @@ describe('selectedServerReducer', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('dispatches error when health endpoint fails', async () => {
|
it('dispatches error when health endpoint fails', async () => {
|
||||||
const id = randomUUID();
|
const id = crypto.randomUUID();
|
||||||
const getState = createGetStateMock(id);
|
const getState = createGetStateMock(id);
|
||||||
const expectedSelectedServer = fromPartial<NonReachableServer>({ id, serverNotReachable: true });
|
const expectedSelectedServer = fromPartial<NonReachableServer>({ id, serverNotReachable: true });
|
||||||
|
|
||||||
|
@ -73,7 +72,7 @@ describe('selectedServerReducer', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('dispatches error when server is not found', async () => {
|
it('dispatches error when server is not found', async () => {
|
||||||
const id = randomUUID();
|
const id = crypto.randomUUID();
|
||||||
const getState = vi.fn(() => fromPartial<ShlinkState>({ servers: {} }));
|
const getState = vi.fn(() => fromPartial<ShlinkState>({ servers: {} }));
|
||||||
const expectedSelectedServer: NotFoundServer = { serverNotFound: true };
|
const expectedSelectedServer: NotFoundServer = { serverNotFound: true };
|
||||||
|
|
||||||
|
|
|
@ -105,15 +105,6 @@ describe('serversReducer', () => {
|
||||||
|
|
||||||
expect(payload).toEqual(list);
|
expect(payload).toEqual(list);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('generates an id for every provided server if they do not have it', () => {
|
|
||||||
const servers = Object.values(list).map(({ name, autoConnect, url, apiKey }) => (
|
|
||||||
{ name, autoConnect, url, apiKey }
|
|
||||||
));
|
|
||||||
const { payload } = createServers(servers);
|
|
||||||
|
|
||||||
expect(Object.values(payload).every(({ id }) => !!id)).toEqual(true);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('setAutoConnect', () => {
|
describe('setAutoConnect', () => {
|
||||||
|
|
Loading…
Reference in a new issue