From b0c15490056de6f460b0c687ed734cb514532630 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 1 Nov 2021 11:13:47 +0100 Subject: [PATCH 1/7] Added sticky header to tags table --- src/tags/TagsTable.scss | 9 +++++++++ src/tags/TagsTable.tsx | 10 ++++++---- src/utils/mixins/sticky-cell.scss | 8 ++++---- 3 files changed, 19 insertions(+), 8 deletions(-) create mode 100644 src/tags/TagsTable.scss diff --git a/src/tags/TagsTable.scss b/src/tags/TagsTable.scss new file mode 100644 index 00000000..94728f7b --- /dev/null +++ b/src/tags/TagsTable.scss @@ -0,0 +1,9 @@ +@import '../utils/base'; +@import '../utils/mixins/sticky-cell'; + +.tags-table__header-cell.tags-table__header-cell { + @include sticky-cell(false); + + top: $headerHeight; + position: sticky; +} diff --git a/src/tags/TagsTable.tsx b/src/tags/TagsTable.tsx index 2de68d44..7caec4af 100644 --- a/src/tags/TagsTable.tsx +++ b/src/tags/TagsTable.tsx @@ -8,6 +8,7 @@ import { useQueryState } from '../utils/helpers/hooks'; import { parseQuery } from '../utils/helpers/query'; import { TagsListChildrenProps } from './data/TagsListChildrenProps'; import { TagsTableRowProps } from './TagsTableRow'; +import './TagsTable.scss'; const TAGS_PER_PAGE = 20; // TODO Allow customizing this value in settings @@ -35,11 +36,12 @@ export const TagsTable = (colorGenerator: ColorGenerator, TagsTableRow: FC - Tag - Short URLs - Visits - + Tag + Short URLs + Visits + + {currentPage.length === 0 && No results found} diff --git a/src/utils/mixins/sticky-cell.scss b/src/utils/mixins/sticky-cell.scss index 0e2d6125..93a7e47c 100644 --- a/src/utils/mixins/sticky-cell.scss +++ b/src/utils/mixins/sticky-cell.scss @@ -1,6 +1,6 @@ @import '../base'; -@mixin sticky-cell() { +@mixin sticky-cell($with-separators: true) { z-index: 1; border: none !important; position: relative; @@ -11,20 +11,20 @@ top: -1px; left: 0; bottom: -1px; - right: -1px; + right: if($with-separators, -1px, 0); background: var(--table-border-color); z-index: -2; } &:first-child:before { - left: -1px; + left: if($with-separators, -1px, 0); } &:after { content: ''; position: absolute; top: 0; - left: 1px; + left: if($with-separators, 1px, 0); bottom: 0; right: 0; background: var(--primary-color); From 844cf51d0418e6e3e1519e045bd6898cabce9714 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 1 Nov 2021 11:18:09 +0100 Subject: [PATCH 2/7] Added missing prettify on number of visits to export and selected visits --- src/visits/VisitsStats.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/visits/VisitsStats.tsx b/src/visits/VisitsStats.tsx index ca255348..4f4e3706 100644 --- a/src/visits/VisitsStats.tsx +++ b/src/visits/VisitsStats.tsx @@ -15,6 +15,7 @@ import { ShlinkApiError } from '../api/ShlinkApiError'; import { Settings } from '../settings/reducers/settings'; import { SelectedServer } from '../servers/data'; import { supportsBotVisits } from '../utils/helpers/features'; +import { prettify } from '../utils/helpers/numbers'; import LineChartCard from './charts/LineChartCard'; import VisitsTable from './VisitsTable'; import { NormalizedOrphanVisit, NormalizedVisit, VisitsFilter, VisitsInfo, VisitsParams } from './types'; @@ -295,7 +296,7 @@ const VisitsStats: FC = ({ className="btn-md-block mr-2" onClick={() => setSelectedVisits([])} > - Clear selection {highlightedVisits.length > 0 && <>({highlightedVisits.length})} + Clear selection {highlightedVisits.length > 0 && <>({prettify(highlightedVisits.length)})} From 5241925acc2e37012dfd8d367574dfc21dc1ddc3 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 1 Nov 2021 12:48:11 +0100 Subject: [PATCH 3/7] Added not-enabled sorting on tags table --- src/short-urls/ShortUrlsList.tsx | 2 +- .../reducers/shortUrlsListParams.ts | 2 +- src/tags/TagsTable.scss | 1 + src/tags/TagsTable.tsx | 31 ++++++++++-------- src/tags/TagsTableRow.tsx | 28 ++++++++-------- src/tags/data/index.ts | 6 ++++ src/tags/services/provideServices.ts | 4 +-- src/utils/SortingDropdown.tsx | 2 +- src/utils/helpers/ordering.ts | 32 +++++++++++++++++++ src/utils/utils.ts | 19 ----------- src/visits/VisitsTable.tsx | 21 +++--------- src/visits/charts/SortableBarChartCard.tsx | 3 +- test/tags/TagsTable.test.tsx | 6 ++-- test/tags/TagsTableRow.test.tsx | 11 +++---- test/utils/SortingDropdown.test.tsx | 2 +- test/utils/utils.test.ts | 3 +- .../charts/SortableBarChartCard.test.tsx | 3 +- 17 files changed, 94 insertions(+), 82 deletions(-) create mode 100644 src/utils/helpers/ordering.ts diff --git a/src/short-urls/ShortUrlsList.tsx b/src/short-urls/ShortUrlsList.tsx index 570f57d1..855d2e52 100644 --- a/src/short-urls/ShortUrlsList.tsx +++ b/src/short-urls/ShortUrlsList.tsx @@ -5,7 +5,7 @@ import { FC, useEffect, useState } from 'react'; import { RouteComponentProps } from 'react-router'; import { Card } from 'reactstrap'; import SortingDropdown from '../utils/SortingDropdown'; -import { determineOrderDir, OrderDir } from '../utils/utils'; +import { determineOrderDir, OrderDir } from '../utils/helpers/ordering'; import { getServerId, SelectedServer } from '../servers/data'; import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub'; import { parseQuery } from '../utils/helpers/query'; diff --git a/src/short-urls/reducers/shortUrlsListParams.ts b/src/short-urls/reducers/shortUrlsListParams.ts index 0dd034aa..1f16e562 100644 --- a/src/short-urls/reducers/shortUrlsListParams.ts +++ b/src/short-urls/reducers/shortUrlsListParams.ts @@ -1,5 +1,5 @@ import { buildActionCreator, buildReducer } from '../../utils/helpers/redux'; -import { OrderDir } from '../../utils/utils'; +import { OrderDir } from '../../utils/helpers/ordering'; import { LIST_SHORT_URLS, ListShortUrlsAction } from './shortUrlsList'; export const RESET_SHORT_URL_PARAMS = 'shlink/shortUrlsListParams/RESET_SHORT_URL_PARAMS'; diff --git a/src/tags/TagsTable.scss b/src/tags/TagsTable.scss index 94728f7b..a6e4dceb 100644 --- a/src/tags/TagsTable.scss +++ b/src/tags/TagsTable.scss @@ -6,4 +6,5 @@ top: $headerHeight; position: sticky; + cursor: pointer; } diff --git a/src/tags/TagsTable.tsx b/src/tags/TagsTable.tsx index 7caec4af..2208f669 100644 --- a/src/tags/TagsTable.tsx +++ b/src/tags/TagsTable.tsx @@ -1,24 +1,37 @@ -import { FC, useEffect, useRef } from 'react'; +import { FC, useEffect, useMemo, useRef, useState } from 'react'; import { splitEvery } from 'ramda'; import { RouteChildrenProps } from 'react-router'; import { SimpleCard } from '../utils/SimpleCard'; -import ColorGenerator from '../utils/services/ColorGenerator'; import SimplePaginator from '../common/SimplePaginator'; import { useQueryState } from '../utils/helpers/hooks'; import { parseQuery } from '../utils/helpers/query'; +import { Order, sortList } from '../utils/helpers/ordering'; import { TagsListChildrenProps } from './data/TagsListChildrenProps'; import { TagsTableRowProps } from './TagsTableRow'; +import { NormalizedTag } from './data'; import './TagsTable.scss'; const TAGS_PER_PAGE = 20; // TODO Allow customizing this value in settings -export const TagsTable = (colorGenerator: ColorGenerator, TagsTableRow: FC) => ( +type OrderableFields = 'tag' | 'shortUrls' | 'visits'; +type TagsOrder = Order; + +export const TagsTable = (TagsTableRow: FC) => ( { tagsList, selectedServer, location }: TagsListChildrenProps & RouteChildrenProps, ) => { const isFirstLoad = useRef(true); const { page: pageFromQuery = 1 } = parseQuery<{ page?: number | string }>(location.search); const [ page, setPage ] = useQueryState('page', Number(pageFromQuery)); - const sortedTags = tagsList.filteredTags; // TODO Support sorting tags + const [ order ] = useState({}); + const normalizedTags = useMemo( + () => tagsList.filteredTags.map((tag): NormalizedTag => ({ + tag, + shortUrls: tagsList.stats[tag]?.shortUrlsCount ?? 0, + visits: tagsList.stats[tag]?.visitsCount ?? 0, + })), + [ tagsList.filteredTags ], + ); + const sortedTags = sortList(normalizedTags, order); const pages = splitEvery(TAGS_PER_PAGE, sortedTags); const showPaginator = pages.length > 1; const currentPage = pages[page - 1] ?? []; @@ -45,15 +58,7 @@ export const TagsTable = (colorGenerator: ColorGenerator, TagsTableRow: FC {currentPage.length === 0 && No results found} - {currentPage.map((tag) => ( - - ))} + {currentPage.map((tag) => )} diff --git a/src/tags/TagsTableRow.tsx b/src/tags/TagsTableRow.tsx index dff5f936..c030e4f8 100644 --- a/src/tags/TagsTableRow.tsx +++ b/src/tags/TagsTableRow.tsx @@ -9,18 +9,18 @@ import { prettify } from '../utils/helpers/numbers'; import { useToggle } from '../utils/helpers/hooks'; import { DropdownBtnMenu } from '../utils/DropdownBtnMenu'; import TagBullet from './helpers/TagBullet'; -import { TagModalProps, TagStats } from './data'; +import { NormalizedTag, TagModalProps } from './data'; export interface TagsTableRowProps { - tag: string; - tagStats?: TagStats; + tag: NormalizedTag; selectedServer: SelectedServer; - colorGenerator: ColorGenerator; } -export const TagsTableRow = (DeleteTagConfirmModal: FC, EditTagModal: FC) => ( - { tag, tagStats, colorGenerator, selectedServer }: TagsTableRowProps, -) => { +export const TagsTableRow = ( + DeleteTagConfirmModal: FC, + EditTagModal: FC, + colorGenerator: ColorGenerator, +) => ({ tag, selectedServer }: TagsTableRowProps) => { const [ isDeleteModalOpen, toggleDelete ] = useToggle(); const [ isEditModalOpen, toggleEdit ] = useToggle(); const [ isDropdownOpen, toggleDropdown ] = useToggle(); @@ -29,16 +29,16 @@ export const TagsTableRow = (DeleteTagConfirmModal: FC, EditTagMo return ( - {tag} + {tag.tag} - - {prettify(tagStats?.shortUrlsCount ?? 0)} + + {prettify(tag.shortUrls)} - - {prettify(tagStats?.visitsCount ?? 0)} + + {prettify(tag.visits)} @@ -52,8 +52,8 @@ export const TagsTableRow = (DeleteTagConfirmModal: FC, EditTagMo - - + + ); }; diff --git a/src/tags/data/index.ts b/src/tags/data/index.ts index 2a1e1aa8..8210a98b 100644 --- a/src/tags/data/index.ts +++ b/src/tags/data/index.ts @@ -8,3 +8,9 @@ export interface TagModalProps { isOpen: boolean; toggle: () => void; } + +export interface NormalizedTag { + tag: string; + shortUrls: number; + visits: number; +} diff --git a/src/tags/services/provideServices.ts b/src/tags/services/provideServices.ts index 1e3e8d39..0068d9d6 100644 --- a/src/tags/services/provideServices.ts +++ b/src/tags/services/provideServices.ts @@ -27,9 +27,9 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.decorator('EditTagModal', connect([ 'tagEdit' ], [ 'editTag', 'tagEdited' ])); bottle.serviceFactory('TagsCards', TagsCards, 'TagCard'); - bottle.serviceFactory('TagsTableRow', TagsTableRow, 'DeleteTagConfirmModal', 'EditTagModal'); + bottle.serviceFactory('TagsTableRow', TagsTableRow, 'DeleteTagConfirmModal', 'EditTagModal', 'ColorGenerator'); - bottle.serviceFactory('TagsTable', TagsTable, 'ColorGenerator', 'TagsTableRow'); + bottle.serviceFactory('TagsTable', TagsTable, 'TagsTableRow'); bottle.decorator('TagsTable', withRouter); bottle.serviceFactory('TagsList', TagsList, 'TagsCards', 'TagsTable'); diff --git a/src/utils/SortingDropdown.tsx b/src/utils/SortingDropdown.tsx index d9e06ec3..bba0bed7 100644 --- a/src/utils/SortingDropdown.tsx +++ b/src/utils/SortingDropdown.tsx @@ -3,7 +3,7 @@ import { toPairs } from 'ramda'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faSortAmountUp as sortAscIcon, faSortAmountDown as sortDescIcon } from '@fortawesome/free-solid-svg-icons'; import classNames from 'classnames'; -import { determineOrderDir, OrderDir } from './utils'; +import { determineOrderDir, OrderDir } from './helpers/ordering'; import './SortingDropdown.scss'; export interface SortingDropdownProps { diff --git a/src/utils/helpers/ordering.ts b/src/utils/helpers/ordering.ts new file mode 100644 index 00000000..f3f19cf3 --- /dev/null +++ b/src/utils/helpers/ordering.ts @@ -0,0 +1,32 @@ +export type OrderDir = 'ASC' | 'DESC' | undefined; + +export interface Order { + field?: Fields; + dir?: OrderDir; +} + +export const determineOrderDir = ( + currentField: T, + newField?: T, + currentOrderDir?: OrderDir, +): OrderDir => { + if (currentField !== newField) { + return 'ASC'; + } + + const newOrderMap: Record<'ASC' | 'DESC', OrderDir> = { + ASC: 'DESC', + DESC: undefined, + }; + + return currentOrderDir ? newOrderMap[currentOrderDir] : 'ASC'; +}; + +export const sortList = (list: List[], { field, dir }: Order>) => !field || !dir + ? list + : list.sort((a, b) => { + const greaterThan = dir === 'ASC' ? 1 : -1; + const smallerThan = dir === 'ASC' ? -1 : 1; + + return a[field] > b[field] ? greaterThan : smallerThan; + }); diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 2b62871e..e341e51d 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -1,25 +1,6 @@ import { isEmpty, isNil, pipe, range } from 'ramda'; import { SyntheticEvent } from 'react'; -export type OrderDir = 'ASC' | 'DESC' | undefined; - -export const determineOrderDir = ( - currentField: T, - newField?: T, - currentOrderDir?: OrderDir, -): OrderDir => { - if (currentField !== newField) { - return 'ASC'; - } - - const newOrderMap: Record<'ASC' | 'DESC', OrderDir> = { - ASC: 'DESC', - DESC: undefined, - }; - - return currentOrderDir ? newOrderMap[currentOrderDir] : 'ASC'; -}; - export const rangeOf = (size: number, mappingFn: (value: number) => T, startAt = 1): T[] => range(startAt, size + 1).map(mappingFn); diff --git a/src/visits/VisitsTable.tsx b/src/visits/VisitsTable.tsx index ed791f67..202f1357 100644 --- a/src/visits/VisitsTable.tsx +++ b/src/visits/VisitsTable.tsx @@ -11,7 +11,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { UncontrolledTooltip } from 'reactstrap'; import SimplePaginator from '../common/SimplePaginator'; import SearchField from '../utils/SearchField'; -import { determineOrderDir, OrderDir } from '../utils/utils'; +import { determineOrderDir, Order, sortList } from '../utils/helpers/ordering'; import { prettify } from '../utils/helpers/numbers'; import { supportsBotVisits } from '../utils/helpers/features'; import { SelectedServer } from '../servers/data'; @@ -29,11 +29,7 @@ export interface VisitsTableProps { } type OrderableFields = 'date' | 'country' | 'city' | 'browser' | 'os' | 'referer' | 'visitedUrl' | 'potentialBot'; - -interface Order { - field?: OrderableFields; - dir?: OrderDir; -} +type VisitsOrder = Order; const PAGE_SIZE = 20; const visitMatchesSearch = ({ browser, os, referer, country, city, ...rest }: NormalizedVisit, searchTerm: string) => @@ -42,15 +38,8 @@ const visitMatchesSearch = ({ browser, os, referer, country, city, ...rest }: No ); const searchVisits = (searchTerm: string, visits: NormalizedVisit[]) => visits.filter((visit) => visitMatchesSearch(visit, searchTerm)); -const sortVisits = ({ field, dir }: Order, visits: NormalizedVisit[]) => !field || !dir ? visits : visits.sort( - (a, b) => { - const greaterThan = dir === 'ASC' ? 1 : -1; - const smallerThan = dir === 'ASC' ? -1 : 1; - - return (a as NormalizedOrphanVisit)[field] > (b as NormalizedOrphanVisit)[field] ? greaterThan : smallerThan; - }, -); -const calculateVisits = (allVisits: NormalizedVisit[], searchTerm: string | undefined, order: Order) => { +const sortVisits = (order: VisitsOrder, visits: NormalizedVisit[]) => sortList(visits, order as any); +const calculateVisits = (allVisits: NormalizedVisit[], searchTerm: string | undefined, order: VisitsOrder) => { const filteredVisits = searchTerm ? searchVisits(searchTerm, allVisits) : [ ...allVisits ]; const sortedVisits = sortVisits(order, filteredVisits); const total = sortedVisits.length; @@ -72,7 +61,7 @@ const VisitsTable = ({ const [ isMobileDevice, setIsMobileDevice ] = useState(matchMobile()); const [ searchTerm, setSearchTerm ] = useState(undefined); - const [ order, setOrder ] = useState({ field: undefined, dir: undefined }); + const [ order, setOrder ] = useState({}); const resultSet = useMemo(() => calculateVisits(visits, searchTerm, order), [ searchTerm, order ]); const isFirstLoad = useRef(true); const [ page, setPage ] = useState(1); diff --git a/src/visits/charts/SortableBarChartCard.tsx b/src/visits/charts/SortableBarChartCard.tsx index e0f1bee5..b300af18 100644 --- a/src/visits/charts/SortableBarChartCard.tsx +++ b/src/visits/charts/SortableBarChartCard.tsx @@ -1,6 +1,7 @@ import { FC, useState } from 'react'; import { fromPairs, pipe, reverse, sortBy, splitEvery, toLower, toPairs, type, zipObj } from 'ramda'; -import { OrderDir, rangeOf } from '../../utils/utils'; +import { rangeOf } from '../../utils/utils'; +import { OrderDir } from '../../utils/helpers/ordering'; import SimplePaginator from '../../common/SimplePaginator'; import { roundTen } from '../../utils/helpers/numbers'; import SortingDropdown from '../../utils/SortingDropdown'; diff --git a/test/tags/TagsTable.test.tsx b/test/tags/TagsTable.test.tsx index ab674edc..8530406c 100644 --- a/test/tags/TagsTable.test.tsx +++ b/test/tags/TagsTable.test.tsx @@ -2,7 +2,6 @@ import { Mock } from 'ts-mockery'; import { shallow, ShallowWrapper } from 'enzyme'; import { match } from 'react-router'; import { Location, History } from 'history'; -import ColorGenerator from '../../src/utils/services/ColorGenerator'; import { TagsTable as createTagsTable } from '../../src/tags/TagsTable'; import { SelectedServer } from '../../src/servers/data'; import { TagsList } from '../../src/tags/reducers/tagsList'; @@ -10,9 +9,8 @@ import { rangeOf } from '../../src/utils/utils'; import SimplePaginator from '../../src/common/SimplePaginator'; describe('', () => { - const colorGenerator = Mock.all(); const TagsTableRow = () => null; - const TagsTable = createTagsTable(colorGenerator, TagsTableRow); + const TagsTable = createTagsTable(TagsTableRow); const tags = (amount: number) => rangeOf(amount, (i) => `tag_${i}`); let wrapper: ShallowWrapper; const createWrapper = (filteredTags: string[] = [], search = '') => { @@ -86,7 +84,7 @@ describe('', () => { expect(tagRows).toHaveLength(expectedRows); tagRows.forEach((row, index) => { - expect(row.prop('tag')).toEqual(`tag_${index + offset + 1}`); + expect(row.prop('tag')).toEqual(expect.objectContaining({ tag: `tag_${index + offset + 1}` })); }); }); diff --git a/test/tags/TagsTableRow.test.tsx b/test/tags/TagsTableRow.test.tsx index 9787fbf9..d264bd32 100644 --- a/test/tags/TagsTableRow.test.tsx +++ b/test/tags/TagsTableRow.test.tsx @@ -5,21 +5,18 @@ import { DropdownItem } from 'reactstrap'; import { TagsTableRow as createTagsTableRow } from '../../src/tags/TagsTableRow'; import { ReachableServer } from '../../src/servers/data'; import ColorGenerator from '../../src/utils/services/ColorGenerator'; -import { TagStats } from '../../src/tags/data'; import { DropdownBtnMenu } from '../../src/utils/DropdownBtnMenu'; describe('', () => { const DeleteTagConfirmModal = () => null; const EditTagModal = () => null; - const TagsTableRow = createTagsTableRow(DeleteTagConfirmModal, EditTagModal); + const TagsTableRow = createTagsTableRow(DeleteTagConfirmModal, EditTagModal, Mock.all()); let wrapper: ShallowWrapper; - const createWrapper = (tagStats?: TagStats) => { + const createWrapper = (tagStats?: { visits?: number; shortUrls?: number }) => { wrapper = shallow( ({ id: 'abc123' })} - colorGenerator={Mock.all()} />, ); @@ -30,7 +27,7 @@ describe('', () => { it.each([ [ undefined, '0', '0' ], - [ Mock.of({ shortUrlsCount: 10, visitsCount: 3480 }), '10', '3,480' ], + [{ shortUrls: 10, visits: 3480 }, '10', '3,480' ], ])('shows expected tag stats', (stats, expectedShortUrls, expectedVisits) => { const wrapper = createWrapper(stats); const links = wrapper.find(Link); diff --git a/test/utils/SortingDropdown.test.tsx b/test/utils/SortingDropdown.test.tsx index 4f85c013..518b5f12 100644 --- a/test/utils/SortingDropdown.test.tsx +++ b/test/utils/SortingDropdown.test.tsx @@ -4,7 +4,7 @@ import { identity, values } from 'ramda'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faSortAmountDown as caretDownIcon } from '@fortawesome/free-solid-svg-icons'; import SortingDropdown, { SortingDropdownProps } from '../../src/utils/SortingDropdown'; -import { OrderDir } from '../../src/utils/utils'; +import { OrderDir } from '../../src/utils/helpers/ordering'; describe('', () => { let wrapper: ShallowWrapper; diff --git a/test/utils/utils.test.ts b/test/utils/utils.test.ts index 22dcb8a4..44ad7b37 100644 --- a/test/utils/utils.test.ts +++ b/test/utils/utils.test.ts @@ -1,4 +1,5 @@ -import { capitalize, determineOrderDir, nonEmptyValueOrNull, rangeOf } from '../../src/utils/utils'; +import { capitalize, nonEmptyValueOrNull, rangeOf } from '../../src/utils/utils'; +import { determineOrderDir } from '../../src/utils/helpers/ordering'; describe('utils', () => { describe('determineOrderDir', () => { diff --git a/test/visits/charts/SortableBarChartCard.test.tsx b/test/visits/charts/SortableBarChartCard.test.tsx index f3b4e1f2..338b220b 100644 --- a/test/visits/charts/SortableBarChartCard.test.tsx +++ b/test/visits/charts/SortableBarChartCard.test.tsx @@ -2,7 +2,8 @@ import { shallow, ShallowWrapper } from 'enzyme'; import { range } from 'ramda'; import SortingDropdown from '../../../src/utils/SortingDropdown'; import PaginationDropdown from '../../../src/utils/PaginationDropdown'; -import { OrderDir, rangeOf } from '../../../src/utils/utils'; +import { rangeOf } from '../../../src/utils/utils'; +import { OrderDir } from '../../../src/utils/helpers/ordering'; import { Stats } from '../../../src/visits/types'; import { SortableBarChartCard } from '../../../src/visits/charts/SortableBarChartCard'; import { HorizontalBarChart } from '../../../src/visits/charts/HorizontalBarChart'; From 04571ea6344ee9f588b03cb274ad1305ba785b9b Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 1 Nov 2021 13:41:16 +0100 Subject: [PATCH 4/7] Added logic to order tags list --- src/short-urls/ShortUrlsList.scss | 3 -- src/short-urls/ShortUrlsList.tsx | 41 +++++++++----------------- src/short-urls/ShortUrlsTable.tsx | 17 ++++------- src/tags/TagsTable.tsx | 41 +++++++++++++++++--------- test/short-urls/ShortUrlsList.test.tsx | 6 ++-- 5 files changed, 50 insertions(+), 58 deletions(-) delete mode 100644 src/short-urls/ShortUrlsList.scss diff --git a/src/short-urls/ShortUrlsList.scss b/src/short-urls/ShortUrlsList.scss deleted file mode 100644 index 5b1be099..00000000 --- a/src/short-urls/ShortUrlsList.scss +++ /dev/null @@ -1,3 +0,0 @@ -.short-urls-list__header-icon { - margin-left: .4rem; -} diff --git a/src/short-urls/ShortUrlsList.tsx b/src/short-urls/ShortUrlsList.tsx index 855d2e52..e6a5eedc 100644 --- a/src/short-urls/ShortUrlsList.tsx +++ b/src/short-urls/ShortUrlsList.tsx @@ -5,7 +5,7 @@ import { FC, useEffect, useState } from 'react'; import { RouteComponentProps } from 'react-router'; import { Card } from 'reactstrap'; import SortingDropdown from '../utils/SortingDropdown'; -import { determineOrderDir, OrderDir } from '../utils/helpers/ordering'; +import { determineOrderDir, Order, OrderDir } from '../utils/helpers/ordering'; import { getServerId, SelectedServer } from '../servers/data'; import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub'; import { parseQuery } from '../utils/helpers/query'; @@ -14,7 +14,6 @@ import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList'; import { OrderableFields, ShortUrlsListParams, SORTABLE_FIELDS } from './reducers/shortUrlsListParams'; import { ShortUrlsTableProps } from './ShortUrlsTable'; import Paginator from './Paginator'; -import './ShortUrlsList.scss'; interface RouteParams { page: string; @@ -29,6 +28,8 @@ export interface ShortUrlsListProps extends RouteComponentProps { resetShortUrlParams: () => void; } +type ShortUrlsOrder = Order; + const ShortUrlsList = (ShortUrlsTable: FC) => boundToMercureHub(({ listShortUrls, resetShortUrlParams, @@ -39,34 +40,20 @@ const ShortUrlsList = (ShortUrlsTable: FC) => boundToMercur selectedServer, }: ShortUrlsListProps) => { const { orderBy } = shortUrlsListParams; - const [ order, setOrder ] = useState<{ orderField?: OrderableFields; orderDir?: OrderDir }>({ - orderField: orderBy && (head(keys(orderBy)) as OrderableFields), - orderDir: orderBy && head(values(orderBy)), + const [ order, setOrder ] = useState({ + field: orderBy && (head(keys(orderBy)) as OrderableFields), + dir: orderBy && head(values(orderBy)), }); const { pagination } = shortUrlsList?.shortUrls ?? {}; const refreshList = (extraParams: ShortUrlsListParams) => listShortUrls({ ...shortUrlsListParams, ...extraParams }); - const handleOrderBy = (orderField?: OrderableFields, orderDir?: OrderDir) => { - setOrder({ orderField, orderDir }); - refreshList({ orderBy: orderField ? { [orderField]: orderDir } : undefined }); + const handleOrderBy = (field?: OrderableFields, dir?: OrderDir) => { + setOrder({ field, dir }); + refreshList({ orderBy: field ? { [field]: dir } : undefined }); }; const orderByColumn = (field: OrderableFields) => () => - handleOrderBy(field, determineOrderDir(field, order.orderField, order.orderDir)); - const renderOrderIcon = (field: OrderableFields) => { - if (order.orderField !== field) { - return null; - } - - if (!order.orderDir) { - return null; - } - - return ( - - ); - }; + handleOrderBy(field, determineOrderDir(field, order.field, order.dir)); + const renderOrderIcon = (field: OrderableFields) => order.dir && order.field === field && + ; useEffect(() => { const { tag } = parseQuery<{ tag?: string }>(location.search); @@ -82,8 +69,8 @@ const ShortUrlsList = (ShortUrlsTable: FC) => boundToMercur
diff --git a/src/short-urls/ShortUrlsTable.tsx b/src/short-urls/ShortUrlsTable.tsx index acf5d8fd..5a51db85 100644 --- a/src/short-urls/ShortUrlsTable.tsx +++ b/src/short-urls/ShortUrlsTable.tsx @@ -63,34 +63,29 @@ export const ShortUrlsTable = (ShortUrlsRow: FC) => ({ - Created at - {renderOrderIcon?.('dateCreated')} + Created at {renderOrderIcon?.('dateCreated')} - Short URL - {renderOrderIcon?.('shortCode')} + Short URL {renderOrderIcon?.('shortCode')} {!supportsTitle && ( - Long URL - {renderOrderIcon?.('longUrl')} + Long URL {renderOrderIcon?.('longUrl')} ) || ( - Title - {renderOrderIcon?.('title')} + Title {renderOrderIcon?.('title')}   /   - Long URL - {renderOrderIcon?.('longUrl')} + Long URL {renderOrderIcon?.('longUrl')} )} Tags - Visits{renderOrderIcon?.('visits')} + Visits {renderOrderIcon?.('visits')}   diff --git a/src/tags/TagsTable.tsx b/src/tags/TagsTable.tsx index 2208f669..e81d59c9 100644 --- a/src/tags/TagsTable.tsx +++ b/src/tags/TagsTable.tsx @@ -1,11 +1,13 @@ import { FC, useEffect, useMemo, useRef, useState } from 'react'; -import { splitEvery } from 'ramda'; +import { pipe, splitEvery } from 'ramda'; import { RouteChildrenProps } from 'react-router'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faCaretDown as caretDownIcon, faCaretUp as caretUpIcon } from '@fortawesome/free-solid-svg-icons'; import { SimpleCard } from '../utils/SimpleCard'; import SimplePaginator from '../common/SimplePaginator'; import { useQueryState } from '../utils/helpers/hooks'; import { parseQuery } from '../utils/helpers/query'; -import { Order, sortList } from '../utils/helpers/ordering'; +import { determineOrderDir, Order, sortList } from '../utils/helpers/ordering'; import { TagsListChildrenProps } from './data/TagsListChildrenProps'; import { TagsTableRowProps } from './TagsTableRow'; import { NormalizedTag } from './data'; @@ -22,20 +24,27 @@ export const TagsTable = (TagsTableRow: FC) => ( const isFirstLoad = useRef(true); const { page: pageFromQuery = 1 } = parseQuery<{ page?: number | string }>(location.search); const [ page, setPage ] = useQueryState('page', Number(pageFromQuery)); - const [ order ] = useState({}); - const normalizedTags = useMemo( - () => tagsList.filteredTags.map((tag): NormalizedTag => ({ - tag, - shortUrls: tagsList.stats[tag]?.shortUrlsCount ?? 0, - visits: tagsList.stats[tag]?.visitsCount ?? 0, - })), - [ tagsList.filteredTags ], + const [ order, setOrder ] = useState({}); + const sortedTags = useMemo( + pipe( + () => tagsList.filteredTags.map((tag): NormalizedTag => ({ + tag, + shortUrls: tagsList.stats[tag]?.shortUrlsCount ?? 0, + visits: tagsList.stats[tag]?.visitsCount ?? 0, + })), + (normalizedTags) => sortList(normalizedTags, order), + ), + [ tagsList.filteredTags, order ], ); - const sortedTags = sortList(normalizedTags, order); const pages = splitEvery(TAGS_PER_PAGE, sortedTags); const showPaginator = pages.length > 1; const currentPage = pages[page - 1] ?? []; + const orderByColumn = (field: OrderableFields) => + () => setOrder({ field, dir: determineOrderDir(field, order.field, order.dir) }); + const renderOrderIcon = (field: OrderableFields) => order.dir && order.field === field && + ; + useEffect(() => { !isFirstLoad.current && setPage(1); isFirstLoad.current = false; @@ -49,9 +58,13 @@ export const TagsTable = (TagsTableRow: FC) => ( - - - + + + diff --git a/test/short-urls/ShortUrlsList.test.tsx b/test/short-urls/ShortUrlsList.test.tsx index f7286649..09ff55d4 100644 --- a/test/short-urls/ShortUrlsList.test.tsx +++ b/test/short-urls/ShortUrlsList.test.tsx @@ -79,13 +79,13 @@ describe('', () => { const renderIcon = (field: OrderableFields) => (wrapper.find(ShortUrlsTable).prop('renderOrderIcon') as (field: OrderableFields) => ReactElement | null)(field); - expect(renderIcon('visits')).toEqual(null); + expect(renderIcon('visits')).toEqual(undefined); wrapper.find(SortingDropdown).simulate('change', 'visits'); - expect(renderIcon('visits')).toEqual(null); + expect(renderIcon('visits')).toEqual(undefined); wrapper.find(SortingDropdown).simulate('change', 'visits', 'ASC'); - expect(renderIcon('visits')).not.toEqual(null); + expect(renderIcon('visits')).not.toEqual(undefined); }); it('handles order by through table', () => { From 2857e5927355fd96282facc62cb5ee9db9439e70 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 1 Nov 2021 13:45:30 +0100 Subject: [PATCH 5/7] Updated changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d3a6bde..808fbb81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), * [#500](https://github.com/shlinkio/shlink-web-client/issues/500) Allowed to set the `forwardQuery` flag when creating/editing short URLs on a Shlink v2.9.0 server. * [#508](https://github.com/shlinkio/shlink-web-client/issues/508) Added new servers management section. * [#490](https://github.com/shlinkio/shlink-web-client/issues/490) Now a server can be marked as auto-connect, skipping home screen when that happens. +* [#492](https://github.com/shlinkio/shlink-web-client/issues/492) Improved tags table, by supporting sorting by column and making the header sticky. ### Changed * Moved ci workflow to external repo and reused From 9cbeef1cb48d8e8984f2b4144a384b89cd4684d0 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 1 Nov 2021 13:57:48 +0100 Subject: [PATCH 6/7] Moved test to the right place --- test/utils/helpers/ordering.test.ts | 25 +++++++++++++++++++++++++ test/utils/utils.test.ts | 23 ----------------------- 2 files changed, 25 insertions(+), 23 deletions(-) create mode 100644 test/utils/helpers/ordering.test.ts diff --git a/test/utils/helpers/ordering.test.ts b/test/utils/helpers/ordering.test.ts new file mode 100644 index 00000000..147d8d1f --- /dev/null +++ b/test/utils/helpers/ordering.test.ts @@ -0,0 +1,25 @@ +import { determineOrderDir } from '../../../src/utils/helpers/ordering'; + +describe('ordering', () => { + describe('determineOrderDir', () => { + it('returns ASC when current order field and selected field are different', () => { + expect(determineOrderDir('foo', 'bar')).toEqual('ASC'); + expect(determineOrderDir('bar', 'foo')).toEqual('ASC'); + }); + + it('returns ASC when no current order dir is provided', () => { + expect(determineOrderDir('foo', 'foo')).toEqual('ASC'); + expect(determineOrderDir('bar', 'bar')).toEqual('ASC'); + }); + + it('returns DESC when current order field and selected field are equal and current order dir is ASC', () => { + expect(determineOrderDir('foo', 'foo', 'ASC')).toEqual('DESC'); + expect(determineOrderDir('bar', 'bar', 'ASC')).toEqual('DESC'); + }); + + it('returns undefined when current order field and selected field are equal and current order dir is DESC', () => { + expect(determineOrderDir('foo', 'foo', 'DESC')).toBeUndefined(); + expect(determineOrderDir('bar', 'bar', 'DESC')).toBeUndefined(); + }); + }); +}); diff --git a/test/utils/utils.test.ts b/test/utils/utils.test.ts index 44ad7b37..7567abee 100644 --- a/test/utils/utils.test.ts +++ b/test/utils/utils.test.ts @@ -1,29 +1,6 @@ import { capitalize, nonEmptyValueOrNull, rangeOf } from '../../src/utils/utils'; -import { determineOrderDir } from '../../src/utils/helpers/ordering'; describe('utils', () => { - describe('determineOrderDir', () => { - it('returns ASC when current order field and selected field are different', () => { - expect(determineOrderDir('foo', 'bar')).toEqual('ASC'); - expect(determineOrderDir('bar', 'foo')).toEqual('ASC'); - }); - - it('returns ASC when no current order dir is provided', () => { - expect(determineOrderDir('foo', 'foo')).toEqual('ASC'); - expect(determineOrderDir('bar', 'bar')).toEqual('ASC'); - }); - - it('returns DESC when current order field and selected field are equal and current order dir is ASC', () => { - expect(determineOrderDir('foo', 'foo', 'ASC')).toEqual('DESC'); - expect(determineOrderDir('bar', 'bar', 'ASC')).toEqual('DESC'); - }); - - it('returns undefined when current order field and selected field are equal and current order dir is DESC', () => { - expect(determineOrderDir('foo', 'foo', 'DESC')).toBeUndefined(); - expect(determineOrderDir('bar', 'bar', 'DESC')).toBeUndefined(); - }); - }); - describe('rangeOf', () => { const func = (i: number) => `result_${i}`; const size = 5; From 39d5853fe3529508fc4e15b3fd4431b9f494aef7 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 1 Nov 2021 14:10:57 +0100 Subject: [PATCH 7/7] Added tests for ordering logic in TagsTable --- test/tags/TagsTable.test.tsx | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/test/tags/TagsTable.test.tsx b/test/tags/TagsTable.test.tsx index 8530406c..a1a2e52c 100644 --- a/test/tags/TagsTable.test.tsx +++ b/test/tags/TagsTable.test.tsx @@ -7,6 +7,7 @@ import { SelectedServer } from '../../src/servers/data'; import { TagsList } from '../../src/tags/reducers/tagsList'; import { rangeOf } from '../../src/utils/utils'; import SimplePaginator from '../../src/common/SimplePaginator'; +import { NormalizedTag } from '../../src/tags/data'; describe('', () => { const TagsTableRow = () => null; @@ -95,4 +96,25 @@ describe('', () => { (wrapper.find(SimplePaginator).prop('setCurrentPage') as Function)(5); expect(wrapper.find(SimplePaginator).prop('currentPage')).toEqual(5); }); + + it('orders tags when column is clicked', () => { + const wrapper = createWrapper(tags(100)); + const firstRowText = () => (wrapper.find('tbody').find(TagsTableRow).first().prop('tag') as NormalizedTag).tag; + + expect(firstRowText()).toEqual('tag_1'); + wrapper.find('thead').find('th').first().simulate('click'); // Tag column ASC + expect(firstRowText()).toEqual('tag_1'); + wrapper.find('thead').find('th').first().simulate('click'); // Tag column DESC + expect(firstRowText()).toEqual('tag_99'); + wrapper.find('thead').find('th').at(2).simulate('click'); // Visits column - ASC + expect(firstRowText()).toEqual('tag_100'); + wrapper.find('thead').find('th').at(2).simulate('click'); // Visits column - DESC + expect(firstRowText()).toEqual('tag_1'); + wrapper.find('thead').find('th').at(2).simulate('click'); // Visits column - reset + expect(firstRowText()).toEqual('tag_1'); + wrapper.find('thead').find('th').at(1).simulate('click'); // Short URLs column - ASC + expect(firstRowText()).toEqual('tag_100'); + wrapper.find('thead').find('th').at(1).simulate('click'); // Short URLs column - DESC + expect(firstRowText()).toEqual('tag_1'); + }); });
TagShort URLsVisitsTag {renderOrderIcon('tag')} + Short URLs {renderOrderIcon('shortUrls')} + + Visits {renderOrderIcon('visits')} +