diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ae2abb0..15318b2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), * [#309](https://github.com/shlinkio/shlink-web-client/issues/309) Added new domain selector component in create URL form which allows selecting from previously used domains or set a new one. * [#315](https://github.com/shlinkio/shlink-web-client/issues/315) Now you can tell if you want to validate the long URL when using Shlink >=2.4. +* [#285](https://github.com/shlinkio/shlink-web-client/issues/285) Improved visits section: + + * Charts are now grouped in tabs, so that only one part of the components is rendered at a time. + * Amount of highlighted visits is now displayed. ### Changed * [#267](https://github.com/shlinkio/shlink-web-client/issues/267) Added some subtle but important improvements on UI/UX. diff --git a/src/common/AsideMenu.scss b/src/common/AsideMenu.scss index af1935b7..22547a50 100644 --- a/src/common/AsideMenu.scss +++ b/src/common/AsideMenu.scss @@ -48,7 +48,7 @@ $asideMenuMobileWidth: 280px; } .aside-menu__item:hover { - background-color: $lightHoverColor; + background-color: $lightColor; } .aside-menu__item--selected { diff --git a/src/index.scss b/src/index.scss index c7973396..8137971b 100644 --- a/src/index.scss +++ b/src/index.scss @@ -6,7 +6,7 @@ html, body, #root { height: 100%; - background: #f5f6fe; + background: $lightColor; } * { @@ -44,6 +44,10 @@ body, background-color: $mainColor; } +.table-hover tbody tr:hover { + background-color: $lightColor; +} + .react-datepicker__input-container, .react-datepicker-wrapper { display: block !important; @@ -88,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/servers/EditServer.tsx b/src/servers/EditServer.tsx index 40c52c68..f18c788d 100644 --- a/src/servers/EditServer.tsx +++ b/src/servers/EditServer.tsx @@ -24,7 +24,7 @@ export const EditServer = (ServerError: FC) => withSelectedServer Edit "{selectedServer.name}"} + title={
Edit "{selectedServer.name}"
} initialValues={selectedServer} onSubmit={handleSubmit} > diff --git a/src/servers/Overview.tsx b/src/servers/Overview.tsx index 0c4ab5c3..974b928b 100644 --- a/src/servers/Overview.tsx +++ b/src/servers/Overview.tsx @@ -9,9 +9,9 @@ import { ShortUrlsTableProps } from '../short-urls/ShortUrlsTable'; import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub'; import { CreateShortUrlProps } from '../short-urls/CreateShortUrl'; import { VisitsOverview } from '../visits/reducers/visitsOverview'; +import { Versions } from '../utils/helpers/version'; import { isServerWithId, SelectedServer } from './data'; import './Overview.scss'; -import { Versions } from '../utils/helpers/version'; interface OverviewConnectProps { shortUrlsList: ShortUrlsListState; 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/utils/DateRangeRow.scss b/src/utils/DateRangeRow.scss deleted file mode 100644 index bcd94e78..00000000 --- a/src/utils/DateRangeRow.scss +++ /dev/null @@ -1,7 +0,0 @@ -@import '../utils/base'; - -.date-range-row__date-input { - @media (max-width: $smMax) { - margin-top: .5rem; - } -} diff --git a/src/utils/DateRangeRow.tsx b/src/utils/DateRangeRow.tsx index e12a6f26..6f006c28 100644 --- a/src/utils/DateRangeRow.tsx +++ b/src/utils/DateRangeRow.tsx @@ -1,6 +1,5 @@ import moment from 'moment'; import DateInput from './DateInput'; -import './DateRangeRow.scss'; interface DateRangeRowProps { startDate?: moment.Moment | null; @@ -26,7 +25,7 @@ const DateRangeRow = (
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.scss b/src/visits/VisitsTable.scss index 54418ddf..ed2cdd7c 100644 --- a/src/visits/VisitsTable.scss +++ b/src/visits/VisitsTable.scss @@ -15,7 +15,7 @@ @media (min-width: $mdMin) { &.visits-table__sticky { - top: $headerHeight - 2px; + top: $headerHeight + 40px; } } } 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');