Created new settings card for tags-related options

This commit is contained in:
Alejandro Celaya 2021-12-23 17:53:06 +01:00
parent e77508edcc
commit 5598fe0f53
14 changed files with 121 additions and 58 deletions

View file

@ -18,9 +18,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
### Changed
* [#534](https://github.com/shlinkio/shlink-web-client/pull/534) Updated axios.
* [#538](https://github.com/shlinkio/shlink-web-client/pull/538) Switched to the `<field>-<dir>` notation in `orderBy` param for short URLs list, in preparation for Shlink v3.0.0
* Fixed typo in identifier for "Last 180 days" interval.
If that was your default interval, you will see now "All visits" is selected instead. You will need to go to settings page and change it again to "Last 180 days".
### Deprecated
* *Nothing*

View file

@ -18,7 +18,8 @@ import { ConnectDecorator } from './types';
type LazyActionMap = Record<string, Function>;
const bottle = new Bottle();
const { container } = bottle;
export const { container } = bottle;
const lazyService = <T extends Function, K>(container: IContainer, serviceName: string) =>
(...args: any[]) => (container[serviceName] as T)(...args) as K;
@ -44,5 +45,3 @@ provideUtilsServices(bottle);
provideMercureServices(bottle);
provideSettingsServices(bottle, connect);
provideDomainsServices(bottle, connect);
export default container;

View file

@ -2,6 +2,8 @@ import ReduxThunk from 'redux-thunk';
import { applyMiddleware, compose, createStore } from 'redux';
import { save, load, RLSOptions } from 'redux-localstorage-simple';
import reducers from '../reducers';
import { migrateDeprecatedSettings } from '../settings/helpers';
import { ShlinkState } from './types';
const isProduction = process.env.NODE_ENV !== 'production';
const composeEnhancers: Function = !isProduction && (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
@ -12,9 +14,8 @@ const localStorageConfig: RLSOptions = {
namespaceSeparator: '.',
debounce: 300,
};
const preloadedState = migrateDeprecatedSettings(load(localStorageConfig) as ShlinkState);
const store = createStore(reducers, load(localStorageConfig), composeEnhancers(
export const store = createStore(reducers, preloadedState, composeEnhancers(
applyMiddleware(save(localStorageConfig), ReduxThunk),
));
export default store;

View file

@ -2,8 +2,8 @@ import { render } from 'react-dom';
import { Provider } from 'react-redux';
import { BrowserRouter } from 'react-router-dom';
import { homepage } from '../package.json';
import container from './container';
import store from './container/store';
import { container } from './container';
import { store } from './container/store';
import { fixLeafletIcons } from './utils/helpers/leaflet';
import { register as registerServiceWorker } from './serviceWorkerRegistration';
import 'react-datepicker/dist/react-datepicker.css';

View file

@ -7,7 +7,7 @@ const SettingsSections: FC<{ items: ReactNode[][] }> = ({ items }) => (
{items.map((child, index) => (
<Row key={index}>
{child.map((subChild, subIndex) => (
<div key={subIndex} className="col-lg-6 mb-3">
<div key={subIndex} className={`col-lg-${12 / child.length} mb-3`}>
{subChild}
</div>
))}
@ -16,12 +16,13 @@ const SettingsSections: FC<{ items: ReactNode[][] }> = ({ items }) => (
</>
);
const Settings = (RealTimeUpdates: FC, ShortUrlCreation: FC, UserInterface: FC, Visits: FC) => () => (
const Settings = (RealTimeUpdates: FC, ShortUrlCreation: FC, UserInterface: FC, Visits: FC, Tags: FC) => () => (
<NoMenuLayout>
<SettingsSections
items={[
[ <UserInterface />, <Visits /> ], // eslint-disable-line react/jsx-key
[ <UserInterface /> ], // eslint-disable-line react/jsx-key
[ <ShortUrlCreation />, <RealTimeUpdates /> ], // eslint-disable-line react/jsx-key
[ <Tags />, <Visits /> ], // eslint-disable-line react/jsx-key
]}
/>
</NoMenuLayout>

25
src/settings/Tags.tsx Normal file
View file

@ -0,0 +1,25 @@
import { FC } from 'react';
import { FormGroup } from 'reactstrap';
import { SimpleCard } from '../utils/SimpleCard';
import { TagsModeDropdown } from '../tags/TagsModeDropdown';
import { capitalize } from '../utils/utils';
import { Settings, TagsSettings } from './reducers/settings';
interface TagsProps {
settings: Settings;
setTagsSettings: (settings: TagsSettings) => void;
}
export const Tags: FC<TagsProps> = ({ settings: { tags }, setTagsSettings }) => (
<SimpleCard title="Tags">
<FormGroup className="mb-0">
<label>Default display mode when managing tags:</label>
<TagsModeDropdown
mode={tags?.defaultMode ?? 'cards'}
renderTitle={(tagsMode) => capitalize(tagsMode)}
onChange={(defaultMode) => setTagsSettings({ ...tags, defaultMode })}
/>
<small className="form-text text-muted">Tags will be displayed as <b>{tags?.defaultMode ?? 'cards'}</b>.</small>
</FormGroup>
</SimpleCard>
);

View file

@ -5,8 +5,6 @@ import { FormGroup } from 'reactstrap';
import { SimpleCard } from '../utils/SimpleCard';
import ToggleSwitch from '../utils/ToggleSwitch';
import { changeThemeInMarkup, Theme } from '../utils/theme';
import { TagsModeDropdown } from '../tags/TagsModeDropdown';
import { capitalize } from '../utils/utils';
import { Settings, UiSettings } from './reducers/settings';
import './UserInterface.scss';
@ -31,14 +29,5 @@ export const UserInterface: FC<UserInterfaceProps> = ({ settings: { ui }, setUiS
Use dark theme.
</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>
);

View file

@ -0,0 +1,17 @@
import { ShlinkState } from '../../container/types';
export const migrateDeprecatedSettings = (state: ShlinkState): ShlinkState => {
// The "last180Days" interval had a typo, with a lowercase d
if ((state.settings.visits?.defaultInterval as any) === 'last180days') {
state.settings.visits && (state.settings.visits.defaultInterval = 'last180Days');
}
// The "tags display mode" option has been moved from "ui" to "tags"
state.settings.tags = {
...state.settings.tags,
defaultMode: state.settings.tags?.defaultMode ?? (state.settings.ui as any)?.tagsMode,
};
state.settings.ui && delete (state.settings.ui as any).tagsMode;
return state;
};

View file

@ -4,6 +4,7 @@ import { buildReducer } from '../../utils/helpers/redux';
import { RecursivePartial } from '../../utils/utils';
import { Theme } from '../../utils/theme';
import { DateInterval } from '../../utils/dates/types';
import { TagsOrder } from '../../tags/data/TagsListChildrenProps';
export const SET_SETTINGS = 'shlink/realTimeUpdates/SET_SETTINGS';
@ -29,18 +30,23 @@ export type TagsMode = 'cards' | 'list';
export interface UiSettings {
theme: Theme;
tagsMode?: TagsMode;
}
export interface VisitsSettings {
defaultInterval: DateInterval;
}
export interface TagsSettings {
defaultOrdering?: TagsOrder;
defaultMode?: TagsMode;
}
export interface Settings {
realTimeUpdates: RealTimeUpdatesSettings;
shortUrlCreation?: ShortUrlCreationSettings;
ui?: UiSettings;
visits?: VisitsSettings;
tags?: TagsSettings;
}
const initialState: Settings = {
@ -90,3 +96,8 @@ export const setVisitsSettings = (settings: VisitsSettings): PartialSettingsActi
type: SET_SETTINGS,
visits: settings,
});
export const setTagsSettings = (settings: TagsSettings): PartialSettingsAction => ({
type: SET_SETTINGS,
tags: settings,
});

View file

@ -4,6 +4,7 @@ import Settings from '../Settings';
import {
setRealTimeUpdatesInterval,
setShortUrlCreationSettings,
setTagsSettings,
setUiSettings,
setVisitsSettings,
toggleRealTimeUpdates,
@ -13,10 +14,11 @@ import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServ
import { ShortUrlCreation } from '../ShortUrlCreation';
import { UserInterface } from '../UserInterface';
import { Visits } from '../Visits';
import { Tags } from '../Tags';
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Components
bottle.serviceFactory('Settings', Settings, 'RealTimeUpdates', 'ShortUrlCreation', 'UserInterface', 'Visits');
bottle.serviceFactory('Settings', Settings, 'RealTimeUpdates', 'ShortUrlCreation', 'UserInterface', 'Visits', 'Tags');
bottle.decorator('Settings', withoutSelectedServer);
bottle.decorator('Settings', connect(null, [ 'resetSelectedServer' ]));
@ -35,12 +37,16 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
bottle.serviceFactory('Visits', () => Visits);
bottle.decorator('Visits', connect([ 'settings' ], [ 'setVisitsSettings' ]));
bottle.serviceFactory('Tags', () => Tags);
bottle.decorator('Tags', connect([ 'settings' ], [ 'setTagsSettings' ]));
// Actions
bottle.serviceFactory('toggleRealTimeUpdates', () => toggleRealTimeUpdates);
bottle.serviceFactory('setRealTimeUpdatesInterval', () => setRealTimeUpdatesInterval);
bottle.serviceFactory('setShortUrlCreationSettings', () => setShortUrlCreationSettings);
bottle.serviceFactory('setUiSettings', () => setUiSettings);
bottle.serviceFactory('setVisitsSettings', () => setVisitsSettings);
bottle.serviceFactory('setTagsSettings', () => setTagsSettings);
};
export default provideServices;

View file

@ -28,7 +28,7 @@ export interface TagsListProps {
const TagsList = (TagsCards: FC<TagsListChildrenProps>, TagsTable: FC<TagsTableProps>) => boundToMercureHub((
{ filterTags, forceListTags, tagsList, selectedServer, settings }: TagsListProps,
) => {
const [ mode, setMode ] = useState<TagsMode>(settings.ui?.tagsMode ?? 'cards');
const [ mode, setMode ] = useState<TagsMode>(settings.tags?.defaultMode ?? 'cards');
const [ order, setOrder ] = useState<TagsOrder>({});
const resolveSortedTags = pipe(
() => tagsList.filteredTags.map((tag): NormalizedTag => ({

View file

@ -4,7 +4,7 @@ import NoMenuLayout from '../../src/common/NoMenuLayout';
describe('<Settings />', () => {
const Component = () => null;
const Settings = createSettings(Component, Component, Component, Component);
const Settings = createSettings(Component, Component, Component, Component, Component);
it('renders a no-menu layout with the expected settings sections', () => {
const wrapper = shallow(<Settings />);
@ -13,6 +13,6 @@ describe('<Settings />', () => {
expect(layout).toHaveLength(1);
expect(sections).toHaveLength(1);
expect((sections.prop('items') as any[]).flat()).toHaveLength(4);
expect((sections.prop('items') as any[]).flat()).toHaveLength(5);
});
});

View file

@ -0,0 +1,44 @@
import { shallow, ShallowWrapper } from 'enzyme';
import { Mock } from 'ts-mockery';
import { Settings, TagsMode, TagsSettings } from '../../src/settings/reducers/settings';
import { TagsModeDropdown } from '../../src/tags/TagsModeDropdown';
import { Tags } from '../../src/settings/Tags';
describe('<Tags />', () => {
let wrapper: ShallowWrapper;
const setTagsSettings = jest.fn();
const createWrapper = (tags?: TagsSettings) => {
wrapper = shallow(<Tags settings={Mock.of<Settings>({ tags })} setTagsSettings={setTagsSettings} />);
return wrapper;
};
afterEach(() => wrapper?.unmount());
afterEach(jest.clearAllMocks);
it.each([
[ undefined, 'cards' ],
[{}, 'cards' ],
[{ defaultMode: 'cards' as TagsMode }, 'cards' ],
[{ defaultMode: 'list' as TagsMode }, 'list' ],
])('shows expected tags displaying mode', (tags, expectedMode) => {
const wrapper = createWrapper(tags);
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 setTagsSettings when tags mode changes', (defaultMode) => {
const wrapper = createWrapper();
const dropdown = wrapper.find(TagsModeDropdown);
expect(setTagsSettings).not.toHaveBeenCalled();
dropdown.simulate('change', defaultMode);
expect(setTagsSettings).toHaveBeenCalledWith({ defaultMode });
});
});

View file

@ -2,11 +2,10 @@ import { shallow, ShallowWrapper } from 'enzyme';
import { Mock } from 'ts-mockery';
import { faMoon, faSun } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Settings, TagsMode, UiSettings } from '../../src/settings/reducers/settings';
import { Settings, UiSettings } from '../../src/settings/reducers/settings';
import { UserInterface } from '../../src/settings/UserInterface';
import ToggleSwitch from '../../src/utils/ToggleSwitch';
import { Theme } from '../../src/utils/theme';
import { TagsModeDropdown } from '../../src/tags/TagsModeDropdown';
describe('<UserInterface />', () => {
let wrapper: ShallowWrapper;
@ -53,30 +52,4 @@ describe('<UserInterface />', () => {
toggle.simulate('change', checked);
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 });
});
});