Replace local settings UI with the one from shlink-web-component

This commit is contained in:
Alejandro Celaya
2024-05-20 20:03:50 +02:00
parent d4bc9dd62a
commit 202a69bdf5
27 changed files with 88 additions and 1096 deletions

View File

@@ -1,4 +1,5 @@
import { changeThemeInMarkup, getSystemPreferredTheme } from '@shlinkio/shlink-frontend-kit';
import type { Settings } from '@shlinkio/shlink-web-component/settings';
import { clsx } from 'clsx';
import type { FC } from 'react';
import { useEffect, useRef } from 'react';
@@ -8,14 +9,13 @@ import { NotFound } from '../common/NotFound';
import type { FCWithDeps } from '../container/utils';
import { componentFactory, useDependencies } from '../container/utils';
import type { ServersMap } from '../servers/data';
import type { AppSettings } from '../settings/reducers/settings';
import { forceUpdate } from '../utils/helpers/sw';
import './App.scss';
type AppProps = {
fetchServers: () => void;
servers: ServersMap;
settings: AppSettings;
settings: Settings;
resetAppUpdate: () => void;
appUpdated: boolean;
};

View File

@@ -1,6 +1,6 @@
import type { FC, PropsWithChildren } from 'react';
import './NoMenuLayout.scss';
export const NoMenuLayout: FC<PropsWithChildren<unknown>> = ({ children }) => (
export const NoMenuLayout: FC<PropsWithChildren> = ({ children }) => (
<div className="no-menu-wrapper container-xl">{children}</div>
);

View File

@@ -1,4 +1,5 @@
import type { Settings, ShlinkWebComponentType, TagColorsStorage } from '@shlinkio/shlink-web-component';
import type { ShlinkWebComponentType, TagColorsStorage } from '@shlinkio/shlink-web-component';
import type { Settings } from '@shlinkio/shlink-web-component/settings';
import type { FC } from 'react';
import { memo } from 'react';
import type { ShlinkApiClientBuilder } from '../api/services/ShlinkApiClientBuilder';

View File

@@ -1,4 +1,4 @@
import type { Settings } from '@shlinkio/shlink-web-component';
import type { Settings } from '@shlinkio/shlink-web-component/settings';
import type { SelectedServer, ServersMap } from '../servers/data';
export interface ShlinkState {

View File

@@ -1,60 +0,0 @@
import { LabeledFormGroup, SimpleCard, ToggleSwitch, useDomId } from '@shlinkio/shlink-frontend-kit';
import type { Settings } from '@shlinkio/shlink-web-component';
import { clsx } from 'clsx';
import { FormGroup, Input } from 'reactstrap';
import { FormText } from '../utils/forms/FormText';
type RealTimeUpdatesProps = {
settings: Settings;
toggleRealTimeUpdates: (enabled: boolean) => void;
setRealTimeUpdatesInterval: (interval: number) => void;
};
const intervalValue = (interval?: number) => (!interval ? '' : `${interval}`);
export const RealTimeUpdatesSettings = (
{ settings, toggleRealTimeUpdates, setRealTimeUpdatesInterval }: RealTimeUpdatesProps,
) => {
const { realTimeUpdates = { enabled: true } } = settings;
const inputId = useDomId();
return (
<SimpleCard title="Real-time updates" className="h-100">
<FormGroup>
<ToggleSwitch checked={realTimeUpdates.enabled} onChange={toggleRealTimeUpdates}>
Enable or disable real-time updates.
<FormText>
Real-time updates are currently being <b>{realTimeUpdates.enabled ? 'processed' : 'ignored'}</b>.
</FormText>
</ToggleSwitch>
</FormGroup>
<LabeledFormGroup
noMargin
label="Real-time updates frequency (in minutes):"
labelClassName={clsx('form-label', { 'text-muted': !realTimeUpdates.enabled })}
id={inputId}
>
<Input
type="number"
min={0}
placeholder="Immediate"
disabled={!realTimeUpdates.enabled}
value={intervalValue(realTimeUpdates.interval)}
id={inputId}
onChange={({ target }) => setRealTimeUpdatesInterval(Number(target.value))}
/>
{realTimeUpdates.enabled && (
<FormText>
{realTimeUpdates.interval !== undefined && realTimeUpdates.interval > 0 && (
<span>
Updates will be reflected in the UI
every <b>{realTimeUpdates.interval}</b> minute{realTimeUpdates.interval > 1 && 's'}.
</span>
)}
{!realTimeUpdates.interval && 'Updates will be reflected in the UI as soon as they happen.'}
</FormText>
)}
</LabeledFormGroup>
</SimpleCard>
);
};

View File

@@ -1,58 +1,20 @@
import { NavPillItem, NavPills } from '@shlinkio/shlink-frontend-kit';
import type { FC, ReactNode } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
import type { Settings as AppSettings } from '@shlinkio/shlink-web-component/settings';
import { ShlinkWebSettings } from '@shlinkio/shlink-web-component/settings';
import type { FC } from 'react';
import { NoMenuLayout } from '../common/NoMenuLayout';
import type { FCWithDeps } from '../container/utils';
import { componentFactory, useDependencies } from '../container/utils';
import { DEFAULT_SHORT_URLS_ORDERING } from './reducers/settings';
type SettingsDeps = {
RealTimeUpdatesSettings: FC;
ShortUrlCreationSettings: FC;
ShortUrlsListSettings: FC;
UserInterfaceSettings: FC;
VisitsSettings: FC;
TagsSettings: FC;
export type SettingsProps = {
settings: AppSettings;
setSettings: (newSettings: AppSettings) => void;
};
const SettingsSections: FC<{ items: ReactNode[] }> = ({ items }) => (
<>
{items.map((child, index) => <div key={index} className="mb-3">{child}</div>)}
</>
export const Settings: FC<SettingsProps> = ({ settings, setSettings }) => (
<NoMenuLayout>
<ShlinkWebSettings
settings={settings}
updateSettings={setSettings}
defaultShortUrlsListOrdering={DEFAULT_SHORT_URLS_ORDERING}
/>
</NoMenuLayout>
);
const Settings: FCWithDeps<{}, SettingsDeps> = () => {
const {
RealTimeUpdatesSettings: RealTimeUpdates,
ShortUrlCreationSettings: ShortUrlCreation,
ShortUrlsListSettings: ShortUrlsList,
UserInterfaceSettings: UserInterface,
VisitsSettings: Visits,
TagsSettings: Tags,
} = useDependencies(Settings);
return (
<NoMenuLayout>
<NavPills className="mb-3">
<NavPillItem to="general">General</NavPillItem>
<NavPillItem to="short-urls">Short URLs</NavPillItem>
<NavPillItem to="other-items">Other items</NavPillItem>
</NavPills>
<Routes>
<Route path="general" element={<SettingsSections items={[<UserInterface />, <RealTimeUpdates />]} />} />
<Route path="short-urls" element={<SettingsSections items={[<ShortUrlCreation />, <ShortUrlsList />]} />} />
<Route path="other-items" element={<SettingsSections items={[<Tags />, <Visits />]} />} />
<Route path="*" element={<Navigate replace to="general" />} />
</Routes>
</NoMenuLayout>
);
};
export const SettingsFactory = componentFactory(Settings, [
'RealTimeUpdatesSettings',
'ShortUrlCreationSettings',
'ShortUrlsListSettings',
'UserInterfaceSettings',
'VisitsSettings',
'TagsSettings',
]);

View File

@@ -1,75 +0,0 @@
import { DropdownBtn, LabeledFormGroup, SimpleCard, ToggleSwitch } from '@shlinkio/shlink-frontend-kit';
import type { Settings, ShortUrlCreationSettings as ShortUrlsSettings } from '@shlinkio/shlink-web-component';
import type { FC, ReactNode } from 'react';
import { DropdownItem, FormGroup } from 'reactstrap';
import { FormText } from '../utils/forms/FormText';
import type { Defined } from '../utils/types';
type TagFilteringMode = Defined<ShortUrlsSettings['tagFilteringMode']>;
interface ShortUrlCreationProps {
settings: Settings;
setShortUrlCreationSettings: (settings: ShortUrlsSettings) => void;
}
const tagFilteringModeText = (tagFilteringMode: TagFilteringMode | undefined): string =>
(tagFilteringMode === 'includes' ? 'Suggest tags including input' : 'Suggest tags starting with input');
const tagFilteringModeHint = (tagFilteringMode: TagFilteringMode | undefined): ReactNode => (
tagFilteringMode === 'includes'
? <>The list of suggested tags will contain those <b>including</b> provided input.</>
: <>The list of suggested tags will contain those <b>starting with</b> provided input.</>
);
export const ShortUrlCreationSettings: FC<ShortUrlCreationProps> = ({ settings, setShortUrlCreationSettings }) => {
const shortUrlCreation: ShortUrlsSettings = settings.shortUrlCreation ?? { validateUrls: false };
const changeTagsFilteringMode = (tagFilteringMode: TagFilteringMode) => () => setShortUrlCreationSettings(
{ ...shortUrlCreation ?? { validateUrls: false }, tagFilteringMode },
);
return (
<SimpleCard title="Short URLs form" className="h-100">
<FormGroup>
<ToggleSwitch
checked={shortUrlCreation.validateUrls ?? false}
onChange={(validateUrls) => setShortUrlCreationSettings({ ...shortUrlCreation, validateUrls })}
>
Request validation on long URLs when creating new short URLs.{' '}
<b>This option is ignored by Shlink {'>='}4.0.0</b>
<FormText>
The initial state of the <b>Validate URL</b> checkbox will
be <b>{shortUrlCreation.validateUrls ? 'checked' : 'unchecked'}</b>.
</FormText>
</ToggleSwitch>
</FormGroup>
<FormGroup>
<ToggleSwitch
checked={shortUrlCreation.forwardQuery ?? true}
onChange={(forwardQuery) => setShortUrlCreationSettings({ ...shortUrlCreation, forwardQuery })}
>
Make all new short URLs forward their query params to the long URL.
<FormText>
The initial state of the <b>Forward query params on redirect</b> checkbox will
be <b>{shortUrlCreation.forwardQuery ?? true ? 'checked' : 'unchecked'}</b>.
</FormText>
</ToggleSwitch>
</FormGroup>
<LabeledFormGroup noMargin label="Tag suggestions search mode:">
<DropdownBtn text={tagFilteringModeText(shortUrlCreation.tagFilteringMode)}>
<DropdownItem
active={!shortUrlCreation.tagFilteringMode || shortUrlCreation.tagFilteringMode === 'startsWith'}
onClick={changeTagsFilteringMode('startsWith')}
>
{tagFilteringModeText('startsWith')}
</DropdownItem>
<DropdownItem
active={shortUrlCreation.tagFilteringMode === 'includes'}
onClick={changeTagsFilteringMode('includes')}
>
{tagFilteringModeText('includes')}
</DropdownItem>
</DropdownBtn>
<FormText>{tagFilteringModeHint(shortUrlCreation.tagFilteringMode)}</FormText>
</LabeledFormGroup>
</SimpleCard>
);
};

View File

@@ -1,31 +0,0 @@
import { LabeledFormGroup, OrderingDropdown, SimpleCard } from '@shlinkio/shlink-frontend-kit';
import type { Settings, ShortUrlsListSettings as ShortUrlsSettings } from '@shlinkio/shlink-web-component';
import type { FC } from 'react';
import { DEFAULT_SHORT_URLS_ORDERING } from './reducers/settings';
interface ShortUrlsListSettingsProps {
settings: Settings;
setShortUrlsListSettings: (settings: ShortUrlsSettings) => void;
}
const SHORT_URLS_ORDERABLE_FIELDS = {
dateCreated: 'Created at',
shortCode: 'Short URL',
longUrl: 'Long URL',
title: 'Title',
visits: 'Visits',
};
export const ShortUrlsListSettings: FC<ShortUrlsListSettingsProps> = (
{ settings: { shortUrlsList }, setShortUrlsListSettings },
) => (
<SimpleCard title="Short URLs list" className="h-100">
<LabeledFormGroup noMargin label="Default ordering for short URLs list:">
<OrderingDropdown
items={SHORT_URLS_ORDERABLE_FIELDS}
order={shortUrlsList?.defaultOrdering ?? DEFAULT_SHORT_URLS_ORDERING}
onChange={(field, dir) => setShortUrlsListSettings({ defaultOrdering: { field, dir } })}
/>
</LabeledFormGroup>
</SimpleCard>
);

View File

@@ -1,29 +0,0 @@
import { LabeledFormGroup, OrderingDropdown, SimpleCard } from '@shlinkio/shlink-frontend-kit';
import type { Settings, TagsSettings as TagsSettingsOptions } from '@shlinkio/shlink-web-component';
import type { FC } from 'react';
import type { Defined } from '../utils/types';
export type TagsOrder = Defined<TagsSettingsOptions['defaultOrdering']>;
interface TagsProps {
settings: Settings;
setTagsSettings: (settings: TagsSettingsOptions) => void;
}
const TAGS_ORDERABLE_FIELDS = {
tag: 'Tag',
shortUrls: 'Short URLs',
visits: 'Visits',
};
export const TagsSettings: FC<TagsProps> = ({ settings: { tags }, setTagsSettings }) => (
<SimpleCard title="Tags" className="h-100">
<LabeledFormGroup noMargin label="Default ordering for tags list:">
<OrderingDropdown
items={TAGS_ORDERABLE_FIELDS}
order={tags?.defaultOrdering ?? {}}
onChange={(field, dir) => setTagsSettings({ ...tags, defaultOrdering: { field, dir } })}
/>
</LabeledFormGroup>
</SimpleCard>
);

View File

@@ -1,4 +0,0 @@
.user-interface__theme-icon {
float: right;
margin-top: .25rem;
}

View File

@@ -1,34 +0,0 @@
import { faMoon, faSun } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import type { Theme } from '@shlinkio/shlink-frontend-kit';
import { getSystemPreferredTheme, SimpleCard, ToggleSwitch } from '@shlinkio/shlink-frontend-kit';
import type { FC } from 'react';
import { useMemo } from 'react';
import type { AppSettings, UiSettings } from './reducers/settings';
import './UserInterfaceSettings.scss';
interface UserInterfaceProps {
settings: AppSettings;
setUiSettings: (settings: UiSettings) => void;
/* Test seam */
_matchMedia?: typeof window.matchMedia;
}
export const UserInterfaceSettings: FC<UserInterfaceProps> = ({ settings: { ui }, setUiSettings, _matchMedia }) => {
const currentTheme = useMemo(() => ui?.theme ?? getSystemPreferredTheme(_matchMedia), [ui?.theme, _matchMedia]);
return (
<SimpleCard title="User interface" className="h-100">
<FontAwesomeIcon icon={currentTheme === 'dark' ? faMoon : faSun} className="user-interface__theme-icon" />
<ToggleSwitch
checked={currentTheme === 'dark'}
onChange={(useDarkTheme) => {
const theme: Theme = useDarkTheme ? 'dark' : 'light';
setUiSettings({ ...ui, theme });
}}
>
Use dark theme.
</ToggleSwitch>
</SimpleCard>
);
};

View File

@@ -1,60 +0,0 @@
import { LabeledFormGroup, SimpleCard, ToggleSwitch } from '@shlinkio/shlink-frontend-kit';
import type { Settings, VisitsSettings as VisitsSettingsConfig } from '@shlinkio/shlink-web-component';
import type { FC } from 'react';
import { useCallback } from 'react';
import { FormGroup } from 'reactstrap';
import type { DateInterval } from '../utils/dates/DateIntervalSelector';
import { DateIntervalSelector } from '../utils/dates/DateIntervalSelector';
import { FormText } from '../utils/forms/FormText';
type VisitsProps = {
settings: Settings;
setVisitsSettings: (settings: VisitsSettingsConfig) => void;
};
const currentDefaultInterval = (settings: Settings): DateInterval => settings.visits?.defaultInterval ?? 'last30Days';
export const VisitsSettings: FC<VisitsProps> = ({ settings, setVisitsSettings }) => {
const updateSettings = useCallback(
({ defaultInterval, ...rest }: Partial<VisitsSettingsConfig>) => setVisitsSettings(
{ defaultInterval: defaultInterval ?? currentDefaultInterval(settings), ...rest },
),
[setVisitsSettings, settings],
);
return (
<SimpleCard title="Visits" className="h-100">
<FormGroup>
<ToggleSwitch
checked={!!settings.visits?.excludeBots}
onChange={(excludeBots) => updateSettings({ excludeBots })}
>
Exclude bots wherever possible (this option&lsquo;s effect might depend on Shlink server&lsquo;s version).
<FormText>
The visits coming from potential bots will
be <b>{settings.visits?.excludeBots ? 'excluded' : 'included'}</b>.
</FormText>
</ToggleSwitch>
</FormGroup>
<FormGroup>
<ToggleSwitch
checked={!!settings.visits?.loadPrevInterval}
onChange={(loadPrevInterval) => updateSettings({ loadPrevInterval })}
>
Compare visits with previous period.
<FormText>
When loading visits, previous period <b>{settings.visits?.loadPrevInterval ? 'will' : 'won\'t'}</b> be
loaded by default.
</FormText>
</ToggleSwitch>
</FormGroup>
<LabeledFormGroup noMargin label="Default interval to load on visits sections:">
<DateIntervalSelector
allText="All visits"
active={currentDefaultInterval(settings)}
onChange={(defaultInterval) => updateSettings({ defaultInterval })}
/>
</LabeledFormGroup>
</SimpleCard>
);
};

View File

@@ -1,15 +1,8 @@
import type { PayloadAction, PrepareAction } from '@reduxjs/toolkit';
import type { PayloadAction } from '@reduxjs/toolkit';
import { createSlice } from '@reduxjs/toolkit';
import { mergeDeepRight } from '@shlinkio/data-manipulation';
import type { Theme } from '@shlinkio/shlink-frontend-kit';
import { getSystemPreferredTheme } from '@shlinkio/shlink-frontend-kit';
import type {
Settings,
ShortUrlCreationSettings,
ShortUrlsListSettings,
TagsSettings,
VisitsSettings,
} from '@shlinkio/shlink-web-component';
import type { Settings, ShortUrlsListSettings } from '@shlinkio/shlink-web-component/settings';
import type { Defined } from '../../utils/types';
type ShortUrlsOrder = Defined<ShortUrlsListSettings['defaultOrdering']>;
@@ -19,15 +12,9 @@ export const DEFAULT_SHORT_URLS_ORDERING: ShortUrlsOrder = {
dir: 'DESC',
};
export type UiSettings = {
theme: Theme;
};
type SettingsAction = PayloadAction<Settings>;
export type AppSettings = Settings & {
ui?: UiSettings;
};
const initialState: AppSettings = {
const initialState: Settings = {
realTimeUpdates: {
enabled: true,
},
@@ -45,39 +32,14 @@ const initialState: AppSettings = {
},
};
type SettingsAction = PayloadAction<AppSettings>;
type SettingsPrepareAction = PrepareAction<AppSettings>;
const commonReducer = (state: AppSettings, { payload }: SettingsAction) => mergeDeepRight(state, payload);
const toReducer = (prepare: SettingsPrepareAction) => ({ reducer: commonReducer, prepare });
const toPreparedAction: SettingsPrepareAction = (payload: AppSettings) => ({ payload });
const { reducer, actions } = createSlice({
name: 'shlink/settings',
initialState,
reducers: {
toggleRealTimeUpdates: toReducer((enabled: boolean) => toPreparedAction({ realTimeUpdates: { enabled } })),
setRealTimeUpdatesInterval: toReducer((interval: number) => toPreparedAction({ realTimeUpdates: { interval } })),
setShortUrlCreationSettings: toReducer(
(shortUrlCreation: ShortUrlCreationSettings) => toPreparedAction({ shortUrlCreation }),
),
setShortUrlsListSettings: toReducer(
(shortUrlsList: ShortUrlsListSettings) => toPreparedAction({ shortUrlsList }),
),
setUiSettings: toReducer((ui: UiSettings) => toPreparedAction({ ui })),
setVisitsSettings: toReducer((visits: VisitsSettings) => toPreparedAction({ visits })),
setTagsSettings: toReducer((tags: TagsSettings) => toPreparedAction({ tags })),
setSettings: (state: Settings, { payload }: SettingsAction) => mergeDeepRight(state, payload),
},
});
export const {
toggleRealTimeUpdates,
setRealTimeUpdatesInterval,
setShortUrlCreationSettings,
setShortUrlsListSettings,
setUiSettings,
setVisitsSettings,
setTagsSettings,
} = actions;
export const { setSettings } = actions;
export const settingsReducer = reducer;

View File

@@ -1,56 +1,15 @@
import type Bottle from 'bottlejs';
import type { ConnectDecorator } from '../../container/types';
import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer';
import { RealTimeUpdatesSettings } from '../RealTimeUpdatesSettings';
import {
setRealTimeUpdatesInterval,
setShortUrlCreationSettings,
setShortUrlsListSettings,
setTagsSettings,
setUiSettings,
setVisitsSettings,
toggleRealTimeUpdates,
} from '../reducers/settings';
import { SettingsFactory } from '../Settings';
import { ShortUrlCreationSettings } from '../ShortUrlCreationSettings';
import { ShortUrlsListSettings } from '../ShortUrlsListSettings';
import { TagsSettings } from '../TagsSettings';
import { UserInterfaceSettings } from '../UserInterfaceSettings';
import { VisitsSettings } from '../VisitsSettings';
import { setSettings } from '../reducers/settings';
import { Settings } from '../Settings';
export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Components
bottle.factory('Settings', SettingsFactory);
bottle.serviceFactory('Settings', () => Settings);
bottle.decorator('Settings', withoutSelectedServer);
bottle.decorator('Settings', connect(null, ['resetSelectedServer']));
bottle.serviceFactory('RealTimeUpdatesSettings', () => RealTimeUpdatesSettings);
bottle.decorator(
'RealTimeUpdatesSettings',
connect(['settings'], ['toggleRealTimeUpdates', 'setRealTimeUpdatesInterval']),
);
bottle.serviceFactory('ShortUrlCreationSettings', () => ShortUrlCreationSettings);
bottle.decorator('ShortUrlCreationSettings', connect(['settings'], ['setShortUrlCreationSettings']));
bottle.serviceFactory('UserInterfaceSettings', () => UserInterfaceSettings);
bottle.decorator('UserInterfaceSettings', connect(['settings'], ['setUiSettings']));
bottle.serviceFactory('VisitsSettings', () => VisitsSettings);
bottle.decorator('VisitsSettings', connect(['settings'], ['setVisitsSettings']));
bottle.serviceFactory('TagsSettings', () => TagsSettings);
bottle.decorator('TagsSettings', connect(['settings'], ['setTagsSettings']));
bottle.serviceFactory('ShortUrlsListSettings', () => ShortUrlsListSettings);
bottle.decorator('ShortUrlsListSettings', connect(['settings'], ['setShortUrlsListSettings']));
bottle.decorator('Settings', connect(['settings'], ['setSettings', 'resetSelectedServer']));
// Actions
bottle.serviceFactory('toggleRealTimeUpdates', () => toggleRealTimeUpdates);
bottle.serviceFactory('setRealTimeUpdatesInterval', () => setRealTimeUpdatesInterval);
bottle.serviceFactory('setShortUrlCreationSettings', () => setShortUrlCreationSettings);
bottle.serviceFactory('setShortUrlsListSettings', () => setShortUrlsListSettings);
bottle.serviceFactory('setUiSettings', () => setUiSettings);
bottle.serviceFactory('setVisitsSettings', () => setVisitsSettings);
bottle.serviceFactory('setTagsSettings', () => setTagsSettings);
bottle.serviceFactory('setSettings', () => setSettings);
};

View File

@@ -1,5 +1,5 @@
import { DropdownBtn } from '@shlinkio/shlink-frontend-kit';
import type { VisitsSettings } from '@shlinkio/shlink-web-component';
import type { VisitsSettings } from '@shlinkio/shlink-web-component/settings';
import type { FC } from 'react';
import { DropdownItem } from 'reactstrap';