diff --git a/CHANGELOG.md b/CHANGELOG.md index f84c1824..4ef4cceb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ 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 +* *Nothing* + +### Changed +* *Nothing* + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#364](https://github.com/shlinkio/shlink-web-client/issues/364) Fixed all dropdowns so that they are consistently styled. + + ## [3.0.0] - 2020-12-22 ### Added * [#340](https://github.com/shlinkio/shlink-web-client/issues/340) Added new "overview" page, showing basic information of the active server. diff --git a/src/domains/DomainSelector.scss b/src/domains/DomainSelector.scss index c9ab0ba0..89e02433 100644 --- a/src/domains/DomainSelector.scss +++ b/src/domains/DomainSelector.scss @@ -1,31 +1,12 @@ @import '../utils/mixins/vertical-align'; -.domains-dropdown__toggle-btn.domains-dropdown__toggle-btn, -.domains-dropdown__toggle-btn.domains-dropdown__toggle-btn:hover, -.domains-dropdown__toggle-btn.domains-dropdown__toggle-btn:active { - text-align: left; - color: #6c757d; - border-color: #ced4da; - background-color: white; -} - .domains-dropdown__toggle-btn--active.domains-dropdown__toggle-btn--active, .domains-dropdown__toggle-btn--active.domains-dropdown__toggle-btn--active:hover, .domains-dropdown__toggle-btn--active.domains-dropdown__toggle-btn--active:active { - color: #495057; + color: #495057 !important; } .domains-dropdown__back-btn.domains-dropdown__back-btn, .domains-dropdown__back-btn.domains-dropdown__back-btn:hover { border-color: #ced4da; } - -.domains-dropdown__toggle-btn.domains-dropdown__toggle-btn::after { - right: .75rem; - - @include vertical-align(); -} - -.domains-dropdown__menu { - width: 100%; -} diff --git a/src/domains/DomainSelector.tsx b/src/domains/DomainSelector.tsx index 510b39f9..c843f409 100644 --- a/src/domains/DomainSelector.tsx +++ b/src/domains/DomainSelector.tsx @@ -1,20 +1,10 @@ import { useEffect } from 'react'; -import { - Button, - Dropdown, - DropdownItem, - DropdownMenu, - DropdownToggle, - Input, - InputGroup, - InputGroupAddon, - UncontrolledTooltip, -} from 'reactstrap'; +import { Button, DropdownItem, Input, InputGroup, InputGroupAddon, UncontrolledTooltip } from 'reactstrap'; import { InputProps } from 'reactstrap/lib/Input'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faUndo } from '@fortawesome/free-solid-svg-icons'; import { isEmpty, pipe } from 'ramda'; -import classNames from 'classnames'; +import { DropdownBtn } from '../utils/DropdownBtn'; import { useToggle } from '../utils/helpers/hooks'; import { DomainsList } from './reducers/domainsList'; import './DomainSelector.scss'; @@ -31,7 +21,6 @@ interface DomainSelectorConnectProps extends DomainSelectorProps { export const DomainSelector = ({ listDomains, value, domainsList, onChange }: DomainSelectorConnectProps) => { const [ inputDisplayed,, showInput, hideInput ] = useToggle(); - const [ isDropdownOpen, toggleDropdown ] = useToggle(); const { domains } = domainsList; const valueIsEmpty = isEmpty(value); const unselectDomain = () => onChange(''); @@ -63,33 +52,24 @@ export const DomainSelector = ({ listDomains, value, domainsList, onChange }: Do ) : ( - - - {valueIsEmpty && <>Domain} - {!valueIsEmpty && <>Domain: {value}} - - - {domains.map(({ domain, isDefault }) => ( - onChange(domain)} - > - {domain} - {isDefault && default} - - ))} - - - New domain + + {domains.map(({ domain, isDefault }) => ( + onChange(domain)} + > + {domain} + {isDefault && default} - - + ))} + + + New domain + + ); }; diff --git a/src/short-urls/ShortUrls.tsx b/src/short-urls/ShortUrls.tsx index d6dea6bc..13814837 100644 --- a/src/short-urls/ShortUrls.tsx +++ b/src/short-urls/ShortUrls.tsx @@ -1,12 +1,9 @@ import { FC, useEffect, useState } from 'react'; -import { Card } from 'reactstrap'; -import Paginator from './Paginator'; import { ShortUrlsListProps } from './ShortUrlsList'; const ShortUrls = (SearchBar: FC, ShortUrlsList: FC) => (props: ShortUrlsListProps) => { - const { match, shortUrlsList } = props; + const { match } = props; const { page = '1', serverId = '' } = match?.params ?? {}; - const { pagination } = shortUrlsList?.shortUrls ?? {}; const [ urlsListKey, setUrlsListKey ] = useState(`${serverId}_${page}`); // Using a key on a component makes react to create a new instance every time the key changes @@ -18,10 +15,7 @@ const ShortUrls = (SearchBar: FC, ShortUrlsList: FC) => (pro return ( <>
- - - - + ); }; diff --git a/src/short-urls/ShortUrlsList.tsx b/src/short-urls/ShortUrlsList.tsx index 86397f73..c92ec4c1 100644 --- a/src/short-urls/ShortUrlsList.tsx +++ b/src/short-urls/ShortUrlsList.tsx @@ -3,14 +3,16 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { head, keys, values } from 'ramda'; import { FC, useEffect, useState } from 'react'; import { RouteComponentProps } from 'react-router'; +import { Card } from 'reactstrap'; import SortingDropdown from '../utils/SortingDropdown'; import { determineOrderDir, OrderDir } from '../utils/utils'; -import { SelectedServer } from '../servers/data'; +import { isReachableServer, SelectedServer } from '../servers/data'; import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub'; import { parseQuery } from '../utils/helpers/query'; import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList'; import { OrderableFields, ShortUrlsListParams, SORTABLE_FIELDS } from './reducers/shortUrlsListParams'; import { ShortUrlsTableProps } from './ShortUrlsTable'; +import Paginator from './Paginator'; import './ShortUrlsList.scss'; interface RouteParams { @@ -40,6 +42,7 @@ const ShortUrlsList = (ShortUrlsTable: FC) => boundToMercur orderField: orderBy && (head(keys(orderBy)) as OrderableFields), orderDir: orderBy && head(values(orderBy)), }); + const { pagination } = shortUrlsList?.shortUrls ?? {}; const refreshList = (extraParams: ShortUrlsListParams) => listShortUrls({ ...shortUrlsListParams, ...extraParams }); const handleOrderBy = (orderField?: OrderableFields, orderDir?: OrderDir) => { setOrder({ orderField, orderDir }); @@ -83,13 +86,16 @@ const ShortUrlsList = (ShortUrlsTable: FC) => boundToMercur onChange={handleOrderBy} /> - refreshList({ tags: [ ...shortUrlsListParams.tags ?? [], tag ] })} - /> + + refreshList({ tags: [ ...shortUrlsListParams.tags ?? [], tag ] })} + /> + + ); }, () => 'https://shlink.io/new-visit'); diff --git a/src/utils/DropdownBtn.scss b/src/utils/DropdownBtn.scss new file mode 100644 index 00000000..6cfae1dc --- /dev/null +++ b/src/utils/DropdownBtn.scss @@ -0,0 +1,19 @@ +@import '../utils/mixins/vertical-align'; + +.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 { + color: #6c757d; + background-color: white; + text-align: left; + border-color: rgba(0, 0, 0, .125); +} + +.dropdown-btn__toggle.dropdown-btn__toggle:after { + @include vertical-align(); + + right: .75rem; +} diff --git a/src/utils/DropdownBtn.tsx b/src/utils/DropdownBtn.tsx new file mode 100644 index 00000000..d1cf6f6b --- /dev/null +++ b/src/utils/DropdownBtn.tsx @@ -0,0 +1,22 @@ +import { FC } from 'react'; +import { Dropdown, DropdownMenu, DropdownToggle } from 'reactstrap'; +import { useToggle } from './helpers/hooks'; +import './DropdownBtn.scss'; + +export interface DropdownBtnProps { + text: string; + disabled?: boolean; + className?: string; +} + +export const DropdownBtn: FC = ({ text, disabled = false, className = '', children }) => { + const [ isOpen, toggle ] = useToggle(); + const toggleClasses = `dropdown-btn__toggle btn-block ${className}`; + + return ( + + {text} + {children} + + ); +}; diff --git a/src/utils/SortingDropdown.tsx b/src/utils/SortingDropdown.tsx index 76fd1ca4..d9e06ec3 100644 --- a/src/utils/SortingDropdown.tsx +++ b/src/utils/SortingDropdown.tsx @@ -28,10 +28,12 @@ export default function SortingDropdown( - Order by + {!isButton && <>Order by} + {isButton && !orderField && <>Order by...} + {isButton && orderField && `Order by: "${items[orderField]}" - "${orderDir ?? 'DESC'}"`} .date-range-selector__btn.date-range-selector__btn.dropdown-toggle { - color: #6c757d; - background-color: white; - text-align: left; - border-color: rgba(0, 0, 0, .125); -} - -.date-range-selector__btn.date-range-selector__btn:after { - @include vertical-align(); - - right: .75rem; -} diff --git a/src/utils/dates/DateRangeSelector.tsx b/src/utils/dates/DateRangeSelector.tsx index 7fbdc7bf..1b680f90 100644 --- a/src/utils/dates/DateRangeSelector.tsx +++ b/src/utils/dates/DateRangeSelector.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; -import { Dropdown, DropdownItem, DropdownMenu, DropdownToggle } from 'reactstrap'; -import { useToggle } from '../helpers/hooks'; +import { DropdownItem } from 'reactstrap'; +import { DropdownBtn } from '../DropdownBtn'; import { DateInterval, DateRange, @@ -10,7 +10,6 @@ import { rangeIsInterval, } from './types'; import DateRangeRow from './DateRangeRow'; -import './DateRangeSelector.scss'; export interface DateRangeSelectorProps { initialDateRange?: DateInterval | DateRange; @@ -20,9 +19,8 @@ export interface DateRangeSelectorProps { } export const DateRangeSelector = ( - { onDatesChange, initialDateRange, defaultText, disabled = false }: DateRangeSelectorProps, + { onDatesChange, initialDateRange, defaultText, disabled }: DateRangeSelectorProps, ) => { - const [ isOpen, toggle ] = useToggle(); const [ activeInterval, setActiveInterval ] = useState( rangeIsInterval(initialDateRange) ? initialDateRange : undefined, ); @@ -41,35 +39,30 @@ export const DateRangeSelector = ( }; return ( - - - {rangeOrIntervalToString(activeInterval ?? activeDateRange) ?? defaultText} - - - - {defaultText} - - - {([ 'today', 'yesterday', 'last7Days', 'last30Days', 'last90Days', 'last180days', 'last365Days' ] as DateInterval[]).map( - (interval) => ( - - {rangeOrIntervalToString(interval)} - - ), - )} - - Custom: - - updateDateRange({ ...activeDateRange, startDate })} - onEndDateChange={(endDate) => updateDateRange({ ...activeDateRange, endDate })} - /> - - - + + + {defaultText} + + + {([ 'today', 'yesterday', 'last7Days', 'last30Days', 'last90Days', 'last180days', 'last365Days' ] as DateInterval[]).map( + (interval) => ( + + {rangeOrIntervalToString(interval)} + + ), + )} + + Custom: + + updateDateRange({ ...activeDateRange, startDate })} + onEndDateChange={(endDate) => updateDateRange({ ...activeDateRange, endDate })} + /> + + ); }; diff --git a/test/domains/DomainSelector.test.tsx b/test/domains/DomainSelector.test.tsx index 0c1ab22c..ec9ae9d6 100644 --- a/test/domains/DomainSelector.test.tsx +++ b/test/domains/DomainSelector.test.tsx @@ -1,9 +1,10 @@ import { shallow, ShallowWrapper } from 'enzyme'; import { Mock } from 'ts-mockery'; -import { DropdownItem, DropdownMenu, InputGroup } from 'reactstrap'; +import { DropdownItem, InputGroup } from 'reactstrap'; import { DomainSelector } from '../../src/domains/DomainSelector'; import { DomainsList } from '../../src/domains/reducers/domainsList'; import { ShlinkDomain } from '../../src/api/types'; +import { DropdownBtn } from '../../src/utils/DropdownBtn'; describe('', () => { let wrapper: ShallowWrapper; @@ -23,7 +24,7 @@ describe('', () => { it('shows dropdown by default', () => { const input = wrapper.find(InputGroup); - const dropdown = wrapper.find(DropdownMenu); + const dropdown = wrapper.find(DropdownBtn); expect(input).toHaveLength(0); expect(dropdown).toHaveLength(1); @@ -33,10 +34,10 @@ describe('', () => { it('allows to toggle between dropdown and input', () => { wrapper.find(DropdownItem).last().simulate('click'); expect(wrapper.find(InputGroup)).toHaveLength(1); - expect(wrapper.find(DropdownMenu)).toHaveLength(0); + expect(wrapper.find(DropdownBtn)).toHaveLength(0); wrapper.find('.domains-dropdown__back-btn').simulate('click'); expect(wrapper.find(InputGroup)).toHaveLength(0); - expect(wrapper.find(DropdownMenu)).toHaveLength(1); + expect(wrapper.find(DropdownBtn)).toHaveLength(1); }); }); diff --git a/test/short-urls/ShortUrls.test.tsx b/test/short-urls/ShortUrls.test.tsx index c47ba0ce..f4caed88 100644 --- a/test/short-urls/ShortUrls.test.tsx +++ b/test/short-urls/ShortUrls.test.tsx @@ -1,7 +1,6 @@ import { shallow, ShallowWrapper } from 'enzyme'; import { Mock } from 'ts-mockery'; import shortUrlsCreator from '../../src/short-urls/ShortUrls'; -import Paginator from '../../src/short-urls/Paginator'; import { ShortUrlsListProps } from '../../src/short-urls/ShortUrlsList'; describe('', () => { @@ -18,9 +17,8 @@ describe('', () => { }); afterEach(() => wrapper.unmount()); - it('wraps a SearchBar, ShortUrlsList as Paginator', () => { + it('wraps a SearchBar and ShortUrlsList', () => { expect(wrapper.find(SearchBar)).toHaveLength(1); expect(wrapper.find(ShortUrlsList)).toHaveLength(1); - expect(wrapper.find(Paginator)).toHaveLength(1); }); }); diff --git a/test/utils/DropdownBtn.test.tsx b/test/utils/DropdownBtn.test.tsx new file mode 100644 index 00000000..79722689 --- /dev/null +++ b/test/utils/DropdownBtn.test.tsx @@ -0,0 +1,41 @@ +import { shallow, ShallowWrapper } from 'enzyme'; +import { DropdownMenu, DropdownToggle } from 'reactstrap'; +import { PropsWithChildren } from 'react'; +import { DropdownBtn, DropdownBtnProps } from '../../src/utils/DropdownBtn'; + +describe('', () => { + let wrapper: ShallowWrapper; + const createWrapper = (props: PropsWithChildren) => { + wrapper = shallow(); + + return wrapper; + }; + + afterEach(() => wrapper?.unmount()); + + it.each([[ 'foo' ], [ 'bar' ], [ 'baz' ]])('displays provided text', (text) => { + const wrapper = createWrapper({ text }); + const toggle = wrapper.find(DropdownToggle); + + expect(toggle.html()).toContain(text); + }); + + it.each([[ 'foo' ], [ 'bar' ], [ 'baz' ]])('displays provided children', (children) => { + const wrapper = createWrapper({ text: '', children }); + const menu = wrapper.find(DropdownMenu); + + expect(menu.html()).toContain(children); + }); + + it.each([ + [ undefined, 'dropdown-btn__toggle btn-block' ], + [ '', 'dropdown-btn__toggle btn-block' ], + [ 'foo', 'dropdown-btn__toggle btn-block foo' ], + [ 'bar', 'dropdown-btn__toggle btn-block bar' ], + ])('includes provided classes', (className, expectedClasses) => { + const wrapper = createWrapper({ text: '', className }); + const toggle = wrapper.find(DropdownToggle); + + expect(toggle.prop('className')?.trim()).toEqual(expectedClasses); + }); +}); diff --git a/test/utils/SortingDropdown.test.tsx b/test/utils/SortingDropdown.test.tsx index c327a172..6b3f9682 100644 --- a/test/utils/SortingDropdown.test.tsx +++ b/test/utils/SortingDropdown.test.tsx @@ -1,9 +1,10 @@ import { shallow, ShallowWrapper } from 'enzyme'; -import { DropdownItem } from 'reactstrap'; +import { DropdownItem, DropdownToggle } from 'reactstrap'; import { identity, values } from 'ramda'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faSortAmountDown as caretDownIcon } from '@fortawesome/free-solid-svg-icons'; import SortingDropdown, { SortingDropdownProps } from '../../src/utils/SortingDropdown'; +import { OrderDir } from '../../src/utils/utils'; describe('', () => { let wrapper: ShallowWrapper; @@ -73,4 +74,23 @@ describe('', () => { expect(onChange).toHaveBeenCalledTimes(1); expect(onChange).toHaveBeenCalledWith('foo', 'DESC'); }); + + it.each([ + [{ isButton: false }, '>Order by<' ], + [{ isButton: true }, '>Order by...<' ], + [ + { isButton: true, orderField: 'foo', orderDir: 'ASC' as OrderDir }, + 'Order by: "Foo" - "ASC"', + ], + [ + { isButton: true, orderField: 'baz', orderDir: 'DESC' as OrderDir }, + 'Order by: "Hello World" - "DESC"', + ], + [{ isButton: true, orderField: 'baz' }, 'Order by: "Hello World" - "DESC"' ], + ])('displays expected text in toggle', (props, expectedText) => { + const wrapper = createWrapper(props); + const toggle = wrapper.find(DropdownToggle); + + expect(toggle.html()).toContain(expectedText); + }); });