Merge pull request #833 from acelaya-forks/feature/menus

Feature/menus
This commit is contained in:
Alejandro Celaya 2023-05-27 10:57:14 +02:00 committed by GitHub
commit 706e00ace0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 114 additions and 111 deletions

View file

@ -11,6 +11,6 @@ jobs:
ci:
uses: shlinkio/github-actions/.github/workflows/web-app-ci.yml@main
with:
node-version: 18.12
node-version: 20.2
publish-coverage: true
force-install: true

View file

@ -9,14 +9,14 @@ jobs:
continue-on-error: true
steps:
- name: Checkout code
uses: actions/checkout@v2
uses: actions/checkout@v3
with:
repository: ${{ github.event.pull_request.head.repo.full_name }}
ref: ${{ github.event.pull_request.head.ref }}
- name: Use node.js
uses: actions/setup-node@v1
uses: actions/setup-node@v3
with:
node-version: 18.12
node-version: 20.2
- name: Build
run: |
npm ci --force && \

View file

@ -10,11 +10,11 @@ jobs:
runs-on: ubuntu-20.04
steps:
- name: Checkout code
uses: actions/checkout@v2
uses: actions/checkout@v3
- name: Use node.js
uses: actions/setup-node@v1
uses: actions/setup-node@v3
with:
node-version: 18.12
node-version: 20.2
- name: Generate release assets
run: npm ci --force && VERSION=${GITHUB_REF#refs/tags/v} npm run build:dist
- name: Publish release with assets

View file

@ -1,4 +1,4 @@
FROM node:18.12-alpine as node
FROM node:20.2-alpine as node
COPY . /shlink-web-client
ARG VERSION="latest"
ENV VERSION ${VERSION}

View file

@ -3,7 +3,7 @@ version: '3'
services:
shlink_web_client_node:
container_name: shlink_web_client_node
image: node:18.12-alpine
image: node:20.2-alpine
command: /bin/sh -c "cd /home/shlink/www && npm install --force && npm run start"
volumes:
- ./:/home/shlink/www

View file

@ -5,9 +5,9 @@ import { Link } from 'react-router-dom';
import { DropdownItem } from 'reactstrap';
import type { SelectedServer } from '../../servers/data';
import { getServerId } from '../../servers/data';
import { DropdownBtnMenu } from '../../utils/DropdownBtnMenu';
import { useFeature } from '../../utils/helpers/features';
import { useToggle } from '../../utils/helpers/hooks';
import { RowDropdownBtn } from '../../utils/RowDropdownBtn';
import { DEFAULT_DOMAIN } from '../../visits/reducers/domainVisits';
import type { Domain } from '../data';
import type { EditDomainRedirects } from '../reducers/domainRedirects';
@ -20,7 +20,6 @@ interface DomainDropdownProps {
}
export const DomainDropdown: FC<DomainDropdownProps> = ({ domain, editDomainRedirects, selectedServer }) => {
const [isOpen, toggle] = useToggle();
const [isModalOpen, toggleModal] = useToggle();
const { isDefault } = domain;
const canBeEdited = !isDefault || useFeature('defaultDomainRedirectsEdition', selectedServer);
@ -28,7 +27,7 @@ export const DomainDropdown: FC<DomainDropdownProps> = ({ domain, editDomainRedi
const serverId = getServerId(selectedServer);
return (
<DropdownBtnMenu isOpen={isOpen} toggle={toggle}>
<RowDropdownBtn>
{withVisits && (
<DropdownItem
tag={Link}
@ -47,6 +46,6 @@ export const DomainDropdown: FC<DomainDropdownProps> = ({ domain, editDomainRedi
toggle={toggleModal}
editDomainRedirects={editDomainRedirects}
/>
</DropdownBtnMenu>
</RowDropdownBtn>
);
};

View file

@ -9,8 +9,8 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import type { FC } from 'react';
import { Link } from 'react-router-dom';
import { DropdownItem } from 'reactstrap';
import { DropdownBtnMenu } from '../utils/DropdownBtnMenu';
import { useToggle } from '../utils/helpers/hooks';
import { RowDropdownBtn } from '../utils/RowDropdownBtn';
import type { ServerWithId } from './data';
import type { DeleteServerModalProps } from './DeleteServerModal';
@ -25,14 +25,13 @@ interface ManageServersRowDropdownConnectProps extends ManageServersRowDropdownP
export const ManageServersRowDropdown = (
DeleteServerModal: FC<DeleteServerModalProps>,
): FC<ManageServersRowDropdownConnectProps> => ({ server, setAutoConnect }) => {
const [isMenuOpen, toggleMenu] = useToggle();
const [isModalOpen,, showModal, hideModal] = useToggle();
const serverUrl = `/server/${server.id}`;
const { autoConnect: isAutoConnect } = server;
const autoConnectIcon = isAutoConnect ? toggleOffIcon : toggleOnIcon;
return (
<DropdownBtnMenu isOpen={isMenuOpen} toggle={toggleMenu}>
<RowDropdownBtn minWidth={170}>
<DropdownItem tag={Link} to={serverUrl}>
<FontAwesomeIcon icon={connectIcon} fixedWidth /> Connect
</DropdownItem>
@ -48,6 +47,6 @@ export const ManageServersRowDropdown = (
</DropdownItem>
<DeleteServerModal redirectHome={false} server={server} isOpen={isModalOpen} toggle={hideModal} />
</DropdownBtnMenu>
</RowDropdownBtn>
);
};

View file

@ -17,7 +17,7 @@ export const ShortUrlsFilterDropdown = (
const onFilterClick = (key: keyof ShortUrlsFilter) => () => onChange({ ...selected, [key]: !selected?.[key] });
return (
<DropdownBtn text="Filters" dropdownClassName={className} className="me-3" right minWidth={250}>
<DropdownBtn text="Filters" dropdownClassName={className} inline end minWidth={250}>
<DropdownItem header>Visits:</DropdownItem>
<DropdownItem active={excludeBots} onClick={onFilterClick('excludeBots')}>Ignore visits from bots</DropdownItem>

View file

@ -87,7 +87,7 @@ export const ShortUrlsRow = (
<td className="responsive-table__cell short-urls-row__cell" data-th="Status">
<ShortUrlStatus shortUrl={shortUrl} />
</td>
<td className="responsive-table__cell short-urls-row__cell">
<td className="responsive-table__cell short-urls-row__cell text-end">
<ShortUrlsRowMenu selectedServer={selectedServer} shortUrl={shortUrl} />
</td>
</tr>

View file

@ -8,8 +8,8 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import type { FC } from 'react';
import { DropdownItem } from 'reactstrap';
import type { SelectedServer } from '../../servers/data';
import { DropdownBtnMenu } from '../../utils/DropdownBtnMenu';
import { useToggle } from '../../utils/helpers/hooks';
import { RowDropdownBtn } from '../../utils/RowDropdownBtn';
import type { ShortUrl, ShortUrlModalProps } from '../data';
import { ShortUrlDetailLink } from './ShortUrlDetailLink';
@ -23,12 +23,11 @@ export const ShortUrlsRowMenu = (
DeleteShortUrlModal: ShortUrlModal,
QrCodeModal: ShortUrlModal,
) => ({ shortUrl, selectedServer }: ShortUrlsRowMenuProps) => {
const [isOpen, toggle] = useToggle();
const [isQrModalOpen,, openQrCodeModal, closeQrCodeModal] = useToggle();
const [isDeleteModalOpen,, openDeleteModal, closeDeleteModal] = useToggle();
return (
<DropdownBtnMenu toggle={toggle} isOpen={isOpen}>
<RowDropdownBtn minWidth={190}>
<DropdownItem tag={ShortUrlDetailLink} selectedServer={selectedServer} shortUrl={shortUrl} suffix="visits">
<FontAwesomeIcon icon={pieChartIcon} fixedWidth /> Visit stats
</DropdownItem>
@ -48,7 +47,7 @@ export const ShortUrlsRowMenu = (
<FontAwesomeIcon icon={deleteIcon} fixedWidth /> Delete short URL
</DropdownItem>
<DeleteShortUrlModal shortUrl={shortUrl} isOpen={isDeleteModalOpen} toggle={closeDeleteModal} />
</DropdownBtnMenu>
</RowDropdownBtn>
);
};

View file

@ -5,9 +5,9 @@ import { Link } from 'react-router-dom';
import { DropdownItem } from 'reactstrap';
import type { SelectedServer } from '../servers/data';
import { getServerId } from '../servers/data';
import { DropdownBtnMenu } from '../utils/DropdownBtnMenu';
import { useToggle } from '../utils/helpers/hooks';
import { prettify } from '../utils/helpers/numbers';
import { RowDropdownBtn } from '../utils/RowDropdownBtn';
import type { ColorGenerator } from '../utils/services/ColorGenerator';
import type { SimplifiedTag, TagModalProps } from './data';
import { TagBullet } from './helpers/TagBullet';
@ -24,7 +24,6 @@ export const TagsTableRow = (
) => ({ tag, selectedServer }: TagsTableRowProps) => {
const [isDeleteModalOpen, toggleDelete] = useToggle();
const [isEditModalOpen, toggleEdit] = useToggle();
const [isDropdownOpen, toggleDropdown] = useToggle();
const serverId = getServerId(selectedServer);
return (
@ -43,14 +42,14 @@ export const TagsTableRow = (
</Link>
</td>
<td className="responsive-table__cell text-lg-end">
<DropdownBtnMenu toggle={toggleDropdown} isOpen={isDropdownOpen}>
<RowDropdownBtn>
<DropdownItem onClick={toggleEdit}>
<FontAwesomeIcon icon={editIcon} fixedWidth className="me-1" /> Edit
</DropdownItem>
<DropdownItem onClick={toggleDelete}>
<FontAwesomeIcon icon={deleteIcon} fixedWidth className="me-1" /> Delete
</DropdownItem>
</DropdownBtnMenu>
</RowDropdownBtn>
</td>
<EditTagModal tag={tag.tag} toggle={toggleEdit} isOpen={isEditModalOpen} />

View file

@ -2,13 +2,20 @@
@import '../utils/mixins/vertical-align';
.dropdown-btn__toggle.dropdown-btn__toggle {
text-align: left;
}
.dropdown-btn__toggle.dropdown-btn__toggle--with-caret {
padding-right: 1.75rem;
}
.dropdown-btn__toggle.dropdown-btn__toggle,
.dropdown-btn__toggle.dropdown-btn__toggle:not(:disabled):not(.disabled).active,
.dropdown-btn__toggle.dropdown-btn__toggle:not(:disabled):not(.disabled):active,
.dropdown-btn__toggle.dropdown-btn__toggle:not(:disabled):not(.disabled):focus,
.dropdown-btn__toggle.dropdown-btn__toggle:not(:disabled):not(.disabled):hover,
.show > .dropdown-btn__toggle.dropdown-btn__toggle.dropdown-toggle {
text-align: left;
color: var(--input-text-color);
background-color: var(--primary-color);
border-color: var(--input-border-color);

View file

@ -1,28 +1,45 @@
import type { FC, PropsWithChildren } from 'react';
import classNames from 'classnames';
import type { FC, PropsWithChildren, ReactNode } from 'react';
import { Dropdown, DropdownMenu, DropdownToggle } from 'reactstrap';
import type { DropdownToggleProps } from 'reactstrap/types/lib/DropdownToggle';
import { useToggle } from './helpers/hooks';
import './DropdownBtn.scss';
export type DropdownBtnProps = PropsWithChildren<{
text: string;
disabled?: boolean;
export type DropdownBtnProps = PropsWithChildren<Omit<DropdownToggleProps, 'caret' | 'size' | 'outline'> & {
text: ReactNode;
noCaret?: boolean;
className?: string;
dropdownClassName?: string;
right?: boolean;
inline?: boolean;
minWidth?: number;
size?: 'sm' | 'md' | 'lg';
}>;
export const DropdownBtn: FC<DropdownBtnProps> = (
{ text, disabled = false, className = '', children, dropdownClassName, right = false, minWidth },
) => {
export const DropdownBtn: FC<DropdownBtnProps> = ({
text,
disabled = false,
className,
children,
dropdownClassName,
noCaret,
end = false,
minWidth,
inline,
size,
}) => {
const [isOpen, toggle] = useToggle();
const toggleClasses = `dropdown-btn__toggle btn-block ${className}`;
const style = { minWidth: minWidth && `${minWidth}px` };
const toggleClasses = classNames('dropdown-btn__toggle', className, {
'btn-block': !inline,
'dropdown-btn__toggle--with-caret': !noCaret,
});
const menuStyle = { minWidth: minWidth && `${minWidth}px` };
return (
<Dropdown isOpen={isOpen} toggle={toggle} disabled={disabled} className={dropdownClassName}>
<DropdownToggle caret className={toggleClasses} color="primary">{text}</DropdownToggle>
<DropdownMenu className="w-100" end={right} style={style}>{children}</DropdownMenu>
<DropdownToggle size={size} caret={!noCaret} className={toggleClasses} color="primary">
{text}
</DropdownToggle>
<DropdownMenu className="w-100" end={end} style={menuStyle}>{children}</DropdownMenu>
</Dropdown>
);
};

View file

@ -1,3 +0,0 @@
.dropdown-btn-menu__dropdown-toggle:after {
display: none !important;
}

View file

@ -1,20 +0,0 @@
import { faEllipsisV as menuIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import type { FC, PropsWithChildren } from 'react';
import { ButtonDropdown, DropdownMenu, DropdownToggle } from 'reactstrap';
import './DropdownBtnMenu.scss';
export type DropdownBtnMenuProps = PropsWithChildren<{
isOpen: boolean;
toggle: () => void;
right?: boolean;
}>;
export const DropdownBtnMenu: FC<DropdownBtnMenuProps> = ({ isOpen, toggle, children, right = true }) => (
<ButtonDropdown toggle={toggle} isOpen={isOpen}>
<DropdownToggle size="sm" caret outline className="dropdown-btn-menu__dropdown-toggle">
&nbsp;<FontAwesomeIcon icon={menuIcon} />&nbsp;
</DropdownToggle>
<DropdownMenu end={right}>{children}</DropdownMenu>
</ButtonDropdown>
);

View file

@ -5,10 +5,10 @@ import type { ButtonProps } from 'reactstrap';
import { Button } from 'reactstrap';
import { prettify } from './helpers/numbers';
interface ExportBtnProps extends Omit<ButtonProps, 'outline' | 'color' | 'disabled'> {
type ExportBtnProps = Omit<ButtonProps, 'outline' | 'color' | 'disabled'> & {
amount?: number;
loading?: boolean;
}
};
export const ExportBtn: FC<ExportBtnProps> = ({ amount = 0, loading = false, ...rest }) => (
<Button {...rest} outline color="primary" disabled={loading}>

View file

@ -0,0 +1,21 @@
import { faEllipsisV as menuIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import type { FC, PropsWithChildren } from 'react';
import { DropdownBtn } from './DropdownBtn';
export type DropdownBtnMenuProps = PropsWithChildren<{
minWidth?: number;
}>;
export const RowDropdownBtn: FC<DropdownBtnMenuProps> = ({ children, minWidth }) => (
<DropdownBtn
text={<FontAwesomeIcon className="px-1" icon={menuIcon} />}
size="sm"
minWidth={minWidth}
end
noCaret
inline
>
{children}
</DropdownBtn>
);

View file

@ -22,7 +22,7 @@ export const VisitsFilterDropdown = (
const onBotsClick = () => onChange({ ...selected, excludeBots: !selected?.excludeBots });
return (
<DropdownBtn text="Filters" dropdownClassName={className} className="me-3" right minWidth={250}>
<DropdownBtn text="Filters" dropdownClassName={className} inline end minWidth={250}>
<DropdownItem header>Bots:</DropdownItem>
<DropdownItem active={excludeBots} onClick={onBotsClick}>Exclude potential bots</DropdownItem>

View file

@ -26,9 +26,8 @@ describe('<DomainSelector />', () => {
const btn = screen.getByRole('button', { name: expectedText });
expect(screen.queryByPlaceholderText('Domain')).not.toBeInTheDocument();
expect(btn).toHaveAttribute(
'class',
`dropdown-btn__toggle btn-block ${expectedClassName} dropdown-toggle btn btn-primary`,
expect(btn).toHaveClass(
`dropdown-btn__toggle ${expectedClassName} btn-block dropdown-btn__toggle--with-caret dropdown-toggle btn btn-primary`,
);
await user.click(btn);

View file

@ -1,42 +0,0 @@
import { screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import type { DropdownBtnMenuProps } from '../../src/utils/DropdownBtnMenu';
import { DropdownBtnMenu } from '../../src/utils/DropdownBtnMenu';
import { renderWithEvents } from '../__helpers__/setUpTest';
describe('<DropdownBtnMenu />', () => {
const setUp = (props: Partial<DropdownBtnMenuProps> = {}) => renderWithEvents(
<DropdownBtnMenu {...fromPartial<DropdownBtnMenuProps>({ toggle: jest.fn(), ...props })}>
the children
</DropdownBtnMenu>,
);
it('renders expected components', () => {
setUp();
const toggle = screen.getByRole('button');
expect(toggle).toBeInTheDocument();
expect(toggle).toHaveClass('btn-sm');
expect(toggle).toHaveClass('dropdown-btn-menu__dropdown-toggle');
expect(screen.getByRole('img', { hidden: true })).toBeInTheDocument();
});
it('renders expected children', () => {
setUp();
expect(screen.getByText('the children')).toBeInTheDocument();
});
it.each([
[undefined, true],
[true, true],
[false, false],
])('renders menu to the end when expected', (right, expectedEnd) => {
setUp({ right });
if (expectedEnd) {
expect(screen.getByRole('menu', { hidden: true })).toHaveClass('dropdown-menu-end');
} else {
expect(screen.getByRole('menu', { hidden: true })).not.toHaveClass('dropdown-menu-end');
}
});
});

View file

@ -0,0 +1,28 @@
import { screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import type { DropdownBtnMenuProps } from '../../src/utils/RowDropdownBtn';
import { RowDropdownBtn } from '../../src/utils/RowDropdownBtn';
import { renderWithEvents } from '../__helpers__/setUpTest';
describe('<RowDropdownBtn />', () => {
const setUp = (props: Partial<DropdownBtnMenuProps> = {}) => renderWithEvents(
<RowDropdownBtn {...fromPartial<DropdownBtnMenuProps>({ ...props })}>
the children
</RowDropdownBtn>,
);
it('renders expected components', () => {
setUp();
const toggle = screen.getByRole('button');
expect(toggle).toBeInTheDocument();
expect(toggle).toHaveClass('btn-sm');
expect(toggle).toHaveClass('dropdown-btn__toggle');
expect(screen.getByRole('img', { hidden: true })).toBeInTheDocument();
});
it('renders expected children', () => {
setUp();
expect(screen.getByText('the children')).toBeInTheDocument();
});
});