Extract logic to determine if a list of servers contains duplicates

This commit is contained in:
Alejandro Celaya
2024-10-31 09:30:14 +01:00
parent 913264b0db
commit 9134d07969
5 changed files with 90 additions and 45 deletions

View File

@@ -9,6 +9,7 @@ import { componentFactory, useDependencies } from '../../container/utils';
import type { ServerData, ServersMap } from '../data';
import type { ServersImporter } from '../services/ServersImporter';
import { DuplicatedServersModal } from './DuplicatedServersModal';
import { dedupServers } from './index';
export type ImportServersBtnProps = PropsWithChildren<{
onImport?: () => void;
@@ -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,7 +41,9 @@ const ImportServersBtn: FCWithDeps<ImportServersBtnConnectProps, ImportServersBt
const [duplicatedServers, setDuplicatedServers] = useState<ServerData[]>([]);
const [isModalOpen,, showModal, hideModal] = useToggle();
const serversToCreate = useRef<ServerData[]>([]);
const importedServersRef = useRef<ServerData[]>([]);
const newServersRef = useRef<ServerData[]>([]);
const create = useCallback((serversData: ServerData[]) => {
createServers(serversData);
onImport();
@@ -51,22 +51,21 @@ const ImportServersBtn: FCWithDeps<ImportServersBtnConnectProps, ImportServersBt
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 = importedServers;
newServersRef.current = newServers;
if (!hasDuplicatedServers) {
create(newServers);
if (duplicatedServers.length === 0) {
create(importedServers);
} 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}

View File

@@ -0,0 +1,50 @@
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 {
const url = new URL(server.url);
return `${server.name} ${url.host}`.toLowerCase().replace(/[^a-zA-Z0-9-_.~]/g, '-');
}
export function serverWithId(server: ServerWithId | ServerData): ServerWithId {
if ('id' in server) {
return server;
}
const id = idForServer(server);
return { ...server, id };
}
export function serversListToMap(servers: ServerWithId[]): ServersMap {
return servers.reduce<ServersMap>(
(acc, server) => ({ ...acc, [server.id]: server }),
{},
);
}
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 };
}

View File

@@ -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, serverWithId } 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,