Skip to content

Commit

Permalink
Merge pull request #52 from acelaya-forks/feature/settings-section
Browse files Browse the repository at this point in the history
Create settings page
  • Loading branch information
acelaya authored May 22, 2024
2 parents c54ea4f + e275876 commit 98e29cb
Show file tree
Hide file tree
Showing 13 changed files with 280 additions and 24 deletions.
2 changes: 1 addition & 1 deletion .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
},
"overrides": [
{
"files": ["**/app/routes/**", "vite*.config.ts"],
"files": ["**/app/routes/**", "**/app/root.tsx", "vite*.config.ts"],
"rules": {
"no-restricted-exports": "off"
}
Expand Down
14 changes: 12 additions & 2 deletions app/common/MainHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { faArrowRightFromBracket as faLogout, faChevronDown as arrowIcon } from '@fortawesome/free-solid-svg-icons';
import {
faArrowRightFromBracket as faLogout,
faChevronDown as arrowIcon,
faCogs,
} from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Link } from '@remix-run/react';
import { Link, useLocation } from '@remix-run/react';
import { useToggle } from '@shlinkio/shlink-frontend-kit';
import type { FC } from 'react';
import React from 'react';
Expand All @@ -11,6 +15,7 @@ import { ShlinkLogo } from './ShlinkLogo';
export const MainHeader: FC = () => {
const session = useSession();
const [isOpen, toggleCollapse] = useToggle();
const { pathname } = useLocation();

return (
<Navbar color="primary" dark fixed="top" className="main-header" expand="md">
Expand All @@ -26,6 +31,11 @@ export const MainHeader: FC = () => {

<Collapse navbar isOpen={isOpen}>
<Nav navbar className="tw-ml-auto">
<NavItem>
<NavLink tag={Link} to="/settings" active={pathname.startsWith('/settings')}>
<FontAwesomeIcon icon={faCogs} className="tw-w-[26px] tw-inline-block" /> Settings
</NavLink>
</NavItem>
<NavItem>
<NavLink tag={Link} to="/logout">
<FontAwesomeIcon icon={faLogout} className="tw-w-[26px] tw-inline-block" /> Logout
Expand Down
5 changes: 5 additions & 0 deletions app/db/migrations/1714375230731-migration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,11 @@ export class Migration1714375230731 implements MigrationInterface {
],
}));
await queryRunner.createForeignKey('settings', userIdFK());
await queryRunner.createIndex('settings', new TableIndex({
name: 'IDX_user_settings',
isUnique: true,
columnNames: ['user_id'],
}));

await queryRunner.createTable(new Table({
name: 'servers',
Expand Down
2 changes: 1 addition & 1 deletion app/entities/Settings.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Settings as ShlinkWebComponentSettings } from '@shlinkio/shlink-web-component';
import type { Settings as ShlinkWebComponentSettings } from '@shlinkio/shlink-web-component/settings';
import { EntitySchema } from 'typeorm';
import type { Base } from './Base';
import { BaseColumnSchema } from './Base';
Expand Down
13 changes: 9 additions & 4 deletions app/root.tsx
Original file line number Diff line number Diff line change
@@ -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<SessionData> = serverContainer[Authenticator.name],
settingsService: SettingsService = serverContainer[SettingsService.name],
) {
// FIXME This should be done during server start-up, not here
if (!appDataSource.isInitialized) {
Expand All @@ -27,15 +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 };
}

/* eslint-disable-next-line no-restricted-exports */
export default function App() {
const { session } = useLoaderData<typeof loader>();
const { session, settings } = useLoaderData<typeof loader>();

return (
<html lang="en">
<html lang="en" data-theme={settings?.ui?.theme ?? getSystemPreferredTheme()}>
<head>
<title>Shlink dashboard</title>

Expand Down
2 changes: 1 addition & 1 deletion app/routes/server.$serverId.$.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { LoaderFunctionArgs } from '@remix-run/node';
import { useLoaderData, useLocation, useParams } from '@remix-run/react';
import type { Settings } from '@shlinkio/shlink-web-component';
import type { Settings } from '@shlinkio/shlink-web-component/settings';
import type { ReactNode } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Authenticator } from 'remix-auth';
Expand Down
55 changes: 55 additions & 0 deletions app/routes/settings.$.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
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 { 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';
import { SettingsService } from '../settings/SettingsService.server';

export async function loader(
{ request }: LoaderFunctionArgs,
authenticator: Authenticator<SessionData> = serverContainer[Authenticator.name],
settingsService: SettingsService = serverContainer[SettingsService.name],
) {
const { userId } = await authenticator.isAuthenticated(request, { failureRedirect: '/login' });
return settingsService.userSettings(userId);
}

export async function action(
{ request }: ActionFunctionArgs,
authenticator: Authenticator<SessionData> = serverContainer[Authenticator.name],
settingsService: SettingsService = serverContainer[SettingsService.name],
) {
const [sessionData, newSettings] = await Promise.all([
authenticator.isAuthenticated(request),
request.json(),
]);
if (!sessionData) {
return {};
}

await settingsService.saveUserSettings(sessionData.userId, newSettings);
return {};
}

export default function Settings() {
const settings = useLoaderData<typeof loader>();
const fetcher = useFetcher();
// TODO Add some deferring
const submitSettings = useCallback((newSettings: AppSettings) => fetcher.submit(newSettings, {
method: 'POST',
encType: 'application/json',
}), [fetcher]);

return (
<div className="tw-container lg:tw-p-5 tw-p-3 mx-auto">
<ShlinkWebSettings
settings={settings}
updateSettings={submitSettings}
defaultShortUrlsListOrdering={{}}
/>
</div>
);
}
13 changes: 11 additions & 2 deletions app/settings/SettingsService.server.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Settings } from '@shlinkio/shlink-web-component';
import type { Settings } from '@shlinkio/shlink-web-component/settings';
import type { EntityManager } from 'typeorm';
import { SettingsEntity } from '../entities/Settings';
import { UserEntity } from '../entities/User';
Expand All @@ -9,10 +9,19 @@ export class SettingsService {
async userSettings(userId: number): Promise<Settings> {
const user = await this.em.findOneBy(UserEntity, { id: userId });
if (!user) {
throw new Error(`No user found for id ${userId}`);
return {};
}

const s = await this.em.findOneBy(SettingsEntity, { user });
return s?.settings ?? {};
}

async saveUserSettings(userId: number, newSettings: Settings): Promise<void> {
const user = await this.em.findOneBy(UserEntity, { id: userId });
if (!user) {
return;
}

await this.em.upsert(SettingsEntity, { user, settings: newSettings }, ['user']);
}
}
24 changes: 12 additions & 12 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
"@remix-run/react": "^2.9.2",
"@shlinkio/shlink-frontend-kit": "^0.5.1",
"@shlinkio/shlink-js-sdk": "^1.1.0",
"@shlinkio/shlink-web-component": "^0.6.2",
"@shlinkio/shlink-web-component": "^0.7.0",
"argon2": "^0.40.1",
"bootstrap": "5.2.3",
"bottlejs": "^2.0.1",
Expand Down
2 changes: 2 additions & 0 deletions test/common/MainHeader.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,11 @@ describe('<MainHeader />', () => {
if (session) {
expect(screen.getByRole('button')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Logout' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Settings' })).toBeInTheDocument();
} else {
expect(screen.queryByRole('button')).not.toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'Logout' })).not.toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'Settings' })).not.toBeInTheDocument();
}
});
});
83 changes: 83 additions & 0 deletions test/routes/settings.test.tsx
Original file line number Diff line number Diff line change
@@ -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<Authenticator<SessionData>>({ isAuthenticated });
const userSettings = vi.fn();
const saveUserSettings = vi.fn();
const settingsService = fromPartial<SettingsService>({ userSettings, saveUserSettings });

describe('loader', () => {
it('checks if user is authenticated and returns their settings', async () => {
const settings = fromPartial<Settings>({
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<Request>({ 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('<Settings />', () => {
const setUp = () => {
const RemixStub = createRemixStub([
{
path: '/settings',
Component: SettingsComp,
loader: () => json({}),
action: () => json({}),
},
]);
return render(<RemixStub />);
};

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();
});
});
});
Loading

0 comments on commit 98e29cb

Please sign in to comment.