Migrate DuplicatedServersModal to tailwind

This commit is contained in:
Alejandro Celaya
2025-04-05 11:15:42 +02:00
parent 7879476739
commit d8a42b4c3a
6 changed files with 92 additions and 77 deletions

View File

@@ -71,7 +71,7 @@ const CreateServer: FCWithDeps<CreateServerProps, CreateServerDeps> = ({ servers
<NoMenuLayout> <NoMenuLayout>
<ServerForm title="Add new server" onSubmit={onSubmit}> <ServerForm title="Add new server" onSubmit={onSubmit}>
{!hasServers && ( {!hasServers && (
<ImportServersBtn tooltipPlacement="top" onImport={setServersImported} onImportError={setErrorImporting} /> <ImportServersBtn tooltipPlacement="top" onImport={setServersImported} onError={setErrorImporting} />
)} )}
{hasServers && <Button variant="secondary" onClick={goBack}>Cancel</Button>} {hasServers && <Button variant="secondary" onClick={goBack}>Cancel</Button>}
<Button type="submit">Create server</Button> <Button type="submit">Create server</Button>
@@ -81,10 +81,10 @@ const CreateServer: FCWithDeps<CreateServerProps, CreateServerDeps> = ({ servers
{errorImporting && <ImportResult variant="error" />} {errorImporting && <ImportResult variant="error" />}
<DuplicatedServersModal <DuplicatedServersModal
isOpen={isConfirmModalOpen} open={isConfirmModalOpen}
duplicatedServers={serverData ? [serverData] : []} duplicatedServers={serverData ? [serverData] : []}
onDiscard={goBack} onClose={goBack}
onSave={() => serverData && saveNewServer(serverData)} onConfirm={() => serverData && saveNewServer(serverData)}
/> />
</NoMenuLayout> </NoMenuLayout>
); );

View File

@@ -48,7 +48,7 @@ const ManageServers: FCWithDeps<ManageServersProps, ManageServersDeps> = ({ serv
<div className="tw:flex tw:flex-col tw:md:flex-row tw:gap-2"> <div className="tw:flex tw:flex-col tw:md:flex-row tw:gap-2">
<div className="tw:flex tw:gap-2"> <div className="tw:flex tw:gap-2">
<ImportServersBtn className="tw:flex-grow" onImportError={setErrorImporting}>Import servers</ImportServersBtn> <ImportServersBtn className="tw:flex-grow" onError={setErrorImporting}>Import servers</ImportServersBtn>
{filteredServers.length > 0 && ( {filteredServers.length > 0 && (
<Button variant="secondary" className="tw:flex-grow" onClick={async () => serversExporter.exportServers()}> <Button variant="secondary" className="tw:flex-grow" onClick={async () => serversExporter.exportServers()}>
<FontAwesomeIcon icon={exportIcon} /> Export servers <FontAwesomeIcon icon={exportIcon} /> Export servers

View File

@@ -1,41 +1,42 @@
import { CardModal } from '@shlinkio/shlink-frontend-kit/tailwind';
import type { FC } from 'react'; import type { FC } from 'react';
import { Fragment } from 'react'; import { Fragment } from 'react';
import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import type { ServerData } from '../data'; import type { ServerData } from '../data';
interface DuplicatedServersModalProps { export type DuplicatedServersModalProps = {
duplicatedServers: ServerData[]; duplicatedServers: ServerData[];
isOpen: boolean; open: boolean;
onDiscard: () => void; onClose: () => void;
onSave: () => void; onConfirm: () => void;
} };
export const DuplicatedServersModal: FC<DuplicatedServersModalProps> = ( export const DuplicatedServersModal: FC<DuplicatedServersModalProps> = (
{ isOpen, duplicatedServers, onDiscard, onSave }, { open, duplicatedServers, onClose, onConfirm },
) => { ) => {
const hasMultipleServers = duplicatedServers.length > 1; const hasMultipleServers = duplicatedServers.length > 1;
return ( return (
<Modal centered isOpen={isOpen}> <CardModal
<ModalHeader>Duplicated server{hasMultipleServers && 's'}</ModalHeader> size="lg"
<ModalBody> title={`Duplicated server${hasMultipleServers ? 's' : ''}`}
<p>{hasMultipleServers ? 'The next servers already exist:' : 'There is already a server with:'}</p> open={open}
<ul> onClose={onClose}
{duplicatedServers.map(({ url, apiKey }, index) => (!hasMultipleServers ? ( onConfirm={onConfirm}
<Fragment key={index}> confirmText={`Save duplicate${hasMultipleServers ? 's' : ''}`}
<li>URL: <b>{url}</b></li> cancelText={hasMultipleServers ? 'Ignore duplicates' : 'Discard'}
<li>API key: <b>{apiKey}</b></li> >
</Fragment> <p>{hasMultipleServers ? 'The next servers already exist:' : 'There is already a server with:'}</p>
) : <li key={index}><b>{url}</b> - <b>{apiKey}</b></li>))} <ul className="tw:list-disc tw:mt-4">
</ul> {duplicatedServers.map(({ url, apiKey }, index) => (!hasMultipleServers ? (
<span> <Fragment key={index}>
{hasMultipleServers ? 'Do you want to ignore duplicated servers' : 'Do you want to save this server anyway'}? <li>URL: <b>{url}</b></li>
</span> <li>API key: <b>{apiKey}</b></li>
</ModalBody> </Fragment>
<ModalFooter> ) : <li key={index}><b>{url}</b> - <b>{apiKey}</b></li>))}
<Button color="link" onClick={onDiscard}>{hasMultipleServers ? 'Ignore duplicates' : 'Discard'}</Button> </ul>
<Button color="primary" onClick={onSave}>Save anyway</Button> <span>
</ModalFooter> {hasMultipleServers ? 'Do you want to save duplicated servers' : 'Do you want to save this server'}?
</Modal> </span>
</CardModal>
); );
}; };

View File

@@ -3,7 +3,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { useElementRef, useToggle } from '@shlinkio/shlink-frontend-kit'; import { useElementRef, useToggle } from '@shlinkio/shlink-frontend-kit';
import { Button } from '@shlinkio/shlink-frontend-kit/tailwind'; import { Button } from '@shlinkio/shlink-frontend-kit/tailwind';
import type { ChangeEvent, PropsWithChildren } from 'react'; import type { ChangeEvent, PropsWithChildren } from 'react';
import { useCallback, useRef, useState } from 'react'; import { useCallback, useRef , useState } from 'react';
import { UncontrolledTooltip } from 'reactstrap'; import { 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';
@@ -14,7 +14,7 @@ import { dedupServers, ensureUniqueIds } from './index';
export type ImportServersBtnProps = PropsWithChildren<{ export type ImportServersBtnProps = PropsWithChildren<{
onImport?: () => void; onImport?: () => void;
onImportError?: (error: Error) => void; onError?: (error: Error) => void;
tooltipPlacement?: 'top' | 'bottom'; tooltipPlacement?: 'top' | 'bottom';
className?: string; className?: string;
}>; }>;
@@ -32,8 +32,8 @@ const ImportServersBtn: FCWithDeps<ImportServersBtnConnectProps, ImportServersBt
createServers, createServers,
servers, servers,
children, children,
onImport = () => {}, onImport,
onImportError = () => {}, onError = () => {},
tooltipPlacement = 'bottom', tooltipPlacement = 'bottom',
className = '', className = '',
}) => { }) => {
@@ -41,41 +41,46 @@ const ImportServersBtn: FCWithDeps<ImportServersBtnConnectProps, ImportServersBt
const ref = useElementRef<HTMLInputElement>(); const ref = useElementRef<HTMLInputElement>();
const [duplicatedServers, setDuplicatedServers] = useState<ServerData[]>([]); const [duplicatedServers, setDuplicatedServers] = useState<ServerData[]>([]);
const [isModalOpen,, showModal, hideModal] = useToggle(); const [isModalOpen,, showModal, hideModal] = useToggle();
const newServersCreatedRef = useRef(false);
const importedServersRef = useRef<ServerWithId[]>([]);
const newServersRef = useRef<ServerWithId[]>([]);
const create = useCallback((serversData: ServerWithId[]) => {
createServers(serversData);
hideModal();
onImport();
}, [createServers, hideModal, 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((importedServers) => { .then((importedServers) => {
const { duplicatedServers, newServers } = dedupServers(servers, importedServers); const { duplicatedServers, newServers } = dedupServers(servers, importedServers);
importedServersRef.current = ensureUniqueIds(servers, importedServers); // Immediately create new servers
newServersRef.current = ensureUniqueIds(servers, newServers); newServersCreatedRef.current = newServers.length > 0;
createServers(ensureUniqueIds(servers, newServers));
if (duplicatedServers.length === 0) { // For duplicated servers, ask for confirmation
create(importedServersRef.current); if (duplicatedServers.length > 0) {
} else {
setDuplicatedServers(duplicatedServers); setDuplicatedServers(duplicatedServers);
showModal(); showModal();
} else {
onImport?.();
} }
}) })
.then(() => { .then(() => {
// Reset file 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(onError),
[create, onImportError, servers, serversImporter, showModal], [createServers, onError, onImport, servers, serversImporter, showModal],
); );
const createAllServers = useCallback(() => create(importedServersRef.current), [create]); const createDuplicatedServers = useCallback(() => {
const createNonDuplicatedServers = useCallback(() => create(newServersRef.current), [create]); createServers(ensureUniqueIds(servers, duplicatedServers));
hideModal();
onImport?.();
}, [createServers, duplicatedServers, hideModal, onImport, servers]);
const discardDuplicatedServers = useCallback(() => {
hideModal();
// If duplicated servers were discarded but some non-duplicated servers were created, call onImport
if (newServersCreatedRef.current) {
onImport?.();
}
}, [hideModal, onImport]);
return ( return (
<> <>
@@ -97,10 +102,10 @@ const ImportServersBtn: FCWithDeps<ImportServersBtnConnectProps, ImportServersBt
/> />
<DuplicatedServersModal <DuplicatedServersModal
isOpen={isModalOpen} open={isModalOpen}
duplicatedServers={duplicatedServers} duplicatedServers={duplicatedServers}
onDiscard={createNonDuplicatedServers} onClose={discardDuplicatedServers}
onSave={createAllServers} onConfirm={createDuplicatedServers}
/> />
</> </>
); );

View File

@@ -6,10 +6,10 @@ import { checkAccessibility } from '../../__helpers__/accessibility';
import { renderWithEvents } from '../../__helpers__/setUpTest'; import { renderWithEvents } from '../../__helpers__/setUpTest';
describe('<DuplicatedServersModal />', () => { describe('<DuplicatedServersModal />', () => {
const onDiscard = vi.fn(); const onClose = vi.fn();
const onSave = vi.fn(); const onConfirm = vi.fn();
const setUp = (duplicatedServers: ServerData[] = []) => act(() => renderWithEvents( const setUp = (duplicatedServers: ServerData[] = []) => act(() => renderWithEvents(
<DuplicatedServersModal isOpen duplicatedServers={duplicatedServers} onDiscard={onDiscard} onSave={onSave} />, <DuplicatedServersModal open duplicatedServers={duplicatedServers} onClose={onClose} onConfirm={onConfirm} />,
)); ));
const mockServer = (data: Partial<ServerData> = {}) => fromPartial<ServerData>(data); const mockServer = (data: Partial<ServerData> = {}) => fromPartial<ServerData>(data);
@@ -32,8 +32,9 @@ describe('<DuplicatedServersModal />', () => {
{ {
header: 'Duplicated server', header: 'Duplicated server',
firstParagraph: 'There is already a server with:', firstParagraph: 'There is already a server with:',
lastParagraph: 'Do you want to save this server anyway?', lastParagraph: 'Do you want to save this server?',
discardBtn: 'Discard', discardBtn: 'Discard',
confirmButton: 'Save duplicate',
}, },
], ],
[ [
@@ -41,8 +42,9 @@ describe('<DuplicatedServersModal />', () => {
{ {
header: 'Duplicated servers', header: 'Duplicated servers',
firstParagraph: 'The next servers already exist:', firstParagraph: 'The next servers already exist:',
lastParagraph: 'Do you want to ignore duplicated servers?', lastParagraph: 'Do you want to save duplicated servers?',
discardBtn: 'Ignore duplicates', discardBtn: 'Ignore duplicates',
confirmButton: 'Save duplicates',
}, },
], ],
])('renders expected texts based on amount of servers', async (duplicatedServers, assertions) => { ])('renders expected texts based on amount of servers', async (duplicatedServers, assertions) => {
@@ -52,6 +54,7 @@ describe('<DuplicatedServersModal />', () => {
expect(screen.getByText(assertions.firstParagraph)).toBeInTheDocument(); expect(screen.getByText(assertions.firstParagraph)).toBeInTheDocument();
expect(screen.getByText(assertions.lastParagraph)).toBeInTheDocument(); expect(screen.getByText(assertions.lastParagraph)).toBeInTheDocument();
expect(screen.getByRole('button', { name: assertions.discardBtn })).toBeInTheDocument(); expect(screen.getByRole('button', { name: assertions.discardBtn })).toBeInTheDocument();
expect(screen.getByRole('button', { name: assertions.confirmButton })).toBeInTheDocument();
}); });
it.each([ it.each([
@@ -80,19 +83,19 @@ describe('<DuplicatedServersModal />', () => {
} }
}); });
it('invokes onDiscard when appropriate button is clicked', async () => { it('invokes onClose when appropriate button is clicked', async () => {
const { user } = await setUp(); const { user } = await setUp();
expect(onDiscard).not.toHaveBeenCalled(); expect(onClose).not.toHaveBeenCalled();
await user.click(screen.getByRole('button', { name: 'Discard' })); await user.click(screen.getByRole('button', { name: 'Discard' }));
expect(onDiscard).toHaveBeenCalled(); expect(onClose).toHaveBeenCalled();
}); });
it('invokes onSave when appropriate button is clicked', async () => { it('invokes onConfirm when appropriate button is clicked', async () => {
const { user } = await setUp(); const { user } = await setUp();
expect(onSave).not.toHaveBeenCalled(); expect(onConfirm).not.toHaveBeenCalled();
await user.click(screen.getByRole('button', { name: 'Save anyway' })); await user.click(screen.getByRole('button', { name: 'Save duplicate' }));
expect(onSave).toHaveBeenCalled(); expect(onConfirm).toHaveBeenCalled();
}); });
}); });

View File

@@ -65,9 +65,9 @@ describe('<ImportServersBtn />', () => {
}); });
it.each([ it.each([
{ btnName: 'Save anyway',savesDuplicatedServers: true }, { btnName: 'Save duplicate', savesDuplicatedServers: true },
{ btnName: 'Discard', savesDuplicatedServers: false }, { btnName: 'Discard', savesDuplicatedServers: false },
])('creates expected servers depending on selected option in modal', async ({ btnName, savesDuplicatedServers }) => { ])('creates duplicated servers depending on selected option in modal', async ({ btnName, savesDuplicatedServers }) => {
const existingServerData: ServerData = { const existingServerData: ServerData = {
name: 'existingServer', name: 'existingServer',
url: 'http://s.test/existingUrl', url: 'http://s.test/existingUrl',
@@ -84,14 +84,20 @@ describe('<ImportServersBtn />', () => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
await user.upload(screen.getByTestId('csv-file-input'), csvFile); await user.upload(screen.getByTestId('csv-file-input'), csvFile);
// Once the file is uploaded, non-duplicated servers are immediately created
expect(createServersMock).toHaveBeenCalledExactlyOnceWith([expect.objectContaining(newServer)]);
expect(screen.getByRole('dialog')).toBeInTheDocument(); expect(screen.getByRole('dialog')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: btnName })); await user.click(screen.getByRole('button', { name: btnName }));
expect(createServersMock).toHaveBeenCalledWith( // If duplicated servers are saved, there's one extra call
savesDuplicatedServers if (savesDuplicatedServers) {
? [expect.objectContaining(existingServerData), expect.objectContaining(newServer)] expect(createServersMock).toHaveBeenLastCalledWith([expect.objectContaining(existingServerData)]);
: [expect.objectContaining(newServer)], }
);
expect(onImportMock).toHaveBeenCalledTimes(1); // On import is called only once, no matter what
expect(onImportMock).toHaveBeenCalledOnce();
expect(createServersMock).toHaveBeenCalledTimes(savesDuplicatedServers ? 2 : 1);
}); });
}); });