diff --git a/src/index.scss b/src/index.scss index b471a731..8137971b 100644 --- a/src/index.scss +++ b/src/index.scss @@ -92,3 +92,17 @@ body, .progress-bar { background-color: $mainColor; } + +.btn-xs-block { + @media (max-width: $xsMax) { + width: 100%; + display: block; + } +} + +.btn-md-block { + @media (max-width: $mdMax) { + width: 100%; + display: block; + } +} diff --git a/src/short-urls/CreateShortUrl.scss b/src/short-urls/CreateShortUrl.scss index 442bc2a5..81adb310 100644 --- a/src/short-urls/CreateShortUrl.scss +++ b/src/short-urls/CreateShortUrl.scss @@ -1,12 +1,5 @@ @import '../utils/base'; -.create-short-url__save-btn { - @media (max-width: $xsMax) { - width: 100%; - display: block; - } -} - .create-short-url .form-group:last-child, .create-short-url p:last-child { margin-bottom: 0; diff --git a/src/short-urls/CreateShortUrl.tsx b/src/short-urls/CreateShortUrl.tsx index aba3ab8c..64c3926f 100644 --- a/src/short-urls/CreateShortUrl.tsx +++ b/src/short-urls/CreateShortUrl.tsx @@ -197,7 +197,7 @@ const CreateShortUrl = ( outline color="primary" disabled={shortUrlCreationResult.saving || isEmpty(shortUrlCreation.longUrl)} - className="create-short-url__save-btn" + className="btn-xs-block" > {shortUrlCreationResult.saving ? 'Creating...' : 'Create'} diff --git a/src/visits/VisitsStats.scss b/src/visits/VisitsStats.scss new file mode 100644 index 00000000..264f43da --- /dev/null +++ b/src/visits/VisitsStats.scss @@ -0,0 +1,20 @@ +@import '../utils/base'; + +.visits-stats__nav-link { + border-radius: 0 !important; + padding-bottom: calc(.5rem - 3px) !important; + border-bottom: 3px solid transparent; + color: #5d6778; + font-weight: 700; + cursor: pointer; +} + +.visits-stats__nav-link:hover { + color: $mainColor !important; +} + +.visits-stats__nav-link.active { + border-color: $mainColor; + background-color: white !important; + color: $mainColor !important; +} diff --git a/src/visits/VisitsStats.tsx b/src/visits/VisitsStats.tsx index eadd778c..7409b3ef 100644 --- a/src/visits/VisitsStats.tsx +++ b/src/visits/VisitsStats.tsx @@ -1,14 +1,13 @@ import { isEmpty, propEq, values } from 'ramda'; import { useState, useEffect, useMemo, FC } from 'react'; -import { Button, Card, Collapse, Progress } from 'reactstrap'; -import classNames from 'classnames'; +import { Button, Card, Nav, NavLink, Progress } from 'reactstrap'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faChevronDown as chevronDown } from '@fortawesome/free-solid-svg-icons'; +import { faCalendarAlt, faMapMarkedAlt, faList, faChartPie } from '@fortawesome/free-solid-svg-icons'; +import { IconDefinition } from '@fortawesome/fontawesome-common-types'; import moment from 'moment'; import DateRangeRow from '../utils/DateRangeRow'; import Message from '../utils/Message'; import { formatDate } from '../utils/helpers/date'; -import { useToggle } from '../utils/helpers/hooks'; import { ShlinkVisitsParams } from '../utils/services/types'; import SortableBarGraph from './helpers/SortableBarGraph'; import GraphCard from './helpers/GraphCard'; @@ -17,15 +16,23 @@ import VisitsTable from './VisitsTable'; import { NormalizedVisit, Stats, VisitsInfo } from './types'; import OpenMapModalBtn from './helpers/OpenMapModalBtn'; import { normalizeVisits, processStatsFromVisits } from './services/VisitsParser'; +import './VisitsStats.scss'; export interface VisitsStatsProps { - matchMedia?: (query: string) => MediaQueryList; getVisits: (params: Partial) => void; visitsInfo: VisitsInfo; cancelGetVisits: () => void; } type HighlightableProps = 'referer' | 'country' | 'city'; +type Section = 'byTime' | 'byContext' | 'byLocation' | 'list'; + +const sections: Record = { + byTime: { title: 'By time', icon: faCalendarAlt }, + byContext: { title: 'By context', icon: faChartPie }, + byLocation: { title: 'By location', icon: faMapMarkedAlt }, + list: { title: 'List', icon: faList }, +}; const highlightedVisitsToStats = ( highlightedVisits: NormalizedVisit[], @@ -42,19 +49,15 @@ const highlightedVisitsToStats = ( const format = formatDate(); let selectedBar: string | undefined; -const VisitsStats: FC = ( - { children, visitsInfo, getVisits, cancelGetVisits, matchMedia = window.matchMedia }, -) => { +const VisitsStats: FC = ({ children, visitsInfo, getVisits, cancelGetVisits }) => { const [ startDate, setStartDate ] = useState(null); const [ endDate, setEndDate ] = useState(null); - const [ showTable, toggleTable ] = useToggle(); - const [ tableIsSticky, , setSticky, unsetSticky ] = useToggle(); const [ highlightedVisits, setHighlightedVisits ] = useState([]); const [ highlightedLabel, setHighlightedLabel ] = useState(); - const [ isMobileDevice, setIsMobileDevice ] = useState(false); + const [ activeSection, setActiveSection ] = useState
('byTime'); + const onSectionChange = (section: Section) => () => setActiveSection(section); const { visits, loading, loadingLarge, error, progress } = visitsInfo; - const showTableControls = !loading && visits.length > 0; const normalizedVisits = useMemo(() => normalizeVisits(visits), [ visits ]); const { os, browsers, referrers, countries, cities, citiesForMap } = useMemo( () => processStatsFromVisits(normalizedVisits), @@ -62,7 +65,6 @@ const VisitsStats: FC = ( ); const mapLocations = values(citiesForMap); - const determineIsMobileDevice = () => setIsMobileDevice(matchMedia('(max-width: 991px)').matches); const setSelectedVisits = (selectedVisits: NormalizedVisit[]) => { selectedBar = undefined; setHighlightedVisits(selectedVisits); @@ -81,15 +83,7 @@ const VisitsStats: FC = ( } }; - useEffect(() => { - determineIsMobileDevice(); - window.addEventListener('resize', determineIsMobileDevice); - - return () => { - cancelGetVisits(); - window.removeEventListener('resize', determineIsMobileDevice); - }; - }, []); + useEffect(() => () => cancelGetVisits(), []); useEffect(() => { getVisits({ startDate: format(startDate) ?? undefined, endDate: format(endDate) ?? undefined }); }, [ startDate, endDate ]); @@ -121,67 +115,106 @@ const VisitsStats: FC = ( } return ( -
-
- + <> + + + +
+ {activeSection === 'byTime' && ( +
+ +
+ )} + {activeSection === 'byContext' && ( + <> +
+ +
+
+ +
+
+ +
+ + )} + {activeSection === 'byLocation' && ( + <> +
+ +
+
+ + mapLocations.length > 0 && + + } + sortingItems={{ + name: 'City name', + amount: 'Visits amount', + }} + onClick={highlightVisitsForProp('city')} + /> +
+ + )} + {activeSection === 'list' && ( +
+ +
+ )}
-
- -
-
- -
-
- -
-
- -
-
- - mapLocations.length > 0 && - - } - sortingItems={{ - name: 'City name', - amount: 'Visits amount', - }} - onClick={highlightVisitsForProp('city')} - /> -
-
+ ); }; @@ -200,47 +233,21 @@ const VisitsStats: FC = ( onEndDateChange={setEndDate} />
-
- {showTableControls && ( - - - - - - - - - )} -
+ {visits.length > 0 && ( +
+ +
+ )}
- {showTableControls && ( - - - - )} -
{renderVisitsContent()}
diff --git a/src/visits/VisitsTable.tsx b/src/visits/VisitsTable.tsx index 03985b8f..eab3d774 100644 --- a/src/visits/VisitsTable.tsx +++ b/src/visits/VisitsTable.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useState, useRef } from 'react'; import Moment from 'react-moment'; import classNames from 'classnames'; import { min, splitEvery } from 'ramda'; @@ -68,6 +68,7 @@ const VisitsTable = ({ const [ searchTerm, setSearchTerm ] = useState(undefined); const [ order, setOrder ] = useState({ field: undefined, dir: undefined }); const resultSet = useMemo(() => calculateVisits(visits, searchTerm, order), [ searchTerm, order ]); + const isFirstLoad = useRef(true); const [ page, setPage ] = useState(1); const end = page * PAGE_SIZE; @@ -91,7 +92,12 @@ const VisitsTable = ({ }, []); useEffect(() => { setPage(1); - setSelectedVisits([]); + + if (isFirstLoad.current) { + isFirstLoad.current = false; + } else { + setSelectedVisits([]); + } }, [ searchTerm ]); return ( diff --git a/src/visits/helpers/DefaultChart.tsx b/src/visits/helpers/DefaultChart.tsx index 48209f11..0753ad35 100644 --- a/src/visits/helpers/DefaultChart.tsx +++ b/src/visits/helpers/DefaultChart.tsx @@ -1,4 +1,4 @@ -import { useRef } from 'react'; +import { useState } from 'react'; import { Doughnut, HorizontalBar } from 'react-chartjs-2'; import { keys, values } from 'ramda'; import classNames from 'classnames'; @@ -118,7 +118,7 @@ const DefaultChart = ( }, { ...stats }), ); const highlightedData = statsAreDefined(highlightedStats) ? fillTheGaps(highlightedStats, labels) : undefined; - const chartRef = useRef(); + const [ chartRef, setChartRef ] = useState() const options: ChartOptions = { legend: { display: false }, @@ -156,7 +156,7 @@ const DefaultChart = (
setChartRef(element ?? undefined)} key={height} data={graphData} options={options} @@ -166,7 +166,7 @@ const DefaultChart = (
{!isBarChart && (
- {chartRef.current?.chartInstance.generateLegend()} + {chartRef?.chartInstance.generateLegend()}
)}
diff --git a/src/visits/helpers/LineChartCard.scss b/src/visits/helpers/LineChartCard.scss index 28272db8..e834aece 100644 --- a/src/visits/helpers/LineChartCard.scss +++ b/src/visits/helpers/LineChartCard.scss @@ -4,6 +4,6 @@ height: 300px !important; @media (min-width: $mdMin) { - height: 350px !important; + height: 400px !important; } } diff --git a/test/visits/VisitsStats.test.tsx b/test/visits/VisitsStats.test.tsx index 899bfc3a..3c39bfa3 100644 --- a/test/visits/VisitsStats.test.tsx +++ b/test/visits/VisitsStats.test.tsx @@ -1,5 +1,5 @@ import { shallow, ShallowWrapper } from 'enzyme'; -import { Card, Progress } from 'reactstrap'; +import { Card, NavLink, Progress } from 'reactstrap'; import { Mock } from 'ts-mockery'; import VisitStats from '../../src/visits/VisitsStats'; import Message from '../../src/utils/Message'; @@ -7,6 +7,8 @@ import GraphCard from '../../src/visits/helpers/GraphCard'; import SortableBarGraph from '../../src/visits/helpers/SortableBarGraph'; import DateRangeRow from '../../src/utils/DateRangeRow'; import { Visit, VisitsInfo } from '../../src/visits/types'; +import LineChartCard from '../../src/visits/helpers/LineChartCard'; +import VisitsTable from '../../src/visits/VisitsTable'; describe('', () => { const visits = [ Mock.all(), Mock.all(), Mock.all() ]; @@ -20,7 +22,6 @@ describe('', () => { getVisits={getVisitsMock} visitsInfo={Mock.of(visitsInfo)} cancelGetVisits={() => {}} - matchMedia={() => Mock.of({ matches: false })} />, ); @@ -66,12 +67,24 @@ describe('', () => { expect(message.html()).toContain('There are no visits matching current filter :('); }); - it('renders all graphics when visits are properly loaded', () => { + it.each([ + [ 0, 1, 0 ], + [ 1, 3, 0 ], + [ 2, 2, 0 ], + [ 3, 0, 1 ], + ])('renders expected amount of graphics based on active section', (navIndex, expectedGraphics, expectedTables) => { const wrapper = createComponent({ loading: false, error: false, visits }); + const nav = wrapper.find(NavLink).at(navIndex); + + nav.simulate('click'); + const graphs = wrapper.find(GraphCard); const sortableBarGraphs = wrapper.find(SortableBarGraph); + const lineChart = wrapper.find(LineChartCard); + const table = wrapper.find(VisitsTable); - expect(graphs.length + sortableBarGraphs.length).toEqual(5); + expect(graphs.length + sortableBarGraphs.length + lineChart.length).toEqual(expectedGraphics); + expect(table).toHaveLength(expectedTables); }); it('reloads visits when selected dates change', () => { @@ -88,6 +101,10 @@ describe('', () => { it('holds the map button content generator on cities graph extraHeaderContent', () => { const wrapper = createComponent({ loading: false, error: false, visits }); + const locationNav = wrapper.find(NavLink).at(2); + + locationNav.simulate('click'); + const citiesGraph = wrapper.find(SortableBarGraph).find('[title="Cities"]'); const extraHeaderContent = citiesGraph.prop('extraHeaderContent');