Set everything up to use hooks for reduc actions and state

This commit is contained in:
Alejandro Celaya
2025-11-14 08:24:58 +01:00
parent ffc8249c22
commit e9951e95a9
16 changed files with 79 additions and 78 deletions

View File

@@ -1,5 +1,5 @@
import { changeThemeInMarkup, getSystemPreferredTheme } from '@shlinkio/shlink-frontend-kit'; import { changeThemeInMarkup, getSystemPreferredTheme } from '@shlinkio/shlink-frontend-kit';
import type { Settings } from '@shlinkio/shlink-web-component/settings'; import type { Settings as AppSettings } from '@shlinkio/shlink-web-component/settings';
import { clsx } from 'clsx'; import { clsx } from 'clsx';
import type { FC } from 'react'; import type { FC } from 'react';
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
@@ -9,12 +9,13 @@ import { NotFound } from '../common/NotFound';
import type { FCWithDeps } from '../container/utils'; import type { FCWithDeps } from '../container/utils';
import { componentFactory, useDependencies } from '../container/utils'; import { componentFactory, useDependencies } from '../container/utils';
import type { ServersMap } from '../servers/data'; import type { ServersMap } from '../servers/data';
import { Settings } from '../settings/Settings';
import { forceUpdate } from '../utils/helpers/sw'; import { forceUpdate } from '../utils/helpers/sw';
type AppProps = { type AppProps = {
fetchServers: () => void; fetchServers: () => void;
servers: ServersMap; servers: ServersMap;
settings: Settings; settings: AppSettings;
resetAppUpdate: () => void; resetAppUpdate: () => void;
appUpdated: boolean; appUpdated: boolean;
}; };
@@ -25,7 +26,6 @@ type AppDeps = {
ShlinkWebComponentContainer: FC; ShlinkWebComponentContainer: FC;
CreateServer: FC; CreateServer: FC;
EditServer: FC; EditServer: FC;
Settings: FC;
ManageServers: FC; ManageServers: FC;
ShlinkVersionsContainer: FC; ShlinkVersionsContainer: FC;
}; };
@@ -39,7 +39,6 @@ const App: FCWithDeps<AppProps, AppDeps> = (
ShlinkWebComponentContainer, ShlinkWebComponentContainer,
CreateServer, CreateServer,
EditServer, EditServer,
Settings,
ManageServers, ManageServers,
ShlinkVersionsContainer, ShlinkVersionsContainer,
} = useDependencies(App); } = useDependencies(App);
@@ -105,7 +104,6 @@ export const AppFactory = componentFactory(App, [
'ShlinkWebComponentContainer', 'ShlinkWebComponentContainer',
'CreateServer', 'CreateServer',
'EditServer', 'EditServer',
'Settings',
'ManageServers', 'ManageServers',
'ShlinkVersionsContainer', 'ShlinkVersionsContainer',
]); ]);

View File

@@ -5,7 +5,6 @@ import { provideServices as provideApiServices } from '../api/services/provideSe
import { provideServices as provideAppServices } from '../app/services/provideServices'; import { provideServices as provideAppServices } from '../app/services/provideServices';
import { provideServices as provideCommonServices } from '../common/services/provideServices'; import { provideServices as provideCommonServices } from '../common/services/provideServices';
import { provideServices as provideServersServices } from '../servers/services/provideServices'; import { provideServices as provideServersServices } from '../servers/services/provideServices';
import { provideServices as provideSettingsServices } from '../settings/services/provideServices';
import { provideServices as provideUtilsServices } from '../utils/services/provideServices'; import { provideServices as provideUtilsServices } from '../utils/services/provideServices';
import type { ConnectDecorator } from './types'; import type { ConnectDecorator } from './types';
@@ -39,4 +38,3 @@ provideCommonServices(bottle, connect);
provideApiServices(bottle); provideApiServices(bottle);
provideServersServices(bottle, connect); provideServersServices(bottle, connect);
provideUtilsServices(bottle); provideUtilsServices(bottle);
provideSettingsServices(bottle, connect);

View File

@@ -1,5 +1,4 @@
import { configureStore } from '@reduxjs/toolkit'; import { configureStore } from '@reduxjs/toolkit';
import type { IContainer } from 'bottlejs';
import type { RLSOptions } from 'redux-localstorage-simple'; import type { RLSOptions } from 'redux-localstorage-simple';
import { load, save } from 'redux-localstorage-simple'; import { load, save } from 'redux-localstorage-simple';
import { initReducers } from '../reducers'; import { initReducers } from '../reducers';
@@ -13,11 +12,11 @@ const localStorageConfig: RLSOptions = {
namespaceSeparator: '.', namespaceSeparator: '.',
debounce: 300, debounce: 300,
}; };
const preloadedState = migrateDeprecatedSettings(load(localStorageConfig) as ShlinkState); const getStateFromLocalStorage = () => migrateDeprecatedSettings(load(localStorageConfig) as ShlinkState);
export const setUpStore = (container: IContainer) => configureStore({ export const setUpStore = (preloadedState = getStateFromLocalStorage()) => configureStore({
devTools: !isProduction, devTools: !isProduction,
reducer: initReducers(container), reducer: initReducers(),
preloadedState, preloadedState,
middleware: (defaultMiddlewaresIncludingReduxThunk) => middleware: (defaultMiddlewaresIncludingReduxThunk) =>
defaultMiddlewaresIncludingReduxThunk({ immutableCheck: false, serializableCheck: false }) // State is too big for these defaultMiddlewaresIncludingReduxThunk({ immutableCheck: false, serializableCheck: false }) // State is too big for these

View File

@@ -7,7 +7,7 @@ import { setUpStore } from './container/store';
import { register as registerServiceWorker } from './serviceWorkerRegistration'; import { register as registerServiceWorker } from './serviceWorkerRegistration';
import './tailwind.css'; import './tailwind.css';
const store = setUpStore(container); const store = setUpStore();
const { App, ScrollToTop, ErrorHandler, appUpdateAvailable } = container; const { App, ScrollToTop, ErrorHandler, appUpdateAvailable } = container;
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(

View File

@@ -1,12 +1,12 @@
import { combineReducers } from '@reduxjs/toolkit'; import { combineReducers } from '@reduxjs/toolkit';
import type { IContainer } from 'bottlejs';
import { appUpdatesReducer } from '../app/reducers/appUpdates'; import { appUpdatesReducer } from '../app/reducers/appUpdates';
import { selectedServerReducer } from '../servers/reducers/selectedServer';
import { serversReducer } from '../servers/reducers/servers'; import { serversReducer } from '../servers/reducers/servers';
import { settingsReducer } from '../settings/reducers/settings'; import { settingsReducer } from '../settings/reducers/settings';
export const initReducers = (container: IContainer) => combineReducers({ export const initReducers = () => combineReducers({
appUpdated: appUpdatesReducer, appUpdated: appUpdatesReducer,
servers: serversReducer, servers: serversReducer,
selectedServer: container.selectedServerReducer, selectedServer: selectedServerReducer,
settings: settingsReducer, settings: settingsReducer,
}); });

View File

@@ -56,14 +56,17 @@ export const selectServer = (buildShlinkApiClient: ShlinkApiClientBuilder) => cr
}, },
); );
type SelectServerThunk = ReturnType<typeof selectServer>; const { reducer } = createSlice({
export const selectedServerReducerCreator = (selectServerThunk: SelectServerThunk) => createSlice({
name: REDUCER_PREFIX, name: REDUCER_PREFIX,
initialState, initialState,
reducers: {}, reducers: {},
extraReducers: (builder) => { extraReducers: (builder) => {
builder.addCase(resetSelectedServer, () => initialState); builder.addCase(resetSelectedServer, () => initialState);
builder.addCase(selectServerThunk.fulfilled, (_, { payload }) => payload as any); builder.addCase(
`${REDUCER_PREFIX}/selectServer/fulfilled`,
(_, { payload }: { payload: SelectedServer }) => payload,
);
}, },
}); });
export const selectedServerReducer = reducer;

View File

@@ -11,11 +11,7 @@ import { ManageServersFactory } from '../ManageServers';
import { ManageServersRowFactory } from '../ManageServersRow'; import { ManageServersRowFactory } from '../ManageServersRow';
import { ManageServersRowDropdownFactory } from '../ManageServersRowDropdown'; import { ManageServersRowDropdownFactory } from '../ManageServersRowDropdown';
import { fetchServers } from '../reducers/remoteServers'; import { fetchServers } from '../reducers/remoteServers';
import { import { resetSelectedServer, selectServer } from '../reducers/selectedServer';
resetSelectedServer,
selectedServerReducerCreator,
selectServer,
} from '../reducers/selectedServer';
import { createServers, deleteServer, editServer, setAutoConnect } from '../reducers/servers'; import { createServers, deleteServer, editServer, setAutoConnect } from '../reducers/servers';
import { ServersDropdown } from '../ServersDropdown'; import { ServersDropdown } from '../ServersDropdown';
import { ServersExporter } from './ServersExporter'; import { ServersExporter } from './ServersExporter';
@@ -66,8 +62,4 @@ export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
bottle.serviceFactory('fetchServers', fetchServers, 'HttpClient'); bottle.serviceFactory('fetchServers', fetchServers, 'HttpClient');
bottle.serviceFactory('resetSelectedServer', () => resetSelectedServer); bottle.serviceFactory('resetSelectedServer', () => resetSelectedServer);
// Reducers
bottle.serviceFactory('selectedServerReducerCreator', selectedServerReducerCreator, 'selectServer');
bottle.serviceFactory('selectedServerReducer', (obj) => obj.reducer, 'selectedServerReducerCreator');
}; };

View File

@@ -1,20 +1,18 @@
import type { Settings as AppSettings } from '@shlinkio/shlink-web-component/settings';
import { ShlinkWebSettings } from '@shlinkio/shlink-web-component/settings'; import { ShlinkWebSettings } from '@shlinkio/shlink-web-component/settings';
import type { FC } from 'react'; import type { FC } from 'react';
import { NoMenuLayout } from '../common/NoMenuLayout'; import { NoMenuLayout } from '../common/NoMenuLayout';
import { DEFAULT_SHORT_URLS_ORDERING } from './reducers/settings'; import { DEFAULT_SHORT_URLS_ORDERING, useSettings } from './reducers/settings';
export type SettingsProps = { export const Settings: FC = () => {
settings: AppSettings; const { settings, setSettings } = useSettings();
setSettings: (newSettings: AppSettings) => void;
return (
<NoMenuLayout>
<ShlinkWebSettings
settings={settings}
onUpdateSettings={setSettings}
defaultShortUrlsListOrdering={DEFAULT_SHORT_URLS_ORDERING}
/>
</NoMenuLayout>
);
}; };
export const Settings: FC<SettingsProps> = ({ settings, setSettings }) => (
<NoMenuLayout>
<ShlinkWebSettings
settings={settings}
onUpdateSettings={setSettings}
defaultShortUrlsListOrdering={DEFAULT_SHORT_URLS_ORDERING}
/>
</NoMenuLayout>
);

View File

@@ -3,6 +3,9 @@ import { createSlice } from '@reduxjs/toolkit';
import { mergeDeepRight } from '@shlinkio/data-manipulation'; import { mergeDeepRight } from '@shlinkio/data-manipulation';
import { getSystemPreferredTheme } from '@shlinkio/shlink-frontend-kit'; import { getSystemPreferredTheme } from '@shlinkio/shlink-frontend-kit';
import type { Settings, ShortUrlsListSettings } from '@shlinkio/shlink-web-component/settings'; import type { Settings, ShortUrlsListSettings } from '@shlinkio/shlink-web-component/settings';
import { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import type { ShlinkState } from '../../container/types';
import type { Defined } from '../../utils/types'; import type { Defined } from '../../utils/types';
type ShortUrlsOrder = Defined<ShortUrlsListSettings['defaultOrdering']>; type ShortUrlsOrder = Defined<ShortUrlsListSettings['defaultOrdering']>;
@@ -41,3 +44,11 @@ const { reducer, actions } = createSlice({
export const { setSettings } = actions; export const { setSettings } = actions;
export const settingsReducer = reducer; export const settingsReducer = reducer;
export const useSettings = () => {
const dispatch = useDispatch();
const setSettings = useCallback((settings: Settings) => dispatch(actions.setSettings(settings)), [dispatch]);
const settings = useSelector((state: ShlinkState) => state.settings);
return { settings, setSettings };
};

View File

@@ -1,15 +0,0 @@
import type Bottle from 'bottlejs';
import type { ConnectDecorator } from '../../container/types';
import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer';
import { setSettings } from '../reducers/settings';
import { Settings } from '../Settings';
export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Components
bottle.serviceFactory('Settings', () => Settings);
bottle.decorator('Settings', withoutSelectedServer);
bottle.decorator('Settings', connect(['settings'], ['setSettings', 'resetSelectedServer']));
// Actions
bottle.serviceFactory('setSettings', () => setSettings);
};

View File

@@ -8,7 +8,5 @@
:root { :root {
--footer-height: 2.3rem; --footer-height: 2.3rem;
--footer-margin: .8rem; --footer-margin: .8rem;
/* FIXME Remove this once updated to shlink-web-component 0.15.1 */
--header-height: 52px;
} }
} }

View File

@@ -1,8 +0,0 @@
import { render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { ReactElement } from 'react';
export const renderWithEvents = (element: ReactElement) => ({
user: userEvent.setup(),
...render(element),
});

View File

@@ -0,0 +1,28 @@
import type { RenderOptions } from '@testing-library/react';
import { render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { PropsWithChildren, ReactElement } from 'react';
import { Provider } from 'react-redux';
import { setUpStore } from '../../src/container/store';
import type { ShlinkState } from '../../src/container/types';
export const renderWithEvents = (element: ReactElement, options?: RenderOptions) => ({
user: userEvent.setup(),
...render(element, options),
});
export type RenderOptionsWithState = Omit<RenderOptions, 'wrapper'> & {
initialState?: Partial<ShlinkState>;
};
export const renderWithStore = (
element: ReactElement,
{ initialState = {}, ...options }: RenderOptionsWithState = {},
) => {
const Wrapper = ({ children }: PropsWithChildren) => (
<Provider store={setUpStore(initialState)}>
{children}
</Provider>
);
return renderWithEvents(element, { ...options, wrapper: Wrapper });
};

View File

@@ -1,8 +1,9 @@
import { act, render, screen } from '@testing-library/react'; import { act, screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn'; import { fromPartial } from '@total-typescript/shoehorn';
import { MemoryRouter } from 'react-router'; import { MemoryRouter } from 'react-router';
import { AppFactory } from '../../src/app/App'; import { AppFactory } from '../../src/app/App';
import { checkAccessibility } from '../__helpers__/accessibility'; import { checkAccessibility } from '../__helpers__/accessibility';
import { renderWithStore } from '../__helpers__/setUpTest';
describe('<App />', () => { describe('<App />', () => {
const App = AppFactory( const App = AppFactory(
@@ -12,12 +13,11 @@ describe('<App />', () => {
ShlinkWebComponentContainer: () => <>ShlinkWebComponentContainer</>, ShlinkWebComponentContainer: () => <>ShlinkWebComponentContainer</>,
CreateServer: () => <>CreateServer</>, CreateServer: () => <>CreateServer</>,
EditServer: () => <>EditServer</>, EditServer: () => <>EditServer</>,
Settings: () => <>SettingsComp</>,
ManageServers: () => <>ManageServers</>, ManageServers: () => <>ManageServers</>,
ShlinkVersionsContainer: () => <>ShlinkVersions</>, ShlinkVersionsContainer: () => <>ShlinkVersions</>,
}), }),
); );
const setUp = async (activeRoute = '/') => act(() => render( const setUp = async (activeRoute = '/') => act(() => renderWithStore(
<MemoryRouter initialEntries={[{ pathname: activeRoute }]}> <MemoryRouter initialEntries={[{ pathname: activeRoute }]}>
<App <App
fetchServers={() => {}} fetchServers={() => {}}
@@ -39,8 +39,8 @@ describe('<App />', () => {
}); });
it.each([ it.each([
['/settings/foo', 'SettingsComp'], ['/settings/general', 'User interface'],
['/settings/bar', 'SettingsComp'], ['/settings/short-urls', 'Short URLs form'],
['/manage-servers', 'ManageServers'], ['/manage-servers', 'ManageServers'],
['/server/create', 'CreateServer'], ['/server/create', 'CreateServer'],
['/server/abc123/edit', 'EditServer'], ['/server/abc123/edit', 'EditServer'],

View File

@@ -6,7 +6,7 @@ import {
MAX_FALLBACK_VERSION, MAX_FALLBACK_VERSION,
MIN_FALLBACK_VERSION, MIN_FALLBACK_VERSION,
resetSelectedServer, resetSelectedServer,
selectedServerReducerCreator, selectedServerReducer as reducer,
selectServer as selectServerCreator, selectServer as selectServerCreator,
} from '../../../src/servers/reducers/selectedServer'; } from '../../../src/servers/reducers/selectedServer';
@@ -15,7 +15,6 @@ describe('selectedServerReducer', () => {
const health = vi.fn(); const health = vi.fn();
const buildApiClient = vi.fn().mockReturnValue(fromPartial<ShlinkApiClient>({ health })); const buildApiClient = vi.fn().mockReturnValue(fromPartial<ShlinkApiClient>({ health }));
const selectServer = selectServerCreator(buildApiClient); const selectServer = selectServerCreator(buildApiClient);
const { reducer } = selectedServerReducerCreator(selectServer);
describe('reducer', () => { describe('reducer', () => {
it('returns default when action is RESET_SELECTED_SERVER', () => it('returns default when action is RESET_SELECTED_SERVER', () =>

View File

@@ -1,12 +1,12 @@
import { render } from '@testing-library/react';
import { MemoryRouter } from 'react-router'; import { MemoryRouter } from 'react-router';
import { Settings } from '../../src/settings/Settings'; import { Settings } from '../../src/settings/Settings';
import { checkAccessibility } from '../__helpers__/accessibility'; import { checkAccessibility } from '../__helpers__/accessibility';
import { renderWithStore } from '../__helpers__/setUpTest';
describe('<Settings />', () => { describe('<Settings />', () => {
const setUp = () => render( const setUp = () => renderWithStore(
<MemoryRouter> <MemoryRouter>
<Settings settings={{}} setSettings={vi.fn()} /> <Settings />
</MemoryRouter>, </MemoryRouter>,
); );