diff --git a/src/api/services/ShlinkApiClient.ts b/src/api/services/ShlinkApiClient.ts index 192bad0f..330bfb55 100644 --- a/src/api/services/ShlinkApiClient.ts +++ b/src/api/services/ShlinkApiClient.ts @@ -24,12 +24,11 @@ const buildShlinkBaseUrl = (url: string, apiVersion: number) => url ? `${url}/re const rejectNilProps = reject(isNil); const normalizeOrderByInParams = (params: ShlinkShortUrlsListParams): ShlinkShortUrlsListNormalizedParams => { const { orderBy = {}, ...rest } = params; - const [ firstKey ] = Object.keys(orderBy); - const [ firstValue ] = Object.values(orderBy); + const { field, dir } = orderBy; - return !firstValue ? rest : { + return !dir ? rest : { ...rest, - orderBy: `${firstKey}-${firstValue}`, + orderBy: `${field}-${dir}`, }; }; diff --git a/src/api/types/index.ts b/src/api/types/index.ts index cce4751c..af833bb2 100644 --- a/src/api/types/index.ts +++ b/src/api/types/index.ts @@ -1,7 +1,7 @@ import { Visit } from '../../visits/types'; import { OptionalString } from '../../utils/utils'; import { ShortUrl, ShortUrlMeta } from '../../short-urls/data'; -import { OrderBy } from '../../short-urls/reducers/shortUrlsListParams'; +import { ShortUrlsOrder } from '../../short-urls/reducers/shortUrlsListParams'; export interface ShlinkShortUrlsResponse { data: ShortUrl[]; @@ -94,7 +94,7 @@ export interface ShlinkShortUrlsListParams { searchTerm?: string; startDate?: string; endDate?: string; - orderBy?: OrderBy; + orderBy?: ShortUrlsOrder; } export interface ShlinkShortUrlsListNormalizedParams extends Omit { diff --git a/src/servers/Overview.tsx b/src/servers/Overview.tsx index 9b99cc6f..a68d3e17 100644 --- a/src/servers/Overview.tsx +++ b/src/servers/Overview.tsx @@ -44,7 +44,7 @@ export const Overview = ( const history = useHistory(); useEffect(() => { - listShortUrls({ itemsPerPage: 5, orderBy: { dateCreated: 'DESC' } }); + listShortUrls({ itemsPerPage: 5, orderBy: { field: 'dateCreated', dir: 'DESC' } }); listTags(); loadVisitsOverview(); }, []); diff --git a/src/settings/reducers/settings.ts b/src/settings/reducers/settings.ts index 2a7e0fcc..fb2932d6 100644 --- a/src/settings/reducers/settings.ts +++ b/src/settings/reducers/settings.ts @@ -9,6 +9,11 @@ import { ShortUrlsOrder } from '../../short-urls/reducers/shortUrlsListParams'; export const SET_SETTINGS = 'shlink/realTimeUpdates/SET_SETTINGS'; +export const DEFAULT_SHORT_URLS_ORDERING: ShortUrlsOrder = { + field: 'dateCreated', + dir: 'DESC', +}; + /** * Important! When adding new props in the main Settings interface or any of the nested props, they have to be set as * optional, as old instances of the app will load partial objects from local storage until it is saved again. @@ -68,6 +73,9 @@ const initialState: Settings = { visits: { defaultInterval: 'last30Days', }, + shortUrlList: { + defaultOrdering: DEFAULT_SHORT_URLS_ORDERING, + }, }; type SettingsAction = Action & Settings; diff --git a/src/short-urls/ShortUrlsList.tsx b/src/short-urls/ShortUrlsList.tsx index f7198a44..fe0623e7 100644 --- a/src/short-urls/ShortUrlsList.tsx +++ b/src/short-urls/ShortUrlsList.tsx @@ -1,4 +1,4 @@ -import { head, keys, pipe, values } from 'ramda'; +import { pipe } from 'ramda'; import { FC, useEffect, useMemo, useState } from 'react'; import { RouteComponentProps } from 'react-router'; import { Card } from 'reactstrap'; @@ -9,6 +9,7 @@ import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub'; import { Topics } from '../mercure/helpers/Topics'; import { TableOrderIcon } from '../utils/table/TableOrderIcon'; import { ShlinkShortUrlsListParams } from '../api/types'; +import { DEFAULT_SHORT_URLS_ORDERING, Settings } from '../settings/reducers/settings'; import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList'; import { OrderableFields, ShortUrlsListParams, ShortUrlsOrder, SORTABLE_FIELDS } from './reducers/shortUrlsListParams'; import { ShortUrlsTableProps } from './ShortUrlsTable'; @@ -18,9 +19,10 @@ import { ShortUrlListRouteParams, useShortUrlsQuery } from './helpers/hooks'; interface ShortUrlsListProps extends RouteComponentProps { selectedServer: SelectedServer; shortUrlsList: ShortUrlsListState; - listShortUrls: (params: ShortUrlsListParams) => void; + listShortUrls: (params: ShlinkShortUrlsListParams) => void; shortUrlsListParams: ShortUrlsListParams; resetShortUrlParams: () => void; + settings: Settings; } const ShortUrlsList = (ShortUrlsTable: FC, SearchBar: FC) => boundToMercureHub(({ @@ -32,24 +34,17 @@ const ShortUrlsList = (ShortUrlsTable: FC, SearchBar: FC) = history, shortUrlsList, selectedServer, + settings, }: ShortUrlsListProps) => { const serverId = getServerId(selectedServer); const { orderBy } = shortUrlsListParams; - const [ order, setOrder ] = useState({ - field: orderBy && (head(keys(orderBy)) as OrderableFields), - dir: orderBy && head(values(orderBy)), - }); + const initialOrderBy = orderBy ?? settings.shortUrlList?.defaultOrdering ?? DEFAULT_SHORT_URLS_ORDERING; + const [ order, setOrder ] = useState(initialOrderBy); const [{ tags, search, startDate, endDate }, toFirstPage ] = useShortUrlsQuery({ history, match, location }); const selectedTags = useMemo(() => tags?.split(',') ?? [], [ tags ]); const { pagination } = shortUrlsList?.shortUrls ?? {}; - const refreshList = (extraParams: ShlinkShortUrlsListParams) => listShortUrls( - { ...shortUrlsListParams, ...extraParams }, - ); - const handleOrderBy = (field?: OrderableFields, dir?: OrderDir) => { - setOrder({ field, dir }); - refreshList({ orderBy: field ? { [field]: dir } : undefined }); - }; + const handleOrderBy = (field?: OrderableFields, dir?: OrderDir) => setOrder({ field, dir }); const orderByColumn = (field: OrderableFields) => () => handleOrderBy(field, determineOrderDir(field, order.field, order.dir)); const renderOrderIcon = (field: OrderableFields) => ; @@ -60,10 +55,16 @@ const ShortUrlsList = (ShortUrlsTable: FC, SearchBar: FC) = useEffect(() => resetShortUrlParams, []); useEffect(() => { - refreshList( - { page: match.params.page, searchTerm: search, tags: selectedTags, itemsPerPage: undefined, startDate, endDate }, - ); - }, [ match.params.page, search, selectedTags, startDate, endDate ]); + listShortUrls({ + page: match.params.page, + searchTerm: search, + tags: selectedTags, + itemsPerPage: undefined, + startDate, + endDate, + orderBy: order, + }); + }, [ match.params.page, search, selectedTags, startDate, endDate, order ]); return ( <> diff --git a/src/short-urls/reducers/shortUrlsList.ts b/src/short-urls/reducers/shortUrlsList.ts index 72290bea..e50d931c 100644 --- a/src/short-urls/reducers/shortUrlsList.ts +++ b/src/short-urls/reducers/shortUrlsList.ts @@ -7,7 +7,6 @@ import { GetState } from '../../container/types'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { ShlinkShortUrlsListParams, ShlinkShortUrlsResponse } from '../../api/types'; import { DeleteShortUrlAction, SHORT_URL_DELETED } from './shortUrlDeletion'; -import { ShortUrlsListParams } from './shortUrlsListParams'; import { CREATE_SHORT_URL, CreateShortUrlAction } from './shortUrlCreation'; import { SHORT_URL_EDITED, ShortUrlEditedAction } from './shortUrlEdition'; @@ -25,7 +24,7 @@ export interface ShortUrlsList { export interface ListShortUrlsAction extends Action { shortUrls: ShlinkShortUrlsResponse; - params: ShortUrlsListParams; + params: ShlinkShortUrlsListParams; } export type ListShortUrlsCombinedAction = ( diff --git a/src/short-urls/reducers/shortUrlsListParams.ts b/src/short-urls/reducers/shortUrlsListParams.ts index cefc4814..f2c2b0bc 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 { Order, OrderDir } from '../../utils/helpers/ordering'; +import { Order } from '../../utils/helpers/ordering'; import { LIST_SHORT_URLS, ListShortUrlsAction } from './shortUrlsList'; export const RESET_SHORT_URL_PARAMS = 'shlink/shortUrlsListParams/RESET_SHORT_URL_PARAMS'; @@ -16,17 +16,14 @@ export type OrderableFields = keyof typeof SORTABLE_FIELDS; export type ShortUrlsOrder = Order; -export type OrderBy = Partial>; - export interface ShortUrlsListParams { page?: string; itemsPerPage?: number; - orderBy?: OrderBy; + orderBy?: ShortUrlsOrder; } const initialState: ShortUrlsListParams = { page: '1', - orderBy: { dateCreated: 'DESC' }, }; export default buildReducer({ diff --git a/src/short-urls/services/provideServices.ts b/src/short-urls/services/provideServices.ts index bd7c7daf..2394ddd5 100644 --- a/src/short-urls/services/provideServices.ts +++ b/src/short-urls/services/provideServices.ts @@ -22,7 +22,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: // Components bottle.serviceFactory('ShortUrlsList', ShortUrlsList, 'ShortUrlsTable', 'SearchBar'); bottle.decorator('ShortUrlsList', connect( - [ 'selectedServer', 'shortUrlsListParams', 'mercureInfo', 'shortUrlsList' ], + [ 'selectedServer', 'shortUrlsListParams', 'mercureInfo', 'shortUrlsList', 'settings' ], [ 'listShortUrls', 'resetShortUrlParams', 'createNewVisits', 'loadMercureInfo' ], )); diff --git a/test/api/services/ShlinkApiClient.test.ts b/test/api/services/ShlinkApiClient.test.ts index 10090847..11131f97 100644 --- a/test/api/services/ShlinkApiClient.test.ts +++ b/test/api/services/ShlinkApiClient.test.ts @@ -5,7 +5,7 @@ import { OptionalString } from '../../../src/utils/utils'; import { ShlinkDomain, ShlinkVisitsOverview } from '../../../src/api/types'; import { ShortUrl } from '../../../src/short-urls/data'; import { Visit } from '../../../src/visits/types'; -import { OrderDir } from '../../../src/utils/helpers/ordering'; +import { ShortUrlsOrder } from '../../../src/short-urls/reducers/shortUrlsListParams'; describe('ShlinkApiClient', () => { const createAxios = (data: AxiosRequestConfig) => (async () => Promise.resolve(data)) as unknown as AxiosInstance; @@ -33,9 +33,9 @@ describe('ShlinkApiClient', () => { }); it.each([ - [{ visits: 'DESC' as OrderDir }, 'visits-DESC' ], - [{ longUrl: 'ASC' as OrderDir }, 'longUrl-ASC' ], - [{ longUrl: undefined as OrderDir }, undefined ], + [ { field: 'visits', dir: 'DESC' } as ShortUrlsOrder, 'visits-DESC' ], + [ { field: 'longUrl', dir: 'ASC' } as ShortUrlsOrder, 'longUrl-ASC' ], + [ { field: 'longUrl', dir: undefined } as ShortUrlsOrder, undefined ], ])('parses orderBy in params', async (orderBy, expectedOrderBy) => { const axiosSpy = createAxiosMock({ data: expectedList, diff --git a/test/settings/reducers/settings.test.ts b/test/settings/reducers/settings.test.ts index 70c5d4b7..272e8905 100644 --- a/test/settings/reducers/settings.test.ts +++ b/test/settings/reducers/settings.test.ts @@ -1,10 +1,12 @@ import reducer, { SET_SETTINGS, + DEFAULT_SHORT_URLS_ORDERING, toggleRealTimeUpdates, setRealTimeUpdatesInterval, setShortUrlCreationSettings, setUiSettings, setVisitsSettings, + setTagsSettings, } from '../../../src/settings/reducers/settings'; describe('settingsReducer', () => { @@ -12,7 +14,8 @@ describe('settingsReducer', () => { const shortUrlCreation = { validateUrls: false }; const ui = { theme: 'light' }; const visits = { defaultInterval: 'last30Days' }; - const settings = { realTimeUpdates, shortUrlCreation, ui, visits }; + const shortUrlList = { defaultOrdering: DEFAULT_SHORT_URLS_ORDERING }; + const settings = { realTimeUpdates, shortUrlCreation, ui, visits, shortUrlList }; describe('reducer', () => { it('returns realTimeUpdates when action is SET_SETTINGS', () => { @@ -59,4 +62,12 @@ describe('settingsReducer', () => { expect(result).toEqual({ type: SET_SETTINGS, visits: { defaultInterval: 'last180Days' } }); }); }); + + describe('setTagsSettings', () => { + it('creates action to set tags settings', () => { + const result = setTagsSettings({ defaultMode: 'list' }); + + expect(result).toEqual({ type: SET_SETTINGS, tags: { defaultMode: 'list' } }); + }); + }); }); diff --git a/test/short-urls/ShortUrlsList.test.tsx b/test/short-urls/ShortUrlsList.test.tsx index ebb09941..71a1dd5f 100644 --- a/test/short-urls/ShortUrlsList.test.tsx +++ b/test/short-urls/ShortUrlsList.test.tsx @@ -8,10 +8,11 @@ import { ShortUrl } from '../../src/short-urls/data'; import { MercureBoundProps } from '../../src/mercure/helpers/boundToMercureHub'; import { ShortUrlsList as ShortUrlsListModel } from '../../src/short-urls/reducers/shortUrlsList'; import SortingDropdown from '../../src/utils/SortingDropdown'; -import { OrderableFields, OrderBy } from '../../src/short-urls/reducers/shortUrlsListParams'; +import { OrderableFields, ShortUrlsOrder } from '../../src/short-urls/reducers/shortUrlsListParams'; import Paginator from '../../src/short-urls/Paginator'; import { ReachableServer } from '../../src/servers/data'; import { ShortUrlListRouteParams } from '../../src/short-urls/helpers/hooks'; +import { Settings } from '../../src/settings/reducers/settings'; describe('', () => { let wrapper: ShallowWrapper; @@ -32,7 +33,7 @@ describe('', () => { }, }); const ShortUrlsList = shortUrlsListCreator(ShortUrlsTable, SearchBar); - const createWrapper = (orderBy: OrderBy = {}) => shallow( + const createWrapper = (orderBy: ShortUrlsOrder = {}) => shallow( ({ mercureInfo: { loading: true } })} listShortUrls={listShortUrlsMock} @@ -43,6 +44,7 @@ describe('', () => { shortUrlsList={shortUrlsList} history={Mock.of({ push })} selectedServer={Mock.of({ id: '1' })} + settings={Mock.all()} />, ).dive(); // Dive is needed as this component is wrapped in a HOC @@ -91,20 +93,16 @@ describe('', () => { it('handles order through table', () => { const orderByColumn: (field: OrderableFields) => Function = wrapper.find(ShortUrlsTable).prop('orderByColumn'); - orderByColumn('visits')(); - orderByColumn('title')(); - orderByColumn('shortCode')(); + expect(wrapper.find(SortingDropdown).prop('order')).toEqual({}); - expect(listShortUrlsMock).toHaveBeenCalledTimes(3); - expect(listShortUrlsMock).toHaveBeenNthCalledWith(1, expect.objectContaining({ - orderBy: { visits: 'ASC' }, - })); - expect(listShortUrlsMock).toHaveBeenNthCalledWith(2, expect.objectContaining({ - orderBy: { title: 'ASC' }, - })); - expect(listShortUrlsMock).toHaveBeenNthCalledWith(3, expect.objectContaining({ - orderBy: { shortCode: 'ASC' }, - })); + orderByColumn('visits')(); + expect(wrapper.find(SortingDropdown).prop('order')).toEqual({ field: 'visits', dir: 'ASC' }); + + orderByColumn('title')(); + expect(wrapper.find(SortingDropdown).prop('order')).toEqual({ field: 'title', dir: 'ASC' }); + + orderByColumn('shortCode')(); + expect(wrapper.find(SortingDropdown).prop('order')).toEqual({ field: 'shortCode', dir: 'ASC' }); }); it('handles order through dropdown', () => { @@ -118,21 +116,12 @@ describe('', () => { wrapper.find(SortingDropdown).simulate('change', undefined, undefined); expect(wrapper.find(SortingDropdown).prop('order')).toEqual({}); - - expect(listShortUrlsMock).toHaveBeenCalledTimes(3); - expect(listShortUrlsMock).toHaveBeenNthCalledWith(1, expect.objectContaining({ - orderBy: { visits: 'ASC' }, - })); - expect(listShortUrlsMock).toHaveBeenNthCalledWith(2, expect.objectContaining({ - orderBy: { shortCode: 'DESC' }, - })); - expect(listShortUrlsMock).toHaveBeenNthCalledWith(3, expect.objectContaining({ orderBy: undefined })); }); it.each([ - [ Mock.of({ visits: 'ASC' }), 'visits', 'ASC' ], - [ Mock.of({ title: 'DESC' }), 'title', 'DESC' ], - [ Mock.of(), undefined, undefined ], + [ Mock.of({ field: 'visits', dir: 'ASC' }), 'visits', 'ASC' ], + [ Mock.of({ field: 'title', dir: 'DESC' }), 'title', 'DESC' ], + [ Mock.of(), undefined, undefined ], ])('has expected initial ordering', (initialOrderBy, field, dir) => { const wrapper = createWrapper(initialOrderBy); diff --git a/test/short-urls/reducers/shortUrlsListParams.test.ts b/test/short-urls/reducers/shortUrlsListParams.test.ts index 871ac7ff..6acace25 100644 --- a/test/short-urls/reducers/shortUrlsListParams.test.ts +++ b/test/short-urls/reducers/shortUrlsListParams.test.ts @@ -10,14 +10,10 @@ describe('shortUrlsListParamsReducer', () => { expect(reducer(undefined, { type: LIST_SHORT_URLS, params: { searchTerm: 'foo', page: '2' } } as any)).toEqual({ page: '2', searchTerm: 'foo', - orderBy: { dateCreated: 'DESC' }, })); it('returns default value when action is RESET_SHORT_URL_PARAMS', () => - expect(reducer(undefined, { type: RESET_SHORT_URL_PARAMS } as any)).toEqual({ - page: '1', - orderBy: { dateCreated: 'DESC' }, - })); + expect(reducer(undefined, { type: RESET_SHORT_URL_PARAMS } as any)).toEqual({ page: '1' })); }); describe('resetShortUrlParams', () => {