Skip to content

Commit 6083b5c

Browse files
authored
Merge pull request #759 from acelaya-forks/feature/visits-columns
Feature/visits columns
2 parents 312f158 + 512dafd commit 6083b5c

16 files changed

Lines changed: 403 additions & 129 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
99
* [#755](https://github.com/shlinkio/shlink-web-component/issues/755) Add support for `any-value-query-param` and `valueless-query-param` redirect conditions when using Shlink >=4.5.0.
1010
* [#756](https://github.com/shlinkio/shlink-web-component/issues/756) Add support for desktop device types on device redirect conditions, when using Shlink >=4.5.0.
1111
* [#713](https://github.com/shlinkio/shlink-web-component/issues/713) Expose a new `ShlinkSidebarToggleButton` component that can be used to customize the location of the sidebar toggle, rather than making it assume there's a header bar and position it there.
12+
* [#657](https://github.com/shlinkio/shlink-web-component/issues/657) Allow visits table columns to be customized via settings, and add a new optional "Region" column.
13+
14+
As a side effect, the "Show user agent" toggle has been removed from the list, as this can now be globally configured in the settings.
1215

1316
### Changed
1417
* *Nothing*

src/settings/components/ShlinkWebSettings.tsx

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { ShortUrlCreationSettings as ShortUrlCreation } from './ShortUrlCreation
1616
import { ShortUrlsListSettings as ShortUrlsList } from './ShortUrlsListSettings';
1717
import { TagsSettings as Tags } from './TagsSettings';
1818
import { UserInterfaceSettings } from './UserInterfaceSettings';
19+
import { VisitsListSettings } from './VisitsListSettings';
1920
import { VisitsSettings as Visits } from './VisitsSettings';
2021

2122
export type ShlinkWebSettingsProps = {
@@ -62,8 +63,9 @@ export const ShlinkWebSettings: FC<ShlinkWebSettingsProps> = ({
6263
<NavPills className="mb-4 sticky top-(--header-height) z-2">
6364
<NavPills.Pill to="../general">General</NavPills.Pill>
6465
<NavPills.Pill to="../short-urls">Short URLs</NavPills.Pill>
66+
<NavPills.Pill to="../visits">Visits</NavPills.Pill>
67+
<NavPills.Pill to="../tags">Tags</NavPills.Pill>
6568
<NavPills.Pill to="../qr-codes">QR codes</NavPills.Pill>
66-
<NavPills.Pill to="../other-items">Other items</NavPills.Pill>
6769
</NavPills>
6870

6971
<Routes>
@@ -92,11 +94,19 @@ export const ShlinkWebSettings: FC<ShlinkWebSettingsProps> = ({
9294
)}
9395
/>
9496
<Route
95-
path="other-items"
97+
path="visits"
9698
element={(
9799
<SettingsSections>
98-
<Tags onChange={(v) => updateSettingsProp('tags', v)} />
99100
<Visits onChange={(v) => updateSettingsProp('visits', v)} />
101+
<VisitsListSettings onChange={(vl) => updateSettingsProp('visitsList', vl)} />
102+
</SettingsSections>
103+
)}
104+
/>
105+
<Route
106+
path="tags"
107+
element={(
108+
<SettingsSections>
109+
<Tags onChange={(v) => updateSettingsProp('tags', v)} />
100110
</SettingsSections>
101111
)}
102112
/>
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { mergeDeepRight } from '@shlinkio/data-manipulation';
2+
import { SimpleCard } from '@shlinkio/shlink-frontend-kit';
3+
import type { FC } from 'react';
4+
import { useCallback, useMemo } from 'react';
5+
import { Muted } from '../../utils/components/Muted';
6+
import { humanFriendlyJoin } from '../../utils/helpers';
7+
import type { VisitsColumn } from '../index';
8+
import { defaultVisitsListColumns , useSetting } from '../index';
9+
import type { VisitsListSettings as VisitsListSettingsConfig } from '../types';
10+
import { LabelledToggle } from './fe-kit/LabelledToggle';
11+
12+
export type VisitsListSettingsProps = {
13+
onChange: (settings: VisitsListSettingsConfig) => void;
14+
};
15+
16+
export const visitsListColumns = {
17+
potentialBot: 'Potential bot',
18+
date: 'Date',
19+
country: 'Country',
20+
region: 'Region',
21+
city: 'City',
22+
browser: 'Browser',
23+
os: 'OS',
24+
userAgent: 'User agent',
25+
referer: 'Referrer',
26+
visitedUrl: 'Visited URL',
27+
} as const satisfies Record<VisitsColumn, string>;
28+
29+
// Columns that exclude other columns
30+
const columnsExclusion: Partial<Record<VisitsColumn, VisitsColumn[]>> = {
31+
browser: ['userAgent'],
32+
os: ['userAgent'],
33+
userAgent: ['browser', 'os'],
34+
} as const;
35+
36+
Object.freeze(columnsExclusion);
37+
38+
export const VisitsListSettings: FC<VisitsListSettingsProps> = ({ onChange }) => {
39+
const visitsListSettings = useSetting('visitsList');
40+
const columns = useMemo(
41+
() => mergeDeepRight(
42+
defaultVisitsListColumns,
43+
visitsListSettings?.columns ?? {},
44+
) as typeof defaultVisitsListColumns,
45+
[visitsListSettings?.columns],
46+
);
47+
const toggleColumn = useCallback((column: VisitsColumn, show: boolean) => {
48+
const newColumns = {
49+
...columns,
50+
[column]: show,
51+
};
52+
53+
// If the column is being shown, hide all columns it excludes
54+
if (show) {
55+
columnsExclusion[column]?.forEach((excludedColumn) => {
56+
newColumns[excludedColumn] = false;
57+
});
58+
}
59+
60+
onChange({ columns: newColumns });
61+
}, [columns, onChange]);
62+
63+
return (
64+
<SimpleCard title="Visits list">
65+
<p className="mb-2">Columns to show in visits table:</p>
66+
<ul className="flex flex-col gap-y-1">
67+
{(Object.entries(visitsListColumns) as [VisitsColumn, string][]).map(([column, name]) => (
68+
<li key={column}>
69+
<LabelledToggle checked={columns[column]} onChange={(show) => toggleColumn(column, show)}>
70+
<span className="inline-flex gap-2">
71+
{name}
72+
{columnsExclusion[column] && (
73+
<Muted>
74+
(excludes {humanFriendlyJoin(columnsExclusion[column].map((col) => visitsListColumns[col]))})
75+
</Muted>
76+
)}
77+
</span>
78+
</LabelledToggle>
79+
</li>
80+
))}
81+
</ul>
82+
</SimpleCard>
83+
);
84+
};

src/settings/components/VisitsSettings.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,14 @@ import { useSetting } from '..';
66
import { DateIntervalSelector } from './DateIntervalSelector';
77
import { LabelledToggle } from './fe-kit/LabelledToggle';
88

9-
export type VisitsProps = {
9+
export type VisitsSettingsProps = {
1010
onChange: (settings: VisitsSettingsConfig) => void;
1111
};
1212

1313
const currentDefaultInterval = (visitsSettings?: VisitsSettingsConfig): DateInterval =>
1414
visitsSettings?.defaultInterval ?? 'last30Days';
1515

16-
export const VisitsSettings: FC<VisitsProps> = ({ onChange }) => {
16+
export const VisitsSettings: FC<VisitsSettingsProps> = ({ onChange }) => {
1717
const visitsSettings = useSetting('visits');
1818
const updateSettings = useCallback(
1919
({ defaultInterval, ...rest }: Partial<VisitsSettingsConfig>) => onChange(

src/settings/index.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { createContext, useContext } from 'react';
2-
import type { QrCodeSettings, Settings } from './types';
2+
import type { QrCodeSettings, Settings, VisitsListSettings } from './types';
33

44
export const defaultQrCodeSettings: QrCodeSettings = {
55
size: 300,
@@ -12,6 +12,21 @@ export const defaultQrCodeSettings: QrCodeSettings = {
1212

1313
Object.freeze(defaultQrCodeSettings);
1414

15+
export const defaultVisitsListColumns: Required<VisitsListSettings['columns']> = {
16+
potentialBot: true,
17+
date: true,
18+
country: true,
19+
region: false,
20+
city: true,
21+
browser: true,
22+
os: true,
23+
userAgent: false,
24+
referer: true,
25+
visitedUrl: true,
26+
} as const;
27+
28+
Object.freeze(defaultVisitsListColumns);
29+
1530
const defaultSettings: Settings = {
1631
realTimeUpdates: {
1732
enabled: true,
@@ -22,6 +37,9 @@ const defaultSettings: Settings = {
2237
visits: {
2338
defaultInterval: 'last30Days',
2439
},
40+
visitsList: {
41+
columns: defaultVisitsListColumns,
42+
},
2543
shortUrlsList: {
2644
defaultOrdering: {
2745
field: 'dateCreated',

src/settings/types.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,22 @@ export type VisitsSettings = {
2828
loadPrevInterval?: boolean;
2929
};
3030

31+
export type VisitsColumn =
32+
| 'potentialBot'
33+
| 'date'
34+
| 'country'
35+
| 'region'
36+
| 'city'
37+
| 'browser'
38+
| 'os'
39+
| 'userAgent'
40+
| 'referer'
41+
| 'visitedUrl';
42+
43+
export type VisitsListSettings = {
44+
columns: Partial<Record<VisitsColumn, boolean>>;
45+
};
46+
3147
export type TagsSettings = {
3248
defaultOrdering?: Order<'tag' | 'shortUrls' | 'visits'>;
3349
};
@@ -60,6 +76,7 @@ export type Settings = {
6076
shortUrlCreation?: ShortUrlCreationSettings;
6177
shortUrlsList?: ShortUrlsListSettings;
6278
visits?: VisitsSettings;
79+
visitsList?: VisitsListSettings;
6380
tags?: TagsSettings;
6481
ui?: UiSettings;
6582
qrCodes?: QrCodeSettings;

src/utils/helpers/index.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,20 @@ export const parseBooleanToString = (value: boolean): BooleanString => (value ?
2424
export const parseOptionalBooleanToString = (value?: boolean): BooleanString | undefined => (
2525
value === undefined ? undefined : parseBooleanToString(value)
2626
);
27+
28+
/**
29+
* Joins a list of strings separated by comma, with the "and" separator between the last two elements
30+
*
31+
* - ['a', 'b', 'c', 'd'] -> 'a, b, c and d'
32+
* - ['a', 'b'] -> 'a and b'
33+
* - ['a'] -> 'a'
34+
* - [] -> ''
35+
*/
36+
export const humanFriendlyJoin = (list: string[]): string => {
37+
if (list.length < 2) {
38+
return list[0] ?? '';
39+
}
40+
41+
const [lastElement, ...rest] = list.reverse();
42+
return `${rest.reverse().join(', ')} and ${lastElement}`;
43+
};

0 commit comments

Comments
 (0)