Added new setting to determine default display mode for tags

This commit is contained in:
Alejandro Celaya 2021-09-25 08:20:56 +02:00
parent 01f6f11ee2
commit 1da7119c5c
9 changed files with 84 additions and 28 deletions

View file

@ -1,9 +1,12 @@
import { FC } from 'react'; import { FC } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSun, faMoon } from '@fortawesome/free-solid-svg-icons'; import { faSun, faMoon } from '@fortawesome/free-solid-svg-icons';
import { FormGroup } from 'reactstrap';
import { SimpleCard } from '../utils/SimpleCard'; import { SimpleCard } from '../utils/SimpleCard';
import ToggleSwitch from '../utils/ToggleSwitch'; import ToggleSwitch from '../utils/ToggleSwitch';
import { changeThemeInMarkup, Theme } from '../utils/theme'; import { changeThemeInMarkup, Theme } from '../utils/theme';
import { TagsModeDropdown } from '../tags/TagsModeDropdown';
import { capitalize } from '../utils/utils';
import { Settings, UiSettings } from './reducers/settings'; import { Settings, UiSettings } from './reducers/settings';
import './UserInterface.scss'; import './UserInterface.scss';
@ -14,17 +17,28 @@ interface UserInterfaceProps {
export const UserInterface: FC<UserInterfaceProps> = ({ settings: { ui }, setUiSettings }) => ( export const UserInterface: FC<UserInterfaceProps> = ({ settings: { ui }, setUiSettings }) => (
<SimpleCard title="User interface" className="h-100"> <SimpleCard title="User interface" className="h-100">
<FontAwesomeIcon icon={ui?.theme === 'dark' ? faMoon : faSun} className="user-interface__theme-icon" /> <FormGroup>
<ToggleSwitch <FontAwesomeIcon icon={ui?.theme === 'dark' ? faMoon : faSun} className="user-interface__theme-icon" />
checked={ui?.theme === 'dark'} <ToggleSwitch
onChange={(useDarkTheme) => { checked={ui?.theme === 'dark'}
const theme: Theme = useDarkTheme ? 'dark' : 'light'; onChange={(useDarkTheme) => {
const theme: Theme = useDarkTheme ? 'dark' : 'light';
setUiSettings({ theme }); setUiSettings({ ...ui, theme });
changeThemeInMarkup(theme); changeThemeInMarkup(theme);
}} }}
> >
Use dark theme. Use dark theme.
</ToggleSwitch> </ToggleSwitch>
</FormGroup>
<FormGroup className="mb-0">
<label>Default display mode when managing tags:</label>
<TagsModeDropdown
mode={ui?.tagsMode ?? 'cards'}
renderTitle={(tagsMode) => capitalize(tagsMode)}
onChange={(tagsMode) => setUiSettings({ ...ui ?? { theme: 'light' }, tagsMode })}
/>
<small className="form-text text-muted">Tags will be displayed as <b>{ui?.tagsMode ?? 'cards'}</b>.</small>
</FormGroup>
</SimpleCard> </SimpleCard>
); );

View file

@ -24,8 +24,11 @@ export interface ShortUrlCreationSettings {
tagFilteringMode?: TagFilteringMode; tagFilteringMode?: TagFilteringMode;
} }
export type TagsMode = 'cards' | 'list';
export interface UiSettings { export interface UiSettings {
theme: Theme; theme: Theme;
tagsMode?: TagsMode;
} }
export interface VisitsSettings { export interface VisitsSettings {

View file

@ -7,21 +7,23 @@ import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
import { Result } from '../utils/Result'; import { Result } from '../utils/Result';
import { ShlinkApiError } from '../api/ShlinkApiError'; import { ShlinkApiError } from '../api/ShlinkApiError';
import { Topics } from '../mercure/helpers/Topics'; import { Topics } from '../mercure/helpers/Topics';
import { Settings, TagsMode } from '../settings/reducers/settings';
import { TagsList as TagsListState } from './reducers/tagsList'; import { TagsList as TagsListState } from './reducers/tagsList';
import { TagsListChildrenProps } from './data/TagsListChildrenProps'; import { TagsListChildrenProps } from './data/TagsListChildrenProps';
import { TagsMode, TagsModeDropdown } from './TagsModeDropdown'; import { TagsModeDropdown } from './TagsModeDropdown';
export interface TagsListProps { export interface TagsListProps {
filterTags: (searchTerm: string) => void; filterTags: (searchTerm: string) => void;
forceListTags: Function; forceListTags: Function;
tagsList: TagsListState; tagsList: TagsListState;
selectedServer: SelectedServer; selectedServer: SelectedServer;
settings: Settings;
} }
const TagsList = (TagsCards: FC<TagsListChildrenProps>, TagsTable: FC<TagsListChildrenProps>) => boundToMercureHub(( const TagsList = (TagsCards: FC<TagsListChildrenProps>, TagsTable: FC<TagsListChildrenProps>) => boundToMercureHub((
{ filterTags, forceListTags, tagsList, selectedServer }: TagsListProps, { filterTags, forceListTags, tagsList, selectedServer, settings }: TagsListProps,
) => { ) => {
const [ mode, setMode ] = useState<TagsMode>('cards'); const [ mode, setMode ] = useState<TagsMode>(settings.ui?.tagsMode ?? 'cards');
useEffect(() => { useEffect(() => {
forceListTags(); forceListTags();

View file

@ -3,16 +3,16 @@ import { DropdownItem } from 'reactstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faBars as listIcon, faThLarge as cardsIcon } from '@fortawesome/free-solid-svg-icons'; import { faBars as listIcon, faThLarge as cardsIcon } from '@fortawesome/free-solid-svg-icons';
import { DropdownBtn } from '../utils/DropdownBtn'; import { DropdownBtn } from '../utils/DropdownBtn';
import { TagsMode } from '../settings/reducers/settings';
interface TagsModeDropdownProps { interface TagsModeDropdownProps {
mode: TagsMode; mode: TagsMode;
onChange: (newMode: TagsMode) => void; onChange: (newMode: TagsMode) => void;
renderTitle?: (mode: TagsMode) => string;
} }
export type TagsMode = 'cards' | 'list'; export const TagsModeDropdown: FC<TagsModeDropdownProps> = ({ mode, onChange, renderTitle }) => (
<DropdownBtn text={renderTitle?.(mode) ?? `Display mode: ${mode}`}>
export const TagsModeDropdown: FC<TagsModeDropdownProps> = ({ mode, onChange }) => (
<DropdownBtn text={`Display mode: ${mode}`}>
<DropdownItem outline active={mode === 'cards'} onClick={() => onChange('cards')}> <DropdownItem outline active={mode === 'cards'} onClick={() => onChange('cards')}>
<FontAwesomeIcon icon={cardsIcon} fixedWidth className="mr-1" /> Cards <FontAwesomeIcon icon={cardsIcon} fixedWidth className="mr-1" /> Cards
</DropdownItem> </DropdownItem>

View file

@ -38,7 +38,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
bottle.serviceFactory('TagsList', TagsList, 'TagsCards', 'TagsTable'); bottle.serviceFactory('TagsList', TagsList, 'TagsCards', 'TagsTable');
bottle.decorator('TagsList', connect( bottle.decorator('TagsList', connect(
[ 'tagsList', 'selectedServer', 'mercureInfo' ], [ 'tagsList', 'selectedServer', 'mercureInfo', 'settings' ],
[ 'forceListTags', 'filterTags', 'createNewVisits', 'loadMercureInfo' ], [ 'forceListTags', 'filterTags', 'createNewVisits', 'loadMercureInfo' ],
)); ));

View file

@ -45,3 +45,5 @@ export type RecursivePartial<T> = {
}; };
export const nonEmptyValueOrNull = <T>(value: T): T | null => isEmpty(value) ? null : value; export const nonEmptyValueOrNull = <T>(value: T): T | null => isEmpty(value) ? null : value;
export const capitalize = <T extends string>(value: T): string => `${value.charAt(0).toUpperCase()}${value.slice(1)}`;

View file

@ -2,21 +2,17 @@ import { shallow, ShallowWrapper } from 'enzyme';
import { Mock } from 'ts-mockery'; import { Mock } from 'ts-mockery';
import { faMoon, faSun } from '@fortawesome/free-solid-svg-icons'; import { faMoon, faSun } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Settings, UiSettings } from '../../src/settings/reducers/settings'; import { Settings, TagsMode, UiSettings } from '../../src/settings/reducers/settings';
import { UserInterface } from '../../src/settings/UserInterface'; import { UserInterface } from '../../src/settings/UserInterface';
import ToggleSwitch from '../../src/utils/ToggleSwitch'; import ToggleSwitch from '../../src/utils/ToggleSwitch';
import { Theme } from '../../src/utils/theme'; import { Theme } from '../../src/utils/theme';
import { TagsModeDropdown } from '../../src/tags/TagsModeDropdown';
describe('<UserInterface />', () => { describe('<UserInterface />', () => {
let wrapper: ShallowWrapper; let wrapper: ShallowWrapper;
const setUiSettings = jest.fn(); const setUiSettings = jest.fn();
const createWrapper = (ui?: UiSettings) => { const createWrapper = (ui?: UiSettings) => {
wrapper = shallow( wrapper = shallow(<UserInterface settings={Mock.of<Settings>({ ui })} setUiSettings={setUiSettings} />);
<UserInterface
settings={Mock.of<Settings>({ ui })}
setUiSettings={setUiSettings}
/>,
);
return wrapper; return wrapper;
}; };
@ -49,7 +45,7 @@ describe('<UserInterface />', () => {
it.each([ it.each([
[ true, 'dark' ], [ true, 'dark' ],
[ false, 'light' ], [ false, 'light' ],
])('invokes setUiSettings when toggle value changes', (checked, theme) => { ])('invokes setUiSettings when theme toggle value changes', (checked, theme) => {
const wrapper = createWrapper(); const wrapper = createWrapper();
const toggle = wrapper.find(ToggleSwitch); const toggle = wrapper.find(ToggleSwitch);
@ -57,4 +53,30 @@ describe('<UserInterface />', () => {
toggle.simulate('change', checked); toggle.simulate('change', checked);
expect(setUiSettings).toHaveBeenCalledWith({ theme }); expect(setUiSettings).toHaveBeenCalledWith({ theme });
}); });
it.each([
[ undefined, 'cards' ],
[{ theme: 'light' as Theme }, 'cards' ],
[{ theme: 'light' as Theme, tagsMode: 'cards' as TagsMode }, 'cards' ],
[{ theme: 'light' as Theme, tagsMode: 'list' as TagsMode }, 'list' ],
])('shows expected tags displaying mode', (ui, expectedMode) => {
const wrapper = createWrapper(ui);
const dropdown = wrapper.find(TagsModeDropdown);
const small = wrapper.find('small');
expect(dropdown.prop('mode')).toEqual(expectedMode);
expect(small.html()).toContain(`Tags will be displayed as <b>${expectedMode}</b>.`);
});
it.each([
[ 'cards' as TagsMode ],
[ 'list' as TagsMode ],
])('invokes setUiSettings when tags mode changes', (tagsMode) => {
const wrapper = createWrapper();
const dropdown = wrapper.find(TagsModeDropdown);
expect(setUiSettings).not.toHaveBeenCalled();
dropdown.simulate('change', tagsMode);
expect(setUiSettings).toHaveBeenCalledWith({ theme: 'light', tagsMode });
});
}); });

View file

@ -8,6 +8,7 @@ import { MercureBoundProps } from '../../src/mercure/helpers/boundToMercureHub';
import { Result } from '../../src/utils/Result'; import { Result } from '../../src/utils/Result';
import { TagsModeDropdown } from '../../src/tags/TagsModeDropdown'; import { TagsModeDropdown } from '../../src/tags/TagsModeDropdown';
import SearchField from '../../src/utils/SearchField'; import SearchField from '../../src/utils/SearchField';
import { Settings } from '../../src/settings/reducers/settings';
describe('<TagsList />', () => { describe('<TagsList />', () => {
let wrapper: ShallowWrapper; let wrapper: ShallowWrapper;
@ -23,6 +24,7 @@ describe('<TagsList />', () => {
forceListTags={identity} forceListTags={identity}
filterTags={filterTags} filterTags={filterTags}
tagsList={Mock.of<TagsList>(tagsList)} tagsList={Mock.of<TagsList>(tagsList)}
settings={Mock.all<Settings>()}
/>, />,
).dive(); // Dive is needed as this component is wrapped in a HOC ).dive(); // Dive is needed as this component is wrapped in a HOC

View file

@ -1,4 +1,4 @@
import { determineOrderDir, nonEmptyValueOrNull, rangeOf } from '../../src/utils/utils'; import { capitalize, determineOrderDir, nonEmptyValueOrNull, rangeOf } from '../../src/utils/utils';
describe('utils', () => { describe('utils', () => {
describe('determineOrderDir', () => { describe('determineOrderDir', () => {
@ -60,4 +60,15 @@ describe('utils', () => {
expect(nonEmptyValueOrNull(value)).toEqual(expected); expect(nonEmptyValueOrNull(value)).toEqual(expected);
}); });
}); });
describe('capitalize', () => {
it.each([
[ 'foo', 'Foo' ],
[ 'BAR', 'BAR' ],
[ 'bAZ', 'BAZ' ],
[ 'with spaces', 'With spaces' ],
])('sets first letter in uppercase', (value, expectedResult) => {
expect(capitalize(value)).toEqual(expectedResult);
});
});
}); });