Expose container via provider

This commit is contained in:
Alejandro Celaya
2025-11-15 10:20:53 +01:00
parent 6094994cfa
commit f301513f5b
15 changed files with 92 additions and 53 deletions

View File

@@ -1,5 +1,4 @@
import { changeThemeInMarkup, getSystemPreferredTheme } from '@shlinkio/shlink-frontend-kit'; import { changeThemeInMarkup, getSystemPreferredTheme } from '@shlinkio/shlink-frontend-kit';
import type { HttpClient } from '@shlinkio/shlink-js-sdk';
import { clsx } from 'clsx'; import { clsx } from 'clsx';
import type { FC } from 'react'; import type { FC } from 'react';
import { useEffect } from 'react'; import { useEffect } from 'react';
@@ -26,7 +25,6 @@ type AppDeps = {
ShlinkWebComponentContainer: FC; ShlinkWebComponentContainer: FC;
CreateServer: FC; CreateServer: FC;
ManageServers: FC; ManageServers: FC;
HttpClient: HttpClient;
}; };
const App: FCWithDeps<AppProps, AppDeps> = ({ appUpdated, resetAppUpdate }) => { const App: FCWithDeps<AppProps, AppDeps> = ({ appUpdated, resetAppUpdate }) => {
@@ -35,10 +33,9 @@ const App: FCWithDeps<AppProps, AppDeps> = ({ appUpdated, resetAppUpdate }) => {
ShlinkWebComponentContainer, ShlinkWebComponentContainer,
CreateServer, CreateServer,
ManageServers, ManageServers,
HttpClient: httpClient,
} = useDependencies(App); } = useDependencies(App);
useLoadRemoteServers(httpClient); useLoadRemoteServers();
const location = useLocation(); const location = useLocation();
const isHome = location.pathname === '/'; const isHome = location.pathname === '/';
@@ -92,5 +89,4 @@ export const AppFactory = componentFactory(App, [
'ShlinkWebComponentContainer', 'ShlinkWebComponentContainer',
'CreateServer', 'CreateServer',
'ManageServers', 'ManageServers',
'HttpClient',
]); ]);

View File

@@ -5,18 +5,19 @@ import {
ShlinkWebComponent, ShlinkWebComponent,
} from '@shlinkio/shlink-web-component'; } from '@shlinkio/shlink-web-component';
import { memo } from 'react'; import { memo } from 'react';
import type { ShlinkApiClientBuilder } from '../api/services/ShlinkApiClientBuilder';
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 { isReachableServer } from '../servers/data'; import { isReachableServer } from '../servers/data';
import { ServerError } from '../servers/helpers/ServerError'; import { ServerError } from '../servers/helpers/ServerError';
import type { WithSelectedServerPropsDeps } from '../servers/helpers/withSelectedServer';
import { withSelectedServer } from '../servers/helpers/withSelectedServer'; import { withSelectedServer } from '../servers/helpers/withSelectedServer';
import { useSelectedServer } from '../servers/reducers/selectedServer'; import { useSelectedServer } from '../servers/reducers/selectedServer';
import { useSettings } from '../settings/reducers/settings'; import { useSettings } from '../settings/reducers/settings';
import { NotFound } from './NotFound'; import { NotFound } from './NotFound';
type ShlinkWebComponentContainerDeps = WithSelectedServerPropsDeps & { type ShlinkWebComponentContainerDeps = {
TagColorsStorage: TagColorsStorage, TagColorsStorage: TagColorsStorage;
buildShlinkApiClient: ShlinkApiClientBuilder;
}; };
const ShlinkWebComponentContainer: FCWithDeps< const ShlinkWebComponentContainer: FCWithDeps<

24
src/container/context.ts Normal file
View File

@@ -0,0 +1,24 @@
import type { IContainer } from 'bottlejs';
import { createContext, useContext } from 'react';
const ContainerContext = createContext<IContainer | null>(null);
export const ContainerProvider = ContainerContext.Provider;
export const useDependencies = <T extends unknown[]>(...names: string[]): T => {
const container = useContext(ContainerContext);
if (!container) {
throw new Error('You cannot use "useDependency" outside of a ContainerProvider');
}
return names.map((name) => {
const dependency = container[name];
if (!dependency) {
throw new Error(`Dependency with name "${name}" not found in container`);
}
return dependency;
}) as T;
};
// TODO Create Higher Order Component that can pull dependencies from the container

View File

@@ -5,6 +5,7 @@ import pack from '../package.json';
import { ErrorHandler } from './common/ErrorHandler'; import { ErrorHandler } from './common/ErrorHandler';
import { ScrollToTop } from './common/ScrollToTop'; import { ScrollToTop } from './common/ScrollToTop';
import { container } from './container'; import { container } from './container';
import { ContainerProvider } from './container/context';
import { register as registerServiceWorker } from './serviceWorkerRegistration'; import { register as registerServiceWorker } from './serviceWorkerRegistration';
import { setUpStore } from './store'; import { setUpStore } from './store';
import './tailwind.css'; import './tailwind.css';
@@ -13,15 +14,17 @@ const store = setUpStore();
const { App, appUpdateAvailable } = container; const { App, appUpdateAvailable } = container;
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<Provider store={store}> <ContainerProvider value={container}>
<BrowserRouter basename={pack.homepage}> <Provider store={store}>
<ErrorHandler> <BrowserRouter basename={pack.homepage}>
<ScrollToTop> <ErrorHandler>
<App /> <ScrollToTop>
</ScrollToTop> <App />
</ErrorHandler> </ScrollToTop>
</BrowserRouter> </ErrorHandler>
</Provider>, </BrowserRouter>
</Provider>
</ContainerProvider>,
); );
// Learn more about service workers: https://cra.link/PWA // Learn more about service workers: https://cra.link/PWA

View File

@@ -1,19 +1,16 @@
import { Button, useParsedQuery } from '@shlinkio/shlink-frontend-kit'; import { Button, useParsedQuery } from '@shlinkio/shlink-frontend-kit';
import type { FC } from 'react';
import { NoMenuLayout } from '../common/NoMenuLayout'; import { NoMenuLayout } from '../common/NoMenuLayout';
import type { FCWithDeps } from '../container/utils';
import { useDependencies } from '../container/utils';
import { useGoBack } from '../utils/helpers/hooks'; import { useGoBack } from '../utils/helpers/hooks';
import type { ServerData } from './data'; import type { ServerData } from './data';
import { isServerWithId } from './data'; import { isServerWithId } from './data';
import { ServerForm } from './helpers/ServerForm'; import { ServerForm } from './helpers/ServerForm';
import type { WithSelectedServerPropsDeps } from './helpers/withSelectedServer';
import { withSelectedServer } from './helpers/withSelectedServer'; import { withSelectedServer } from './helpers/withSelectedServer';
import { useSelectedServer } from './reducers/selectedServer'; import { useSelectedServer } from './reducers/selectedServer';
import { useServers } from './reducers/servers'; import { useServers } from './reducers/servers';
export const EditServer: FCWithDeps<any, WithSelectedServerPropsDeps> = withSelectedServer(() => { export const EditServer: FC = withSelectedServer(() => {
const { editServer } = useServers(); const { editServer } = useServers();
const { buildShlinkApiClient } = useDependencies(EditServer);
const { selectServer, selectedServer } = useSelectedServer(); const { selectServer, selectedServer } = useSelectedServer();
const goBack = useGoBack(); const goBack = useGoBack();
const { reconnect } = useParsedQuery<{ reconnect?: 'true' }>(); const { reconnect } = useParsedQuery<{ reconnect?: 'true' }>();
@@ -25,7 +22,7 @@ export const EditServer: FCWithDeps<any, WithSelectedServerPropsDeps> = withSele
const handleSubmit = (serverData: ServerData) => { const handleSubmit = (serverData: ServerData) => {
editServer(selectedServer.id, serverData); editServer(selectedServer.id, serverData);
if (reconnect === 'true') { if (reconnect === 'true') {
selectServer({ serverId: selectedServer.id, buildShlinkApiClient }); selectServer(selectedServer.id);
} }
goBack(); goBack();
}; };

View File

@@ -1,31 +1,22 @@
import { Message } from '@shlinkio/shlink-frontend-kit'; import { Message } from '@shlinkio/shlink-frontend-kit';
import type { FC } from 'react';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useParams } from 'react-router'; import { useParams } from 'react-router';
import type { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
import { NoMenuLayout } from '../../common/NoMenuLayout'; import { NoMenuLayout } from '../../common/NoMenuLayout';
import type { FCWithDeps } from '../../container/utils';
import { useDependencies } from '../../container/utils';
import { isNotFoundServer } from '../data'; import { isNotFoundServer } from '../data';
import { useSelectedServer } from '../reducers/selectedServer'; import { useSelectedServer } from '../reducers/selectedServer';
import { ServerError } from './ServerError'; import { ServerError } from './ServerError';
export type WithSelectedServerPropsDeps = { export function withSelectedServer<T extends object>(WrappedComponent: FC<T>) {
buildShlinkApiClient: ShlinkApiClientBuilder; const ComponentWrapper: FC<T> = (props) => {
};
export function withSelectedServer<T extends object>(
WrappedComponent: FCWithDeps<T, WithSelectedServerPropsDeps>,
) {
const ComponentWrapper: FCWithDeps<T, WithSelectedServerPropsDeps> = (props) => {
const { buildShlinkApiClient } = useDependencies(ComponentWrapper);
const params = useParams<{ serverId: string }>(); const params = useParams<{ serverId: string }>();
const { selectServer, selectedServer } = useSelectedServer(); const { selectServer, selectedServer } = useSelectedServer();
useEffect(() => { useEffect(() => {
if (params.serverId) { if (params.serverId) {
selectServer({ serverId: params.serverId, buildShlinkApiClient }); selectServer(params.serverId);
} }
}, [buildShlinkApiClient, params.serverId, selectServer]); }, [params.serverId, selectServer]);
if (!selectedServer) { if (!selectedServer) {
return ( return (

View File

@@ -1,6 +1,7 @@
import type { HttpClient } from '@shlinkio/shlink-js-sdk'; import type { HttpClient } from '@shlinkio/shlink-js-sdk';
import { useCallback, useEffect, useRef } from 'react'; import { useCallback, useEffect, useRef } from 'react';
import pack from '../../../package.json'; import pack from '../../../package.json';
import { useDependencies } from '../../container/context';
import { useAppDispatch } from '../../store'; import { useAppDispatch } from '../../store';
import { createAsyncThunk } from '../../store/helpers'; import { createAsyncThunk } from '../../store/helpers';
import { hasServerData } from '../data'; import { hasServerData } from '../data';
@@ -24,12 +25,13 @@ export const fetchServers = createAsyncThunk(
export const useRemoteServers = () => { export const useRemoteServers = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const dispatchFetchServer = useCallback((httpClient: HttpClient) => dispatch(fetchServers(httpClient)), [dispatch]); const [httpClient] = useDependencies<[HttpClient]>('HttpClient');
const dispatchFetchServer = useCallback(() => dispatch(fetchServers(httpClient)), [dispatch, httpClient]);
return { fetchServers: dispatchFetchServer }; return { fetchServers: dispatchFetchServer };
}; };
export const useLoadRemoteServers = (httpClient: HttpClient) => { export const useLoadRemoteServers = () => {
const { fetchServers } = useRemoteServers(); const { fetchServers } = useRemoteServers();
const { servers } = useServers(); const { servers } = useServers();
const initialServers = useRef(servers); const initialServers = useRef(servers);
@@ -38,7 +40,7 @@ export const useLoadRemoteServers = (httpClient: HttpClient) => {
// Try to fetch the remote servers if the list is empty during first render. // Try to fetch the remote servers if the list is empty during first render.
// We use a ref because we don't care if the servers list becomes empty later. // We use a ref because we don't care if the servers list becomes empty later.
if (Object.keys(initialServers.current).length === 0) { if (Object.keys(initialServers.current).length === 0) {
fetchServers(httpClient); fetchServers();
} }
}, [fetchServers, httpClient]); }, [fetchServers]);
}; };

View File

@@ -3,6 +3,7 @@ import { memoizeWith } from '@shlinkio/data-manipulation';
import type { ShlinkHealth } from '@shlinkio/shlink-web-component/api-contract'; import type { ShlinkHealth } from '@shlinkio/shlink-web-component/api-contract';
import { useCallback } from 'react'; import { useCallback } from 'react';
import type { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import type { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
import { useDependencies } from '../../container/context';
import { useAppDispatch, useAppSelector } from '../../store'; import { useAppDispatch, useAppSelector } from '../../store';
import { createAsyncThunk } from '../../store/helpers'; import { createAsyncThunk } from '../../store/helpers';
import { versionToPrintable, versionToSemVer as toSemVer } from '../../utils/helpers/version'; import { versionToPrintable, versionToSemVer as toSemVer } from '../../utils/helpers/version';
@@ -75,10 +76,11 @@ export const { reducer: selectedServerReducer } = createSlice({
export const useSelectedServer = () => { export const useSelectedServer = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const [buildShlinkApiClient] = useDependencies<[ShlinkApiClientBuilder]>('buildShlinkApiClient');
const dispatchResetSelectedServer = useCallback(() => dispatch(resetSelectedServer()), [dispatch]); const dispatchResetSelectedServer = useCallback(() => dispatch(resetSelectedServer()), [dispatch]);
const dispatchSelectServer = useCallback( const dispatchSelectServer = useCallback(
(options: SelectServerOptions) => dispatch(selectServer(options)), (serverId: string) => dispatch(selectServer({ serverId, buildShlinkApiClient })),
[dispatch], [buildShlinkApiClient, dispatch],
); );
const selectedServer = useAppSelector(({ selectedServer }) => selectedServer); const selectedServer = useAppSelector(({ selectedServer }) => selectedServer);

View File

@@ -3,6 +3,7 @@ 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 { ContainerProvider } from '../../src/container/context';
import type { ServerWithId } from '../../src/servers/data'; import type { ServerWithId } from '../../src/servers/data';
import { checkAccessibility } from '../__helpers__/accessibility'; import { checkAccessibility } from '../__helpers__/accessibility';
import { renderWithStore } from '../__helpers__/setUpTest'; import { renderWithStore } from '../__helpers__/setUpTest';
@@ -14,12 +15,15 @@ describe('<App />', () => {
ShlinkWebComponentContainer: () => <>ShlinkWebComponentContainer</>, ShlinkWebComponentContainer: () => <>ShlinkWebComponentContainer</>,
CreateServer: () => <>CreateServer</>, CreateServer: () => <>CreateServer</>,
ManageServers: () => <>ManageServers</>, ManageServers: () => <>ManageServers</>,
HttpClient: fromPartial<HttpClient>({}),
}), }),
); );
const setUp = async (activeRoute = '/') => act(() => renderWithStore( const setUp = async (activeRoute = '/') => act(() => renderWithStore(
<MemoryRouter initialEntries={[{ pathname: activeRoute }]}> <MemoryRouter initialEntries={[{ pathname: activeRoute }]}>
<App appUpdated={false} resetAppUpdate={() => {}} /> <ContainerProvider
value={fromPartial({ HttpClient: fromPartial<HttpClient>({}), buildShlinkApiClient: vi.fn() })}
>
<App appUpdated={false} resetAppUpdate={() => {}} />
</ContainerProvider>
</MemoryRouter>, </MemoryRouter>,
{ {
initialState: { initialState: {

View File

@@ -1,7 +1,9 @@
import { screen } from '@testing-library/react'; import { screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import { createMemoryHistory } from 'history'; import { createMemoryHistory } from 'history';
import { Router } from 'react-router'; import { Router } from 'react-router';
import { MainHeader } from '../../src/common/MainHeader'; import { MainHeader } from '../../src/common/MainHeader';
import { ContainerProvider } from '../../src/container/context';
import { checkAccessibility } from '../__helpers__/accessibility'; import { checkAccessibility } from '../__helpers__/accessibility';
import { renderWithStore } from '../__helpers__/setUpTest'; import { renderWithStore } from '../__helpers__/setUpTest';
@@ -12,7 +14,9 @@ describe('<MainHeader />', () => {
return renderWithStore( return renderWithStore(
<Router location={history.location} navigator={history}> <Router location={history.location} navigator={history}>
<MainHeader /> <ContainerProvider value={fromPartial({ buildShlinkApiClient: vi.fn() })}>
<MainHeader />
</ContainerProvider>
</Router>, </Router>,
); );
}; };

View File

@@ -1,12 +1,15 @@
import { fromPartial } from '@total-typescript/shoehorn'; import { fromPartial } from '@total-typescript/shoehorn';
import { ShlinkVersionsContainer } from '../../src/common/ShlinkVersionsContainer'; import { ShlinkVersionsContainer } from '../../src/common/ShlinkVersionsContainer';
import { ContainerProvider } from '../../src/container/context';
import type { ReachableServer, SelectedServer } from '../../src/servers/data'; import type { ReachableServer, SelectedServer } from '../../src/servers/data';
import { checkAccessibility } from '../__helpers__/accessibility'; import { checkAccessibility } from '../__helpers__/accessibility';
import { renderWithStore } from '../__helpers__/setUpTest'; import { renderWithStore } from '../__helpers__/setUpTest';
describe('<ShlinkVersionsContainer />', () => { describe('<ShlinkVersionsContainer />', () => {
const setUp = (selectedServer: SelectedServer = null) => renderWithStore( const setUp = (selectedServer: SelectedServer = null) => renderWithStore(
<ShlinkVersionsContainer />, <ContainerProvider value={fromPartial({ buildShlinkApiClient: vi.fn() })}>
<ShlinkVersionsContainer />
</ContainerProvider>,
{ {
initialState: { selectedServer }, initialState: { selectedServer },
}, },

View File

@@ -2,6 +2,7 @@ import { 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 { ShlinkWebComponentContainerFactory } from '../../src/common/ShlinkWebComponentContainer'; import { ShlinkWebComponentContainerFactory } from '../../src/common/ShlinkWebComponentContainer';
import { ContainerProvider } from '../../src/container/context';
import type { NonReachableServer, NotFoundServer, SelectedServer } from '../../src/servers/data'; import type { NonReachableServer, NotFoundServer, SelectedServer } from '../../src/servers/data';
import { checkAccessibility } from '../__helpers__/accessibility'; import { checkAccessibility } from '../__helpers__/accessibility';
import { renderWithStore } from '../__helpers__/setUpTest'; import { renderWithStore } from '../__helpers__/setUpTest';
@@ -19,7 +20,9 @@ describe('<ShlinkWebComponentContainer />', () => {
})); }));
const setUp = (selectedServer: SelectedServer) => renderWithStore( const setUp = (selectedServer: SelectedServer) => renderWithStore(
<MemoryRouter> <MemoryRouter>
<ShlinkWebComponentContainer /> <ContainerProvider value={fromPartial({ buildShlinkApiClient: vi.fn() })}>
<ShlinkWebComponentContainer />
</ContainerProvider>
</MemoryRouter>, </MemoryRouter>,
{ {
initialState: { selectedServer, servers: {}, settings: {} }, initialState: { selectedServer, servers: {}, settings: {} },

View File

@@ -2,6 +2,7 @@ import { fireEvent, screen, waitFor } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn'; import { fromPartial } from '@total-typescript/shoehorn';
import { createMemoryHistory } from 'history'; import { createMemoryHistory } from 'history';
import { Router } from 'react-router'; import { Router } from 'react-router';
import { ContainerProvider } from '../../src/container/context';
import type { ReachableServer, SelectedServer } from '../../src/servers/data'; import type { ReachableServer, SelectedServer } from '../../src/servers/data';
import { isServerWithId } from '../../src/servers/data'; import { isServerWithId } from '../../src/servers/data';
import { EditServer } from '../../src/servers/EditServer'; import { EditServer } from '../../src/servers/EditServer';
@@ -21,7 +22,9 @@ describe('<EditServer />', () => {
history, history,
...renderWithStore( ...renderWithStore(
<Router location={history.location} navigator={history}> <Router location={history.location} navigator={history}>
<EditServer /> <ContainerProvider value={fromPartial({ buildShlinkApiClient: vi.fn() })}>
<EditServer />
</ContainerProvider>
</Router>, </Router>,
{ {
initialState: { initialState: {

View File

@@ -1,6 +1,7 @@
import { screen } from '@testing-library/react'; import { 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 { ContainerProvider } from '../../src/container/context';
import type { ServersMap } from '../../src/servers/data'; import type { ServersMap } from '../../src/servers/data';
import { ServersDropdown } from '../../src/servers/ServersDropdown'; import { ServersDropdown } from '../../src/servers/ServersDropdown';
import { checkAccessibility } from '../__helpers__/accessibility'; import { checkAccessibility } from '../__helpers__/accessibility';
@@ -14,9 +15,11 @@ describe('<ServersDropdown />', () => {
}; };
const setUp = (servers: ServersMap = fallbackServers) => renderWithStore( const setUp = (servers: ServersMap = fallbackServers) => renderWithStore(
<MemoryRouter> <MemoryRouter>
<ul role="menu"> <ContainerProvider value={fromPartial({ buildShlinkApiClient: vi.fn() })}>
<ServersDropdown /> <ul role="menu">
</ul> <ServersDropdown />
</ul>
</ContainerProvider>
</MemoryRouter>, </MemoryRouter>,
{ {
initialState: { selectedServer: null, servers }, initialState: { selectedServer: null, servers },

View File

@@ -1,6 +1,7 @@
import { screen } from '@testing-library/react'; import { 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 { ContainerProvider } from '../../../src/container/context';
import type { NonReachableServer, NotFoundServer, SelectedServer } from '../../../src/servers/data'; import type { NonReachableServer, NotFoundServer, SelectedServer } from '../../../src/servers/data';
import { ServerError } from '../../../src/servers/helpers/ServerError'; import { ServerError } from '../../../src/servers/helpers/ServerError';
import { checkAccessibility } from '../../__helpers__/accessibility'; import { checkAccessibility } from '../../__helpers__/accessibility';
@@ -9,7 +10,9 @@ import { renderWithStore } from '../../__helpers__/setUpTest';
describe('<ServerError />', () => { describe('<ServerError />', () => {
const setUp = (selectedServer: SelectedServer) => renderWithStore( const setUp = (selectedServer: SelectedServer) => renderWithStore(
<MemoryRouter> <MemoryRouter>
<ServerError /> <ContainerProvider value={fromPartial({ buildShlinkApiClient: vi.fn() })}>
<ServerError />
</ContainerProvider>
</MemoryRouter>, </MemoryRouter>,
{ {
initialState: { selectedServer, servers: {} }, initialState: { selectedServer, servers: {} },