mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2025-01-08 17:27:32 +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).
|
||||
|
||||
## [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
|
||||
### Added
|
||||
* *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)
|
||||
|
||||
[![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)
|
||||
|
||||
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",
|
||||
"reactstrap": "^9.2.3",
|
||||
"redux-localstorage-simple": "^2.5.1",
|
||||
"uuid": "^10.0.0",
|
||||
"workbox-core": "^7.1.0",
|
||||
"workbox-expiration": "^7.1.0",
|
||||
"workbox-precaching": "^7.1.0",
|
||||
|
@ -11580,19 +11579,6 @@
|
|||
"dev": true,
|
||||
"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": {
|
||||
"version": "3.0.4",
|
||||
"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",
|
||||
"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": {
|
||||
"version": "3.0.4",
|
||||
"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",
|
||||
"reactstrap": "^9.2.3",
|
||||
"redux-localstorage-simple": "^2.5.1",
|
||||
"uuid": "^10.0.0",
|
||||
"workbox-core": "^7.1.0",
|
||||
"workbox-expiration": "^7.1.0",
|
||||
"workbox-precaching": "^7.1.0",
|
||||
|
|
|
@ -4,6 +4,8 @@ set -e
|
|||
|
||||
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() {
|
||||
[ -n "$SHLINK_SERVER_URL" ] || return 0
|
||||
[ -n "$SHLINK_SERVER_API_KEY" ] || return 0
|
||||
|
|
|
@ -50,8 +50,8 @@ const App: FCWithDeps<AppProps, AppDeps> = (
|
|||
const isHome = location.pathname === '/';
|
||||
|
||||
useEffect(() => {
|
||||
// Try to fetch the remote servers if the list is empty at first
|
||||
// We use a ref because we don't care if the servers list becomes empty later
|
||||
// Try to fetch the remote servers if the list is empty during first render.
|
||||
// We use a ref because we don't care if the servers list becomes empty later.
|
||||
if (Object.keys(initialServers.current).length === 0) {
|
||||
fetchServers();
|
||||
}
|
||||
|
|
|
@ -8,8 +8,8 @@ import { NoMenuLayout } from '../common/NoMenuLayout';
|
|||
import type { FCWithDeps } from '../container/utils';
|
||||
import { componentFactory, useDependencies } from '../container/utils';
|
||||
import { useGoBack } from '../utils/helpers/hooks';
|
||||
import { randomUUID } from '../utils/utils';
|
||||
import type { ServerData, ServersMap, ServerWithId } from './data';
|
||||
import { ensureUniqueIds } from './helpers';
|
||||
import { DuplicatedServersModal } from './helpers/DuplicatedServersModal';
|
||||
import type { ImportServersBtnProps } from './helpers/ImportServersBtn';
|
||||
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 [isConfirmModalOpen, toggleConfirmModal] = useToggle();
|
||||
const [serverData, setServerData] = useState<ServerData>();
|
||||
const saveNewServer = useCallback((theServerData: ServerData) => {
|
||||
const id = randomUUID();
|
||||
const saveNewServer = useCallback((newServerData: ServerData) => {
|
||||
const [newServerWithUniqueId] = ensureUniqueIds(servers, [newServerData]);
|
||||
|
||||
createServers([{ ...theServerData, id }]);
|
||||
navigate(`/server/${id}`);
|
||||
}, [createServers, navigate]);
|
||||
createServers([newServerWithUniqueId]);
|
||||
navigate(`/server/${newServerWithUniqueId.id}`);
|
||||
}, [createServers, navigate, servers]);
|
||||
const onSubmit = useCallback((newServerData: ServerData) => {
|
||||
setServerData(newServerData);
|
||||
|
||||
|
|
|
@ -6,9 +6,10 @@ import { useCallback, useRef, useState } from 'react';
|
|||
import { Button, UncontrolledTooltip } from 'reactstrap';
|
||||
import type { FCWithDeps } 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 { DuplicatedServersModal } from './DuplicatedServersModal';
|
||||
import { dedupServers, ensureUniqueIds } from './index';
|
||||
|
||||
export type ImportServersBtnProps = PropsWithChildren<{
|
||||
onImport?: () => void;
|
||||
|
@ -18,7 +19,7 @@ export type ImportServersBtnProps = PropsWithChildren<{
|
|||
}>;
|
||||
|
||||
type ImportServersBtnConnectProps = ImportServersBtnProps & {
|
||||
createServers: (servers: ServerData[]) => void;
|
||||
createServers: (servers: ServerWithId[]) => void;
|
||||
servers: ServersMap;
|
||||
};
|
||||
|
||||
|
@ -26,9 +27,6 @@ type ImportServersBtnDeps = {
|
|||
ServersImporter: ServersImporter
|
||||
};
|
||||
|
||||
const serversInclude = (servers: ServerData[], { url, apiKey }: ServerData) =>
|
||||
servers.some((server) => server.url === url && server.apiKey === apiKey);
|
||||
|
||||
const ImportServersBtn: FCWithDeps<ImportServersBtnConnectProps, ImportServersBtnDeps> = ({
|
||||
createServers,
|
||||
servers,
|
||||
|
@ -43,30 +41,31 @@ const ImportServersBtn: FCWithDeps<ImportServersBtnConnectProps, ImportServersBt
|
|||
const [duplicatedServers, setDuplicatedServers] = useState<ServerData[]>([]);
|
||||
const [isModalOpen,, showModal, hideModal] = useToggle();
|
||||
|
||||
const serversToCreate = useRef<ServerData[]>([]);
|
||||
const create = useCallback((serversData: ServerData[]) => {
|
||||
const importedServersRef = useRef<ServerWithId[]>([]);
|
||||
const newServersRef = useRef<ServerWithId[]>([]);
|
||||
|
||||
const create = useCallback((serversData: ServerWithId[]) => {
|
||||
createServers(serversData);
|
||||
onImport();
|
||||
}, [createServers, onImport]);
|
||||
const onFile = useCallback(
|
||||
async ({ target }: ChangeEvent<HTMLInputElement>) =>
|
||||
serversImporter.importServersFromFile(target.files?.[0])
|
||||
.then((newServers) => {
|
||||
serversToCreate.current = newServers;
|
||||
.then((importedServers) => {
|
||||
const { duplicatedServers, newServers } = dedupServers(servers, importedServers);
|
||||
|
||||
const existingServers = Object.values(servers);
|
||||
const dupServers = newServers.filter((server) => serversInclude(existingServers, server));
|
||||
const hasDuplicatedServers = !!dupServers.length;
|
||||
importedServersRef.current = ensureUniqueIds(servers, importedServers);
|
||||
newServersRef.current = ensureUniqueIds(servers, newServers);
|
||||
|
||||
if (!hasDuplicatedServers) {
|
||||
create(newServers);
|
||||
if (duplicatedServers.length === 0) {
|
||||
create(importedServersRef.current);
|
||||
} else {
|
||||
setDuplicatedServers(dupServers);
|
||||
setDuplicatedServers(duplicatedServers);
|
||||
showModal();
|
||||
}
|
||||
})
|
||||
.then(() => {
|
||||
// Reset input after processing file
|
||||
// Reset file input after processing file
|
||||
(target as { value: string | null }).value = null;
|
||||
})
|
||||
.catch(onImportError),
|
||||
|
@ -74,13 +73,13 @@ const ImportServersBtn: FCWithDeps<ImportServersBtnConnectProps, ImportServersBt
|
|||
);
|
||||
|
||||
const createAllServers = useCallback(() => {
|
||||
create(serversToCreate.current);
|
||||
create(importedServersRef.current);
|
||||
hideModal();
|
||||
}, [create, hideModal, serversToCreate]);
|
||||
}, [create, hideModal]);
|
||||
const createNonDuplicatedServers = useCallback(() => {
|
||||
create(serversToCreate.current.filter((server) => !serversInclude(duplicatedServers, server)));
|
||||
create(newServersRef.current);
|
||||
hideModal();
|
||||
}, [create, duplicatedServers, hideModal]);
|
||||
}, [create, hideModal]);
|
||||
|
||||
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.
|
||||
</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
|
||||
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 pack from '../../../package.json';
|
||||
import { createAsyncThunk } from '../../utils/helpers/redux';
|
||||
import type { ServerData } from '../data';
|
||||
import { hasServerData } from '../data';
|
||||
import { ensureUniqueIds } from '../helpers';
|
||||
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(
|
||||
'shlink/remoteServers/fetchServers',
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import type { PayloadAction } from '@reduxjs/toolkit';
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
import { randomUUID } from '../../utils/utils';
|
||||
import type { ServerData, ServersMap, ServerWithId } from '../data';
|
||||
import { serversListToMap } from '../helpers';
|
||||
|
||||
interface EditServer {
|
||||
serverId: string;
|
||||
|
@ -15,19 +15,6 @@ interface SetAutoConnect {
|
|||
|
||||
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({
|
||||
name: 'shlink/servers',
|
||||
initialState,
|
||||
|
@ -70,10 +57,7 @@ export const { actions, reducer } = createSlice({
|
|||
},
|
||||
},
|
||||
createServers: {
|
||||
prepare: (servers: ServerData[]) => {
|
||||
const payload = serversListToMap(servers.map(serverWithId));
|
||||
return { payload };
|
||||
},
|
||||
prepare: (servers: ServerWithId[]) => ({ payload: serversListToMap(servers) }),
|
||||
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 { v4 } from 'uuid';
|
||||
|
||||
export const handleEventPreventingDefault = <T>(handler: () => T) => (e: SyntheticEvent) => {
|
||||
e.preventDefault();
|
||||
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 type { ServersMap, ServerWithId } from '../../../src/servers/data';
|
||||
import type { ServerData, ServersMap, ServerWithId } from '../../../src/servers/data';
|
||||
import type {
|
||||
ImportServersBtnProps } 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';
|
||||
|
||||
describe('<ImportServersBtn />', () => {
|
||||
const csvFile = new File([''], 'servers.csv', { type: 'text/csv' });
|
||||
const onImportMock = vi.fn();
|
||||
const createServersMock = vi.fn();
|
||||
const importServersFromFile = vi.fn().mockResolvedValue([]);
|
||||
|
@ -54,34 +55,43 @@ describe('<ImportServersBtn />', () => {
|
|||
});
|
||||
|
||||
it('imports servers when file input changes', async () => {
|
||||
const { container } = setUp();
|
||||
const input = container.querySelector('[type=file]');
|
||||
const { user } = setUp();
|
||||
|
||||
const input = screen.getByTestId('csv-file-input');
|
||||
await user.upload(input, csvFile);
|
||||
|
||||
if (input) {
|
||||
fireEvent.change(input, { target: { files: [''] } });
|
||||
}
|
||||
expect(importServersFromFile).toHaveBeenCalledTimes(1);
|
||||
await waitFor(() => expect(createServersMock).toHaveBeenCalledTimes(1));
|
||||
expect(createServersMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['Save anyway', true],
|
||||
['Discard', false],
|
||||
])('creates expected servers depending on selected option in modal', async (btnName, savesDuplicatedServers) => {
|
||||
const existingServer = fromPartial<ServerWithId>({ id: 'abc', url: 'existingUrl', apiKey: 'existingApiKey' });
|
||||
const newServer = fromPartial<ServerWithId>({ url: 'newUrl', apiKey: 'newApiKey' });
|
||||
const { container, user } = setUp({}, { abc: existingServer });
|
||||
const input = container.querySelector('[type=file]');
|
||||
{ btnName: 'Save anyway',savesDuplicatedServers: true },
|
||||
{ btnName: 'Discard', savesDuplicatedServers: false },
|
||||
])('creates expected servers depending on selected option in modal', async ({ btnName, savesDuplicatedServers }) => {
|
||||
const existingServerData: ServerData = {
|
||||
name: 'existingServer',
|
||||
url: 'http://s.test/existingUrl',
|
||||
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]);
|
||||
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
if (input) {
|
||||
fireEvent.change(input, { target: { files: [''] } });
|
||||
}
|
||||
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument());
|
||||
await user.upload(screen.getByTestId('csv-file-input'), csvFile);
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
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 });
|
||||
|
||||
it.each([
|
||||
[
|
||||
[
|
||||
{
|
||||
serversArray: [
|
||||
{
|
||||
id: '111',
|
||||
name: 'acel.me from servers.json',
|
||||
url: 'https://acel.me',
|
||||
apiKey: '07fb8a96-8059-4094-a24c-80a7d5e7e9b0',
|
||||
},
|
||||
{
|
||||
id: '222',
|
||||
name: 'Local from servers.json',
|
||||
url: 'http://localhost:8000',
|
||||
apiKey: '7a531c75-134e-4d5c-86e0-a71b7167b57a',
|
||||
},
|
||||
],
|
||||
{
|
||||
111: {
|
||||
id: '111',
|
||||
expectedNewServers: {
|
||||
'acel.me-from-servers.json-acel.me': {
|
||||
id: 'acel.me-from-servers.json-acel.me',
|
||||
name: 'acel.me from servers.json',
|
||||
url: 'https://acel.me',
|
||||
apiKey: '07fb8a96-8059-4094-a24c-80a7d5e7e9b0',
|
||||
},
|
||||
222: {
|
||||
id: '222',
|
||||
'local-from-servers.json-localhost-8000': {
|
||||
id: 'local-from-servers.json-localhost-8000',
|
||||
name: 'Local from servers.json',
|
||||
url: 'http://localhost:8000',
|
||||
apiKey: '7a531c75-134e-4d5c-86e0-a71b7167b57a',
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
[
|
||||
},
|
||||
{
|
||||
serversArray: [
|
||||
{
|
||||
id: '111',
|
||||
name: 'acel.me from servers.json',
|
||||
url: 'https://acel.me',
|
||||
apiKey: '07fb8a96-8059-4094-a24c-80a7d5e7e9b0',
|
||||
},
|
||||
{
|
||||
id: '222',
|
||||
name: 'Invalid',
|
||||
},
|
||||
{
|
||||
id: '333',
|
||||
name: 'Local from servers.json',
|
||||
url: 'http://localhost:8000',
|
||||
apiKey: '7a531c75-134e-4d5c-86e0-a71b7167b57a',
|
||||
},
|
||||
],
|
||||
{
|
||||
111: {
|
||||
id: '111',
|
||||
expectedNewServers: {
|
||||
'acel.me-from-servers.json-acel.me': {
|
||||
id: 'acel.me-from-servers.json-acel.me',
|
||||
name: 'acel.me from servers.json',
|
||||
url: 'https://acel.me',
|
||||
apiKey: '07fb8a96-8059-4094-a24c-80a7d5e7e9b0',
|
||||
},
|
||||
333: {
|
||||
id: '333',
|
||||
'local-from-servers.json-localhost-8000': {
|
||||
id: 'local-from-servers.json-localhost-8000',
|
||||
name: 'Local from servers.json',
|
||||
url: 'http://localhost:8000',
|
||||
apiKey: '7a531c75-134e-4d5c-86e0-a71b7167b57a',
|
||||
},
|
||||
|
||||
},
|
||||
],
|
||||
['<html></html>', {}],
|
||||
[{}, {}],
|
||||
])('tries to fetch servers from remote', async (mockedValue, expectedNewServers) => {
|
||||
jsonRequest.mockResolvedValue(mockedValue);
|
||||
},
|
||||
{
|
||||
serversArray: '<html></html>',
|
||||
expectedNewServers: {},
|
||||
},
|
||||
{
|
||||
serversArray: {},
|
||||
expectedNewServers: {},
|
||||
},
|
||||
])('tries to fetch servers from remote', async ({ serversArray, expectedNewServers }) => {
|
||||
jsonRequest.mockResolvedValue(serversArray);
|
||||
const doFetchServers = fetchServers(httpClient);
|
||||
|
||||
await doFetchServers()(dispatch, vi.fn(), {});
|
||||
|
|
|
@ -9,7 +9,6 @@ import {
|
|||
selectedServerReducerCreator,
|
||||
selectServer as selectServerCreator,
|
||||
} from '../../../src/servers/reducers/selectedServer';
|
||||
import { randomUUID } from '../../../src/utils/utils';
|
||||
|
||||
describe('selectedServerReducer', () => {
|
||||
const dispatch = vi.fn();
|
||||
|
@ -41,7 +40,7 @@ describe('selectedServerReducer', () => {
|
|||
['latest', MAX_FALLBACK_VERSION, 'latest'],
|
||||
['%invalid_semver%', MIN_FALLBACK_VERSION, '%invalid_semver%'],
|
||||
])('dispatches proper actions', async (serverVersion, expectedVersion, expectedPrintableVersion) => {
|
||||
const id = randomUUID();
|
||||
const id = crypto.randomUUID();
|
||||
const getState = createGetStateMock(id);
|
||||
const expectedSelectedServer = {
|
||||
id,
|
||||
|
@ -60,7 +59,7 @@ describe('selectedServerReducer', () => {
|
|||
});
|
||||
|
||||
it('dispatches error when health endpoint fails', async () => {
|
||||
const id = randomUUID();
|
||||
const id = crypto.randomUUID();
|
||||
const getState = createGetStateMock(id);
|
||||
const expectedSelectedServer = fromPartial<NonReachableServer>({ id, serverNotReachable: true });
|
||||
|
||||
|
@ -73,7 +72,7 @@ describe('selectedServerReducer', () => {
|
|||
});
|
||||
|
||||
it('dispatches error when server is not found', async () => {
|
||||
const id = randomUUID();
|
||||
const id = crypto.randomUUID();
|
||||
const getState = vi.fn(() => fromPartial<ShlinkState>({ servers: {} }));
|
||||
const expectedSelectedServer: NotFoundServer = { serverNotFound: true };
|
||||
|
||||
|
|
|
@ -105,15 +105,6 @@ describe('serversReducer', () => {
|
|||
|
||||
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', () => {
|
||||
|
|
Loading…
Reference in a new issue