Replace reactstrap nav bar with tailwind-based one

This commit is contained in:
Alejandro Celaya 2025-06-16 11:18:03 +02:00
parent 9c0c2fc3f9
commit d10d7fd96d
9 changed files with 81 additions and 139 deletions

View File

@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
### Changed ### Changed
* Update to `@shlinkio/shlink-frontend-kit` 0.9 and `@shlinkio/shlink-web-component` 0.14 to add initial support to the new light theme brand color. * Update to `@shlinkio/shlink-frontend-kit` 0.9 and `@shlinkio/shlink-web-component` 0.14 to add initial support to the new light theme brand color.
* Replace reactstrap nav bar with `NavBar` component from `@shlinkio/shlink-frontend-kit`
### Deprecated ### Deprecated
* *Nothing* * *Nothing*

50
package-lock.json generated
View File

@ -16,9 +16,9 @@
"@json2csv/plainjs": "^7.0.6", "@json2csv/plainjs": "^7.0.6",
"@reduxjs/toolkit": "^2.8.2", "@reduxjs/toolkit": "^2.8.2",
"@shlinkio/data-manipulation": "^1.0.3", "@shlinkio/data-manipulation": "^1.0.3",
"@shlinkio/shlink-frontend-kit": "^0.9.10", "@shlinkio/shlink-frontend-kit": "^0.9.13",
"@shlinkio/shlink-js-sdk": "^2.1.0", "@shlinkio/shlink-js-sdk": "^2.1.0",
"@shlinkio/shlink-web-component": "^0.14.2", "@shlinkio/shlink-web-component": "^0.14.3",
"bootstrap": "5.2.3", "bootstrap": "5.2.3",
"bottlejs": "^2.0.1", "bottlejs": "^2.0.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@ -3516,9 +3516,9 @@
} }
}, },
"node_modules/@shlinkio/shlink-frontend-kit": { "node_modules/@shlinkio/shlink-frontend-kit": {
"version": "0.9.10", "version": "0.9.13",
"resolved": "https://registry.npmjs.org/@shlinkio/shlink-frontend-kit/-/shlink-frontend-kit-0.9.10.tgz", "resolved": "https://registry.npmjs.org/@shlinkio/shlink-frontend-kit/-/shlink-frontend-kit-0.9.13.tgz",
"integrity": "sha512-L1+z3imvoSXHYWaO+H39JXGg40eQW1ytY3hMIE8JUuqJYNmWWLrafmfj1MHenCWGZEhymbQnpGD1yyziy6a9Lw==", "integrity": "sha512-qiEDmrzYA/rmHPdLI9znaYqMKD16ITWon/vG66LlUeDL3zR0Psppy79FjM5k3CmIGsCCZdekXQle6U19YSOQLA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@floating-ui/react": "^0.27.12", "@floating-ui/react": "^0.27.12",
@ -3550,9 +3550,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@shlinkio/shlink-web-component": { "node_modules/@shlinkio/shlink-web-component": {
"version": "0.14.2", "version": "0.14.3",
"resolved": "https://registry.npmjs.org/@shlinkio/shlink-web-component/-/shlink-web-component-0.14.2.tgz", "resolved": "https://registry.npmjs.org/@shlinkio/shlink-web-component/-/shlink-web-component-0.14.3.tgz",
"integrity": "sha512-4GRT1nLuhVCGuKP8fwRv1EBtgQ2wCvpJqJ6ipYM/QKwA2uJIXChom4TDia+s4X8mESIOjV0++aoPOEr6y6H2iA==", "integrity": "sha512-IgTHJYkxp6Pqo4E8waouBXbpytiRasqPyMAMQ5vYfGXU5Y53D4QNkRwPsILkEVTb7B+qwQegyNax4YKjRi/hgA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@formkit/drag-and-drop": "^0.5.3", "@formkit/drag-and-drop": "^0.5.3",
@ -3569,7 +3569,6 @@
"react-external-link": "^2.5.0", "react-external-link": "^2.5.0",
"react-leaflet": "^4.2.1 || ^5.0", "react-leaflet": "^4.2.1 || ^5.0",
"react-swipeable": "^7.0.2", "react-swipeable": "^7.0.2",
"react-tag-autocomplete": "^7.5.0",
"recharts": "^2.15.3" "recharts": "^2.15.3"
}, },
"peerDependencies": { "peerDependencies": {
@ -3579,7 +3578,7 @@
"@fortawesome/free-solid-svg-icons": "^6.7.2", "@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/react-fontawesome": "^0.2.2", "@fortawesome/react-fontawesome": "^0.2.2",
"@reduxjs/toolkit": "^2.5.0", "@reduxjs/toolkit": "^2.5.0",
"@shlinkio/shlink-frontend-kit": "^0.9.10", "@shlinkio/shlink-frontend-kit": "^0.9.11",
"@shlinkio/shlink-js-sdk": "^2.0.0", "@shlinkio/shlink-js-sdk": "^2.0.0",
"react": "^18.3 || ^19.0", "react": "^18.3 || ^19.0",
"react-dom": "^18.3 || ^19.0", "react-dom": "^18.3 || ^19.0",
@ -9400,18 +9399,6 @@
"react": "^16.8.3 || ^17 || ^18 || ^19.0.0 || ^19.0.0-rc" "react": "^16.8.3 || ^17 || ^18 || ^19.0.0 || ^19.0.0-rc"
} }
}, },
"node_modules/react-tag-autocomplete": {
"version": "7.5.0",
"resolved": "https://registry.npmjs.org/react-tag-autocomplete/-/react-tag-autocomplete-7.5.0.tgz",
"integrity": "sha512-uy6ncusMKr6p3ip7xb4DTYtF22g7cSRyZq0IeFpgmrQipTbKz4RVFDB5QnnqstN6HTs9cdTtIb3vVuOuOdzH3w==",
"license": "ISC",
"engines": {
"node": ">= 16.12.0"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0"
}
},
"node_modules/react-transition-group": { "node_modules/react-transition-group": {
"version": "4.4.5", "version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
@ -14073,9 +14060,9 @@
"requires": {} "requires": {}
}, },
"@shlinkio/shlink-frontend-kit": { "@shlinkio/shlink-frontend-kit": {
"version": "0.9.10", "version": "0.9.13",
"resolved": "https://registry.npmjs.org/@shlinkio/shlink-frontend-kit/-/shlink-frontend-kit-0.9.10.tgz", "resolved": "https://registry.npmjs.org/@shlinkio/shlink-frontend-kit/-/shlink-frontend-kit-0.9.13.tgz",
"integrity": "sha512-L1+z3imvoSXHYWaO+H39JXGg40eQW1ytY3hMIE8JUuqJYNmWWLrafmfj1MHenCWGZEhymbQnpGD1yyziy6a9Lw==", "integrity": "sha512-qiEDmrzYA/rmHPdLI9znaYqMKD16ITWon/vG66LlUeDL3zR0Psppy79FjM5k3CmIGsCCZdekXQle6U19YSOQLA==",
"requires": { "requires": {
"@floating-ui/react": "^0.27.12", "@floating-ui/react": "^0.27.12",
"clsx": "^2.1.1" "clsx": "^2.1.1"
@ -14087,9 +14074,9 @@
"integrity": "sha512-K6zmA/A7Ux9hTn+ZjAm85YmMl7/v5XgZBM62syCxCsK7Tdw7Gg4+C06cZ2gUv+HWrHtv5IXsi4ax00++8Kg5vw==" "integrity": "sha512-K6zmA/A7Ux9hTn+ZjAm85YmMl7/v5XgZBM62syCxCsK7Tdw7Gg4+C06cZ2gUv+HWrHtv5IXsi4ax00++8Kg5vw=="
}, },
"@shlinkio/shlink-web-component": { "@shlinkio/shlink-web-component": {
"version": "0.14.2", "version": "0.14.3",
"resolved": "https://registry.npmjs.org/@shlinkio/shlink-web-component/-/shlink-web-component-0.14.2.tgz", "resolved": "https://registry.npmjs.org/@shlinkio/shlink-web-component/-/shlink-web-component-0.14.3.tgz",
"integrity": "sha512-4GRT1nLuhVCGuKP8fwRv1EBtgQ2wCvpJqJ6ipYM/QKwA2uJIXChom4TDia+s4X8mESIOjV0++aoPOEr6y6H2iA==", "integrity": "sha512-IgTHJYkxp6Pqo4E8waouBXbpytiRasqPyMAMQ5vYfGXU5Y53D4QNkRwPsILkEVTb7B+qwQegyNax4YKjRi/hgA==",
"requires": { "requires": {
"@formkit/drag-and-drop": "^0.5.3", "@formkit/drag-and-drop": "^0.5.3",
"@json2csv/plainjs": "^7.0.6", "@json2csv/plainjs": "^7.0.6",
@ -14105,7 +14092,6 @@
"react-external-link": "^2.5.0", "react-external-link": "^2.5.0",
"react-leaflet": "^4.2.1 || ^5.0", "react-leaflet": "^4.2.1 || ^5.0",
"react-swipeable": "^7.0.2", "react-swipeable": "^7.0.2",
"react-tag-autocomplete": "^7.5.0",
"recharts": "^2.15.3" "recharts": "^2.15.3"
}, },
"dependencies": { "dependencies": {
@ -18014,12 +18000,6 @@
"integrity": "sha512-v1Qx1l+aC2fdxKa9aKJiaU/ZxmJ5o98RMoFwUqAAzVWUcxgfHFXDDruCKXhw6zIYXm6V64JiHgP9f6mlME5l8w==", "integrity": "sha512-v1Qx1l+aC2fdxKa9aKJiaU/ZxmJ5o98RMoFwUqAAzVWUcxgfHFXDDruCKXhw6zIYXm6V64JiHgP9f6mlME5l8w==",
"requires": {} "requires": {}
}, },
"react-tag-autocomplete": {
"version": "7.5.0",
"resolved": "https://registry.npmjs.org/react-tag-autocomplete/-/react-tag-autocomplete-7.5.0.tgz",
"integrity": "sha512-uy6ncusMKr6p3ip7xb4DTYtF22g7cSRyZq0IeFpgmrQipTbKz4RVFDB5QnnqstN6HTs9cdTtIb3vVuOuOdzH3w==",
"requires": {}
},
"react-transition-group": { "react-transition-group": {
"version": "4.4.5", "version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",

View File

@ -29,9 +29,9 @@
"@json2csv/plainjs": "^7.0.6", "@json2csv/plainjs": "^7.0.6",
"@reduxjs/toolkit": "^2.8.2", "@reduxjs/toolkit": "^2.8.2",
"@shlinkio/data-manipulation": "^1.0.3", "@shlinkio/data-manipulation": "^1.0.3",
"@shlinkio/shlink-frontend-kit": "^0.9.10", "@shlinkio/shlink-frontend-kit": "^0.9.13",
"@shlinkio/shlink-js-sdk": "^2.1.0", "@shlinkio/shlink-js-sdk": "^2.1.0",
"@shlinkio/shlink-web-component": "^0.14.2", "@shlinkio/shlink-web-component": "^0.14.3",
"bootstrap": "5.2.3", "bootstrap": "5.2.3",
"bottlejs": "^2.0.1", "bottlejs": "^2.0.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",

View File

@ -64,7 +64,7 @@ const App: FCWithDeps<AppProps, AppDeps> = (
<div className="tw:h-full"> <div className="tw:h-full">
<MainHeader /> <MainHeader />
<div className="tw:h-full tw:pt-(--header-height)"> <div className="tw:h-full tw:pt-(--tw-header-height)">
<div <div
data-testid="shlink-wrapper" data-testid="shlink-wrapper"
className={clsx( className={clsx(

View File

@ -1,11 +1,8 @@
import { faChevronDown as arrowIcon, faCogs as cogsIcon } from '@fortawesome/free-solid-svg-icons'; import { faCogs as cogsIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { useToggle } from '@shlinkio/shlink-frontend-kit'; import { NavBar } from '@shlinkio/shlink-frontend-kit/tailwind';
import { clsx } from 'clsx';
import type { FC } from 'react'; import type { FC } from 'react';
import { useEffect } from 'react';
import { Link, useLocation } from 'react-router'; import { Link, useLocation } from 'react-router';
import { Collapse, Nav, Navbar, NavbarBrand, NavbarToggler, NavItem, NavLink } 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 { ShlinkLogo } from './img/ShlinkLogo'; import { ShlinkLogo } from './img/ShlinkLogo';
@ -16,39 +13,28 @@ type MainHeaderDeps = {
const MainHeader: FCWithDeps<unknown, MainHeaderDeps> = () => { const MainHeader: FCWithDeps<unknown, MainHeaderDeps> = () => {
const { ServersDropdown } = useDependencies(MainHeader); const { ServersDropdown } = useDependencies(MainHeader);
const { flag: isNotCollapsed, toggle: toggleCollapse, setToFalse: collapse } = useToggle(false, true); const { pathname } = useLocation();
const location = useLocation();
const { pathname } = location;
// In mobile devices, collapse the navbar when location changes
useEffect(collapse, [location, collapse]);
const settingsPath = '/settings'; const settingsPath = '/settings';
return ( return (
<Navbar color="primary" dark fixed="top" expand="md" className="tw:text-white tw:bg-lm-main tw:dark:bg-dm-main"> <NavBar
<NavbarBrand tag={Link} to="/"> className="tw:[&]:fixed tw:top-0 tw:z-900"
<ShlinkLogo className="tw:inline tw:w-7 tw:mr-1" color="white" /> Shlink brand={(
</NavbarBrand> <Link to="/" className="tw:[&]:text-white tw:no-underline tw:flex tw:items-center tw:gap-2">
<ShlinkLogo className="tw:w-7" color="white" /> <small className="tw:font-normal">Shlink</small>
<NavbarToggler onClick={toggleCollapse}> </Link>
<FontAwesomeIcon )}
icon={arrowIcon} >
className={clsx('tw:transition-transform tw:duration-300', { 'tw:rotate-180': isNotCollapsed })} <NavBar.MenuItem
/> to={settingsPath}
</NavbarToggler> active={pathname.startsWith(settingsPath)}
className="tw:flex tw:items-center tw:gap-1.5"
<Collapse navbar isOpen={isNotCollapsed}> >
<Nav navbar className="tw:ml-auto"> <FontAwesomeIcon icon={cogsIcon} /> Settings
<NavItem> </NavBar.MenuItem>
<NavLink tag={Link} to={settingsPath} active={pathname.startsWith(settingsPath)}>
<FontAwesomeIcon icon={cogsIcon} />&nbsp; Settings
</NavLink>
</NavItem>
<ServersDropdown /> <ServersDropdown />
</Nav> </NavBar>
</Collapse>
</Navbar>
); );
}; };

View File

@ -1,7 +1,6 @@
import { faPlus as plusIcon, faServer as serverIcon } from '@fortawesome/free-solid-svg-icons'; import { faPlus as plusIcon, faServer as serverIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Link } from 'react-router'; import { Dropdown, NavBar } from '@shlinkio/shlink-frontend-kit/tailwind';
import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap';
import type { SelectedServer, ServersMap } from './data'; import type { SelectedServer, ServersMap } from './data';
import { getServerId } from './data'; import { getServerId } from './data';
@ -14,29 +13,28 @@ export const ServersDropdown = ({ servers, selectedServer }: ServersDropdownProp
const serversList = Object.values(servers); const serversList = Object.values(servers);
return ( return (
<UncontrolledDropdown nav inNavbar> <NavBar.Dropdown buttonContent={(
<DropdownToggle nav caret> <span className="tw:flex tw:items-center tw:gap-1.5">
<FontAwesomeIcon icon={serverIcon} /> <span className="tw:ml-1">Servers</span> <FontAwesomeIcon icon={serverIcon} fixedWidth /> Servers
</DropdownToggle> </span>
<DropdownMenu end className="tw:right-0"> )}>
{serversList.length === 0 ? ( {serversList.length === 0 ? (
<DropdownItem tag={Link} to="/server/create"> <Dropdown.Item to="/server/create">
<FontAwesomeIcon icon={plusIcon} /> <span className="tw:ml-1">Add a server</span> <FontAwesomeIcon icon={plusIcon} /> Add a server
</DropdownItem> </Dropdown.Item>
) : ( ) : (
<> <>
{serversList.map(({ name, id }) => ( {serversList.map(({ name, id }) => (
<DropdownItem key={id} tag={Link} to={`/server/${id}`} active={getServerId(selectedServer) === id}> <Dropdown.Item key={id} to={`/server/${id}`} selected={getServerId(selectedServer) === id}>
{name} {name}
</DropdownItem> </Dropdown.Item>
))} ))}
<DropdownItem divider tag="hr" /> <Dropdown.Separator />
<DropdownItem tag={Link} to="/manage-servers"> <Dropdown.Item to="/manage-servers">
<FontAwesomeIcon icon={serverIcon} /> <span className="tw:ml-1">Manage servers</span> <FontAwesomeIcon icon={serverIcon} /> Manage servers
</DropdownItem> </Dropdown.Item>
</> </>
)} )}
</DropdownMenu> </NavBar.Dropdown>
</UncontrolledDropdown>
); );
}; };

View File

@ -8,5 +8,7 @@
:root { :root {
--footer-height: 2.3rem; --footer-height: 2.3rem;
--footer-margin: .8rem; --footer-margin: .8rem;
/* Temp alias fo header-height to tw-header-height, so that shlink-web-component uses the right value */
--header-height: var(--tw-header-height);
} }
} }

View File

@ -1,4 +1,4 @@
import { screen, waitFor } from '@testing-library/react'; import { screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn'; import { fromPartial } from '@total-typescript/shoehorn';
import { createMemoryHistory } from 'history'; import { createMemoryHistory } from 'history';
import { Router } from 'react-router'; import { Router } from 'react-router';
@ -8,8 +8,8 @@ import { renderWithEvents } from '../__helpers__/setUpTest';
describe('<MainHeader />', () => { describe('<MainHeader />', () => {
const MainHeader = MainHeaderFactory(fromPartial({ const MainHeader = MainHeaderFactory(fromPartial({
// Fake this component as a li, as it gets rendered inside a ul // Fake this component as a li[role="menuitem"], as it gets rendered inside a ul[role="menu"]
ServersDropdown: () => <li>ServersDropdown</li>, ServersDropdown: () => <li role="menuitem">ServersDropdown</li>,
})); }));
const setUp = (pathname = '') => { const setUp = (pathname = '') => {
const history = createMemoryHistory(); const history = createMemoryHistory();
@ -37,35 +37,8 @@ describe('<MainHeader />', () => {
['/settings/bar', true], ['/settings/bar', true],
])('sets link to settings as active only when current path is settings', (currentPath, isActive) => { ])('sets link to settings as active only when current path is settings', (currentPath, isActive) => {
setUp(currentPath); setUp(currentPath);
expect(screen.getByRole('menuitem', { name: /Settings$/ })).toHaveAttribute(
if (isActive) { 'data-active', isActive ? 'true' : 'false',
expect(screen.getByText(/Settings$/).getAttribute('class')).toContain('active'); );
} else {
expect(screen.getByText(/Settings$/).getAttribute('class')).not.toContain('active');
}
});
it('renders expected class based on the nav bar state', async () => {
const { user } = setUp();
const toggle = screen.getByLabelText('Toggle navigation');
const icon = toggle.firstChild;
expect(icon).not.toHaveClass('tw:rotate-180');
await user.click(toggle);
expect(icon).toHaveClass('tw:rotate-180');
await user.click(toggle);
expect(icon).not.toHaveClass('tw:rotate-180');
});
it('opens Collapse when clicking toggle', async () => {
const { container, user } = setUp();
const collapse = container.querySelector('.collapse');
const toggle = screen.getByLabelText('Toggle navigation');
expect(collapse).not.toHaveAttribute('class', expect.stringContaining('show'));
await user.click(toggle);
await waitFor(() => expect(collapse).toHaveAttribute('class', expect.stringContaining('show')));
}); });
}); });

View File

@ -14,7 +14,7 @@ describe('<ServersDropdown />', () => {
}; };
const setUp = (servers: ServersMap = fallbackServers) => renderWithEvents( const setUp = (servers: ServersMap = fallbackServers) => renderWithEvents(
<MemoryRouter> <MemoryRouter>
<ul> <ul role="menu">
<ServersDropdown servers={servers} selectedServer={null} /> <ServersDropdown servers={servers} selectedServer={null} />
</ul> </ul>
</MemoryRouter>, </MemoryRouter>,
@ -33,16 +33,18 @@ describe('<ServersDropdown />', () => {
await user.click(screen.getByText('Servers')); await user.click(screen.getByText('Servers'));
const items = screen.getAllByRole('menuitem'); const items = screen.getAllByRole('menuitem');
expect(items).toHaveLength(Object.values(fallbackServers).length + 1);
expect(items[0]).toHaveTextContent('foo'); // We have to add two for the "Manage servers" and the "Settings" menu items
expect(items[1]).toHaveTextContent('bar'); expect(items).toHaveLength(Object.values(fallbackServers).length + 2);
expect(items[2]).toHaveTextContent('baz'); expect(items[1]).toHaveTextContent('foo');
expect(items[3]).toHaveTextContent('Manage servers'); expect(items[2]).toHaveTextContent('bar');
expect(items[3]).toHaveTextContent('baz');
expect(items[4]).toHaveTextContent('Manage servers');
}); });
it('contains a toggle with proper text', () => { it('contains a toggle with proper text', () => {
setUp(); setUp();
expect(screen.getByRole('link')).toHaveTextContent('Servers'); expect(screen.getByRole('button')).toHaveTextContent('Servers');
}); });
it('contains a button to manage servers', async () => { it('contains a button to manage servers', async () => {
@ -56,6 +58,6 @@ describe('<ServersDropdown />', () => {
const { user } = setUp({}); const { user } = setUp({});
await user.click(screen.getByText('Servers')); await user.click(screen.getByText('Servers'));
expect(screen.getByRole('menuitem')).toHaveTextContent('Add a server'); expect(screen.getByRole('menuitem', { name: 'Add a server' })).toBeInTheDocument();
}); });
}); });