Created section to set default date interval for visits

This commit is contained in:
Alejandro Celaya 2021-03-06 16:54:43 +01:00
parent d3f9650e82
commit fee62484b5
12 changed files with 170 additions and 42 deletions

View file

@ -1,22 +1,29 @@
import { FC } from 'react'; import { FC, ReactNode } from 'react';
import { Row } from 'reactstrap'; import { Row } from 'reactstrap';
import NoMenuLayout from '../common/NoMenuLayout'; import NoMenuLayout from '../common/NoMenuLayout';
const Settings = (RealTimeUpdates: FC, ShortUrlCreation: FC, UserInterface: FC) => () => ( const SettingsSections: FC<{ items: ReactNode[][] }> = ({ items }) => (
<NoMenuLayout> <>
<Row> {items.map((child, index) => (
<div className="col-lg-6"> <Row key={index}>
<div className="mb-3 mb-md-4"> {child.map((subChild, subIndex) => (
<UserInterface /> <div key={subIndex} className="col-lg-6">
</div> <div className="mb-3 mb-md-4">{subChild}</div>
<div className="mb-3 mb-md-4">
<ShortUrlCreation />
</div>
</div>
<div className="col-lg-6">
<RealTimeUpdates />
</div> </div>
))}
</Row> </Row>
))}
</>
);
const Settings = (RealTimeUpdates: FC, ShortUrlCreation: FC, UserInterface: FC, Visits: FC) => () => (
<NoMenuLayout>
<SettingsSections
items={[
[ <UserInterface />, <ShortUrlCreation /> ], // eslint-disable-line react/jsx-key
[ <Visits />, <RealTimeUpdates /> ], // eslint-disable-line react/jsx-key
]}
/>
</NoMenuLayout> </NoMenuLayout>
); );

22
src/settings/Visits.tsx Normal file
View file

@ -0,0 +1,22 @@
import { FormGroup } from 'reactstrap';
import { FC } from 'react';
import { SimpleCard } from '../utils/SimpleCard';
import { DateIntervalSelector } from '../utils/dates/DateIntervalSelector';
import { Settings, VisitsSettings } from './reducers/settings';
interface VisitsProps {
settings: Settings;
setVisitsSettings: (settings: VisitsSettings) => void;
}
export const Visits: FC<VisitsProps> = ({ settings, setVisitsSettings }) => (
<SimpleCard title="Visits">
<FormGroup className="mb-0">
<label>Default interval to load on visits sections:</label>
<DateIntervalSelector
active={settings.visits?.defaultInterval ?? 'last30Days'}
onChange={(defaultInterval) => setVisitsSettings({ defaultInterval })}
/>
</FormGroup>
</SimpleCard>
);

View file

@ -78,3 +78,8 @@ export const setUiSettings = (settings: UiSettings): PartialSettingsAction => ({
type: SET_SETTINGS, type: SET_SETTINGS,
ui: settings, ui: settings,
}); });
export const setVisitsSettings = (settings: VisitsSettings): PartialSettingsAction => ({
type: SET_SETTINGS,
visits: settings,
});

View file

@ -5,16 +5,18 @@ import {
setRealTimeUpdatesInterval, setRealTimeUpdatesInterval,
setShortUrlCreationSettings, setShortUrlCreationSettings,
setUiSettings, setUiSettings,
setVisitsSettings,
toggleRealTimeUpdates, toggleRealTimeUpdates,
} from '../reducers/settings'; } from '../reducers/settings';
import { ConnectDecorator } from '../../container/types'; import { ConnectDecorator } from '../../container/types';
import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer'; import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer';
import { ShortUrlCreation } from '../ShortUrlCreation'; import { ShortUrlCreation } from '../ShortUrlCreation';
import { UserInterface } from '../UserInterface'; import { UserInterface } from '../UserInterface';
import { Visits } from '../Visits';
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Components // Components
bottle.serviceFactory('Settings', Settings, 'RealTimeUpdates', 'ShortUrlCreation', 'UserInterface'); bottle.serviceFactory('Settings', Settings, 'RealTimeUpdates', 'ShortUrlCreation', 'UserInterface', 'Visits');
bottle.decorator('Settings', withoutSelectedServer); bottle.decorator('Settings', withoutSelectedServer);
bottle.decorator('Settings', connect(null, [ 'resetSelectedServer' ])); bottle.decorator('Settings', connect(null, [ 'resetSelectedServer' ]));
@ -30,11 +32,15 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
bottle.serviceFactory('UserInterface', () => UserInterface); bottle.serviceFactory('UserInterface', () => UserInterface);
bottle.decorator('UserInterface', connect([ 'settings' ], [ 'setUiSettings' ])); bottle.decorator('UserInterface', connect([ 'settings' ], [ 'setUiSettings' ]));
bottle.serviceFactory('Visits', () => Visits);
bottle.decorator('Visits', connect([ 'settings' ], [ 'setVisitsSettings' ]));
// Actions // Actions
bottle.serviceFactory('toggleRealTimeUpdates', () => toggleRealTimeUpdates); bottle.serviceFactory('toggleRealTimeUpdates', () => toggleRealTimeUpdates);
bottle.serviceFactory('setRealTimeUpdatesInterval', () => setRealTimeUpdatesInterval); bottle.serviceFactory('setRealTimeUpdatesInterval', () => setRealTimeUpdatesInterval);
bottle.serviceFactory('setShortUrlCreationSettings', () => setShortUrlCreationSettings); bottle.serviceFactory('setShortUrlCreationSettings', () => setShortUrlCreationSettings);
bottle.serviceFactory('setUiSettings', () => setUiSettings); bottle.serviceFactory('setUiSettings', () => setUiSettings);
bottle.serviceFactory('setVisitsSettings', () => setVisitsSettings);
}; };
export default provideServices; export default provideServices;

View file

@ -0,0 +1,20 @@
import { DropdownItem } from 'reactstrap';
import { FC } from 'react';
import { DATE_INTERVALS, DateInterval, rangeOrIntervalToString } from './types';
export interface DateIntervalDropdownProps {
active?: DateInterval;
onChange: (interval: DateInterval) => void;
}
export const DateIntervalDropdownItems: FC<DateIntervalDropdownProps> = ({ active, onChange }) => (
<>
{DATE_INTERVALS.map(
(interval) => (
<DropdownItem key={interval} active={active === interval} onClick={() => onChange(interval)}>
{rangeOrIntervalToString(interval)}
</DropdownItem>
),
)}
</>
);

View file

@ -0,0 +1,10 @@
import { FC } from 'react';
import { DropdownBtn } from '../DropdownBtn';
import { rangeOrIntervalToString } from './types';
import { DateIntervalDropdownItems, DateIntervalDropdownProps } from './DateIntervalDropdownItems';
export const DateIntervalSelector: FC<DateIntervalDropdownProps> = ({ onChange, active }) => (
<DropdownBtn text={rangeOrIntervalToString(active) ?? ''}>
<DateIntervalDropdownItems active={active} onChange={onChange} />
</DropdownBtn>
);

View file

@ -10,6 +10,7 @@ import {
rangeIsInterval, rangeIsInterval,
} from './types'; } from './types';
import DateRangeRow from './DateRangeRow'; import DateRangeRow from './DateRangeRow';
import { DateIntervalDropdownItems } from './DateIntervalDropdownItems';
export interface DateRangeSelectorProps { export interface DateRangeSelectorProps {
initialDateRange?: DateInterval | DateRange; initialDateRange?: DateInterval | DateRange;
@ -47,13 +48,7 @@ export const DateRangeSelector = (
{defaultText} {defaultText}
</DropdownItem> </DropdownItem>
<DropdownItem divider /> <DropdownItem divider />
{([ 'today', 'yesterday', 'last7Days', 'last30Days', 'last90Days', 'last180days', 'last365Days' ] as DateInterval[]).map( <DateIntervalDropdownItems active={activeInterval} onChange={(interval) => updateInterval(interval)()} />
(interval) => (
<DropdownItem key={interval} active={activeInterval === interval} onClick={updateInterval(interval)}>
{rangeOrIntervalToString(interval)}
</DropdownItem>
),
)}
<DropdownItem divider /> <DropdownItem divider />
<DropdownItem header>Custom:</DropdownItem> <DropdownItem header>Custom:</DropdownItem>
<DropdownItem text> <DropdownItem text>

View file

@ -24,6 +24,8 @@ const INTERVAL_TO_STRING_MAP: Record<DateInterval, string> = {
last365Days: 'Last 365 days', last365Days: 'Last 365 days',
}; };
export const DATE_INTERVALS: DateInterval[] = Object.keys(INTERVAL_TO_STRING_MAP) as DateInterval[];
const dateRangeToString = (range?: DateRange): string | undefined => { const dateRangeToString = (range?: DateRange): string | undefined => {
if (!range || dateRangeIsEmpty(range)) { if (!range || dateRangeIsEmpty(range)) {
return undefined; return undefined;

View file

@ -4,6 +4,7 @@ import reducer, {
setRealTimeUpdatesInterval, setRealTimeUpdatesInterval,
setShortUrlCreationSettings, setShortUrlCreationSettings,
setUiSettings, setUiSettings,
setVisitsSettings,
} from '../../../src/settings/reducers/settings'; } from '../../../src/settings/reducers/settings';
describe('settingsReducer', () => { describe('settingsReducer', () => {
@ -50,4 +51,12 @@ describe('settingsReducer', () => {
expect(result).toEqual({ type: SET_SETTINGS, ui: { theme: 'dark' } }); expect(result).toEqual({ type: SET_SETTINGS, ui: { theme: 'dark' } });
}); });
}); });
describe('setVisitsSettings', () => {
it('creates action to set visits settings', () => {
const result = setVisitsSettings({ defaultInterval: 'last180days' });
expect(result).toEqual({ type: SET_SETTINGS, visits: { defaultInterval: 'last180days' } });
});
});
}); });

View file

@ -0,0 +1,39 @@
import { DropdownItem } from 'reactstrap';
import { shallow, ShallowWrapper } from 'enzyme';
import { DateIntervalDropdownItems } from '../../../src/utils/dates/DateIntervalDropdownItems';
import { DATE_INTERVALS } from '../../../src/utils/dates/types';
describe('<DateIntervalDropdownItems />', () => {
let wrapper: ShallowWrapper;
const onChange = jest.fn();
beforeEach(() => {
wrapper = shallow(<DateIntervalDropdownItems active="last180days" onChange={onChange} />);
});
afterEach(jest.clearAllMocks);
afterEach(() => wrapper?.unmount());
test('expected amount of items is rendered', () => {
const items = wrapper.find(DropdownItem);
expect(items).toHaveLength(DATE_INTERVALS.length);
});
test('expected item is active', () => {
const items = wrapper.find(DropdownItem);
const EXPECTED_ACTIVE_INDEX = 5;
expect.assertions(DATE_INTERVALS.length);
items.forEach((item, index) => expect(item.prop('active')).toEqual(index === EXPECTED_ACTIVE_INDEX));
});
test('selecting an element triggers onChange callback', () => {
const items = wrapper.find(DropdownItem);
items.at(2).simulate('click');
items.at(4).simulate('click');
items.at(1).simulate('click');
expect(onChange).toHaveBeenCalledTimes(3);
});
});

View file

@ -4,6 +4,7 @@ import moment from 'moment';
import { Mock } from 'ts-mockery'; import { Mock } from 'ts-mockery';
import { DateRangeSelector, DateRangeSelectorProps } from '../../../src/utils/dates/DateRangeSelector'; import { DateRangeSelector, DateRangeSelectorProps } from '../../../src/utils/dates/DateRangeSelector';
import { DateInterval } from '../../../src/utils/dates/types'; import { DateInterval } from '../../../src/utils/dates/types';
import { DateIntervalDropdownItems } from '../../../src/utils/dates/DateIntervalDropdownItems';
describe('<DateRangeSelector />', () => { describe('<DateRangeSelector />', () => {
let wrapper: ShallowWrapper; let wrapper: ShallowWrapper;
@ -20,39 +21,49 @@ describe('<DateRangeSelector />', () => {
test('proper amount of items is rendered', () => { test('proper amount of items is rendered', () => {
const wrapper = createWrapper(); const wrapper = createWrapper();
const items = wrapper.find(DropdownItem); const items = wrapper.find(DropdownItem);
const dateIntervalItems = wrapper.find(DateIntervalDropdownItems);
expect(items).toHaveLength(12); expect(items).toHaveLength(5);
expect(dateIntervalItems).toHaveLength(1);
expect(items.filter('[divider]')).toHaveLength(2); expect(items.filter('[divider]')).toHaveLength(2);
expect(items.filter('[header]')).toHaveLength(1); expect(items.filter('[header]')).toHaveLength(1);
expect(items.filter('[text]')).toHaveLength(1); expect(items.filter('[text]')).toHaveLength(1);
expect(items.filter('[active]')).toHaveLength(8); expect(items.filter('[active]')).toHaveLength(1);
}); });
test.each([ test.each([
[ undefined, 0 ], [ undefined, 1, 0 ],
[ 'today' as DateInterval, 1 ], [ 'today' as DateInterval, 0, 1 ],
[ 'yesterday' as DateInterval, 2 ], [ 'yesterday' as DateInterval, 0, 1 ],
[ 'last7Days' as DateInterval, 3 ], [ 'last7Days' as DateInterval, 0, 1 ],
[ 'last30Days' as DateInterval, 4 ], [ 'last30Days' as DateInterval, 0, 1 ],
[ 'last90Days' as DateInterval, 5 ], [ 'last90Days' as DateInterval, 0, 1 ],
[ 'last180days' as DateInterval, 6 ], [ 'last180days' as DateInterval, 0, 1 ],
[ 'last365Days' as DateInterval, 7 ], [ 'last365Days' as DateInterval, 0, 1 ],
[{ startDate: moment() }, 8 ], [{ startDate: moment() }, 0, 0 ],
])('proper element is active based on provided date range', (initialDateRange, expectedActiveIndex) => { ])('proper element is active based on provided date range', (
initialDateRange,
expectedActiveItems,
expectedActiveIntervalItems,
) => {
const wrapper = createWrapper({ initialDateRange }); const wrapper = createWrapper({ initialDateRange });
const items = wrapper.find(DropdownItem).filter('[active]'); const items = wrapper.find(DropdownItem).filterWhere((item) => item.prop('active') === true);
const dateIntervalItems = wrapper.find(DateIntervalDropdownItems).filterWhere(
(item) => item.prop('active') !== undefined,
);
expect.assertions(8); expect(items).toHaveLength(expectedActiveItems);
items.forEach((item, index) => expect(item.prop('active')).toEqual(index === expectedActiveIndex)); expect(dateIntervalItems).toHaveLength(expectedActiveIntervalItems);
}); });
test('selecting an element triggers onDatesChange callback', () => { test('selecting an element triggers onDatesChange callback', () => {
const wrapper = createWrapper(); const wrapper = createWrapper();
const items = wrapper.find(DropdownItem).filter('[active]'); const item = wrapper.find(DropdownItem).at(0);
const dateIntervalItems = wrapper.find(DateIntervalDropdownItems);
items.at(2).simulate('click'); item.simulate('click');
items.at(4).simulate('click'); item.simulate('click');
items.at(1).simulate('click'); dateIntervalItems.simulate('change');
expect(onDatesChange).toHaveBeenCalledTimes(3); expect(onDatesChange).toHaveBeenCalledTimes(3);
}); });
}); });

View file

@ -7,6 +7,7 @@ import { MercureBoundProps } from '../../src/mercure/helpers/boundToMercureHub';
import { VisitsInfo } from '../../src/visits/types'; import { VisitsInfo } from '../../src/visits/types';
import VisitsStats from '../../src/visits/VisitsStats'; import VisitsStats from '../../src/visits/VisitsStats';
import { OrphanVisitsHeader } from '../../src/visits/OrphanVisitsHeader'; import { OrphanVisitsHeader } from '../../src/visits/OrphanVisitsHeader';
import { Settings } from '../../src/settings/reducers/settings';
describe('<OrphanVisits />', () => { describe('<OrphanVisits />', () => {
it('wraps visits stats and header', () => { it('wraps visits stats and header', () => {
@ -24,6 +25,7 @@ describe('<OrphanVisits />', () => {
history={Mock.of<History>({ goBack })} history={Mock.of<History>({ goBack })}
location={Mock.all<Location>()} location={Mock.all<Location>()}
match={Mock.of<match>({ url: 'the_base_url' })} match={Mock.of<match>({ url: 'the_base_url' })}
settings={Mock.all<Settings>()}
/>, />,
).dive(); ).dive();
const stats = wrapper.find(VisitsStats); const stats = wrapper.find(VisitsStats);