diff --git a/app/root.tsx b/app/root.tsx index 1f525b1..8c45cef 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -1,16 +1,19 @@ import type { LoaderFunctionArgs } from '@remix-run/node'; import { Links, Meta, Outlet, Scripts, useLoaderData } from '@remix-run/react'; +import { getSystemPreferredTheme } from '@shlinkio/shlink-frontend-kit'; import { Authenticator } from 'remix-auth'; import type { SessionData } from './auth/session-context'; import { SessionProvider } from './auth/session-context'; import { MainHeader } from './common/MainHeader'; import { serverContainer } from './container/container.server'; import { appDataSource } from './db/data-source.server'; +import { SettingsService } from './settings/SettingsService.server'; import './index.scss'; export async function loader( { request }: LoaderFunctionArgs, authenticator: Authenticator = serverContainer[Authenticator.name], + settingsService: SettingsService = serverContainer[SettingsService.name], ) { // FIXME This should be done during server start-up, not here if (!appDataSource.isInitialized) { @@ -27,14 +30,17 @@ export async function loader( request, { failureRedirect: `/login?redirect-to=${encodeURIComponent(pathname)}` }, )); - return { session }; + + const settings = session && await settingsService.userSettings(session.userId); + + return { session, settings }; } export default function App() { - const { session } = useLoaderData(); + const { session, settings } = useLoaderData(); return ( - + Shlink dashboard diff --git a/app/routes/settings.$.tsx b/app/routes/settings.$.tsx index 63ee60f..2f5ff67 100644 --- a/app/routes/settings.$.tsx +++ b/app/routes/settings.$.tsx @@ -1,7 +1,8 @@ import type { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/node'; import { useFetcher, useLoaderData } from '@remix-run/react'; import type { Settings as AppSettings } from '@shlinkio/shlink-web-component/settings'; -import { type ReactNode, useCallback, useEffect, useState } from 'react'; +import { ShlinkWebSettings } from '@shlinkio/shlink-web-component/settings'; +import { useCallback } from 'react'; import { Authenticator } from 'remix-auth'; import type { SessionData } from '../auth/session-context'; import { serverContainer } from '../container/container.server'; @@ -9,8 +10,8 @@ import { SettingsService } from '../settings/SettingsService.server'; export async function loader( { request }: LoaderFunctionArgs, - settingsService: SettingsService = serverContainer[SettingsService.name], authenticator: Authenticator = serverContainer[Authenticator.name], + settingsService: SettingsService = serverContainer[SettingsService.name], ) { const { userId } = await authenticator.isAuthenticated(request, { failureRedirect: '/login' }); return settingsService.userSettings(userId); @@ -18,8 +19,8 @@ export async function loader( export async function action( { request }: ActionFunctionArgs, - settingsService: SettingsService = serverContainer[SettingsService.name], authenticator: Authenticator = serverContainer[Authenticator.name], + settingsService: SettingsService = serverContainer[SettingsService.name], ) { const [sessionData, newSettings] = await Promise.all([ authenticator.isAuthenticated(request), @@ -30,12 +31,10 @@ export async function action( } await settingsService.saveUserSettings(sessionData.userId, newSettings); - return {}; } export default function Settings() { - const [component, setComponent] = useState(null); const settings = useLoaderData(); const fetcher = useFetcher(); // TODO Add some deferring @@ -44,19 +43,13 @@ export default function Settings() { encType: 'application/json', }), [fetcher]); - useEffect(() => { - import('@shlinkio/shlink-web-component/settings').then(({ ShlinkWebSettings }) => setComponent( + return ( +
, - )); - }, [submitSettings, settings]); - - return ( -
- {component} + defaultShortUrlsListOrdering={{}} + />
); } diff --git a/test/routes/settings.test.tsx b/test/routes/settings.test.tsx new file mode 100644 index 0000000..3481a73 --- /dev/null +++ b/test/routes/settings.test.tsx @@ -0,0 +1,83 @@ +import type { ActionFunctionArgs } from '@remix-run/node'; +import { json } from '@remix-run/node'; +import { createRemixStub } from '@remix-run/testing'; +import type { Settings } from '@shlinkio/shlink-web-component/settings'; +import { render, screen, waitFor } from '@testing-library/react'; +import { fromPartial } from '@total-typescript/shoehorn'; +import type { Authenticator } from 'remix-auth'; +import type { SessionData } from '../../app/auth/session-context'; +import SettingsComp, { action as settingsAction, loader } from '../../app/routes/settings.$'; +import type { SettingsService } from '../../app/settings/SettingsService.server'; + +describe('settings', () => { + const isAuthenticated = vi.fn(); + const authenticator = fromPartial>({ isAuthenticated }); + const userSettings = vi.fn(); + const saveUserSettings = vi.fn(); + const settingsService = fromPartial({ userSettings, saveUserSettings }); + + describe('loader', () => { + it('checks if user is authenticated and returns their settings', async () => { + const settings = fromPartial({ + ui: { theme: 'dark' }, + }); + userSettings.mockResolvedValue(settings); + isAuthenticated.mockResolvedValue({ userId: 1 }); + + const result = await loader(fromPartial({ request: {} }), authenticator, settingsService); + + expect(result).toEqual(settings); + expect(userSettings).toHaveBeenCalled(); + expect(isAuthenticated).toHaveBeenCalled(); + }); + }); + + describe('action', () => { + const setUp = () => (args: ActionFunctionArgs) => settingsAction(args, authenticator, settingsService); + const request = fromPartial({ json: vi.fn().mockResolvedValue({}) }); + + it('does not save settings when user is not logged in', async () => { + const action = setUp(); + + isAuthenticated.mockResolvedValue(null); + + await action(fromPartial({ request })); + + expect(isAuthenticated).toHaveBeenCalledWith(request); + expect(saveUserSettings).not.toHaveBeenCalled(); + }); + + it('saves settings when user is logged in', async () => { + const action = setUp(); + + isAuthenticated.mockResolvedValue({ userId: 1 }); + + await action(fromPartial({ request })); + + expect(isAuthenticated).toHaveBeenCalledWith(request); + expect(saveUserSettings).toHaveBeenCalledWith(1, {}); + }); + }); + + // Skipping for now, as createRemixStub always results in a 404 page + describe.skip('', () => { + const setUp = () => { + const RemixStub = createRemixStub([ + { + path: '/settings', + Component: SettingsComp, + loader: () => json({}), + action: () => json({}), + }, + ]); + return render(); + }; + + it('renders settings component', async () => { + setUp(); + + await waitFor(() => expect(screen.getByRole('heading', { name: 'User interface' })).toBeInTheDocument()); + expect(screen.getByRole('heading', { name: 'Real-time updates' })).toBeInTheDocument(); + }); + }); +});