feat: move my apps page to RSC

This commit is contained in:
Nicolas Meienberger 2023-09-06 08:41:59 +02:00 committed by Nicolas Meienberger
parent da31470fd7
commit a8933e592e
18 changed files with 122 additions and 188 deletions

View File

@ -40,7 +40,7 @@ services:
tipi-redis:
container_name: tipi-redis
image: redis:alpine
image: redis:7.2.0
restart: unless-stopped
command: redis-server --requirepass ${REDIS_PASSWORD}
ports:

View File

@ -995,8 +995,8 @@ packages:
'@commitlint/types': 17.4.4
'@types/node': 20.4.7
chalk: 4.1.2
cosmiconfig: 8.2.0
cosmiconfig-typescript-loader: 4.4.0(@types/node@20.4.7)(cosmiconfig@8.2.0)(ts-node@10.9.1)(typescript@4.7.4)
cosmiconfig: 8.3.4(typescript@4.7.4)
cosmiconfig-typescript-loader: 4.4.0(@types/node@20.4.7)(cosmiconfig@8.3.4)(ts-node@10.9.1)(typescript@4.7.4)
lodash.isplainobject: 4.0.6
lodash.merge: 4.6.2
lodash.uniq: 4.5.0
@ -5430,7 +5430,7 @@ packages:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
dev: true
/cosmiconfig-typescript-loader@4.4.0(@types/node@20.4.7)(cosmiconfig@8.2.0)(ts-node@10.9.1)(typescript@4.7.4):
/cosmiconfig-typescript-loader@4.4.0(@types/node@20.4.7)(cosmiconfig@8.3.4)(ts-node@10.9.1)(typescript@4.7.4):
resolution: {integrity: sha512-BabizFdC3wBHhbI4kJh0VkQP9GkBfoHPydD0COMce1nJ1kJAB3F2TmJ/I7diULBKtmEWSwEbuN/KDtgnmUUVmw==}
engines: {node: '>=v14.21.3'}
peerDependencies:
@ -5440,7 +5440,7 @@ packages:
typescript: '>=4'
dependencies:
'@types/node': 20.4.7
cosmiconfig: 8.2.0
cosmiconfig: 8.3.4(typescript@4.7.4)
ts-node: 10.9.1(@types/node@18.6.2)(typescript@4.7.4)
typescript: 4.7.4
dev: true
@ -5456,14 +5456,20 @@ packages:
yaml: 1.10.2
dev: false
/cosmiconfig@8.2.0:
resolution: {integrity: sha512-3rTMnFJA1tCOPwRxtgF4wd7Ab2qvDbL8jX+3smjIbS4HlZBagTlpERbdN7iAbWlrfxE3M8c27kTwTawQ7st+OQ==}
/cosmiconfig@8.3.4(typescript@4.7.4):
resolution: {integrity: sha512-SF+2P8+o/PTV05rgsAjDzL4OFdVXAulSfC/L19VaeVT7+tpOOSscCt2QLxDZ+CLxF2WOiq6y1K5asvs8qUJT/Q==}
engines: {node: '>=14'}
peerDependencies:
typescript: '>=4.9.5'
peerDependenciesMeta:
typescript:
optional: true
dependencies:
import-fresh: 3.3.0
js-yaml: 4.1.0
parse-json: 5.2.0
path-type: 4.0.0
typescript: 4.7.4
dev: true
/create-require@1.1.1:

View File

@ -1,3 +1,5 @@
'use client';
import Link from 'next/link';
import React from 'react';
import { IconDownload } from '@tabler/icons-react';
@ -5,9 +7,9 @@ import { Tooltip } from 'react-tooltip';
import type { AppStatus as AppStatusEnum } from '@/server/db/schema';
import { useTranslations } from 'next-intl';
import type { AppInfo } from '@runtipi/shared';
import { AppStatus } from '../AppStatus';
import { AppLogo } from '../AppLogo/AppLogo';
import { limitText } from '../../modules/AppStore/helpers/table.helpers';
import { AppLogo } from '@/components/AppLogo';
import { AppStatus } from '@/components/AppStatus';
import { limitText } from '@/client/modules/AppStore/helpers/table.helpers';
import styles from './AppTile.module.scss';
type AppTileInfo = Pick<AppInfo, 'id' | 'name' | 'description' | 'short_desc'>;

View File

@ -0,0 +1,38 @@
import { AppServiceClass } from '@/server/services/apps/apps.service';
import { db } from '@/server/db';
import React from 'react';
import { AppRouterOutput } from '@/server/routers/app/app.router';
import { useUIStore } from '@/client/state/uiStore';
import { Metadata } from 'next';
import { AppTile } from './components/AppTile';
import { EmptyPage } from '../../components/EmptyPage';
export async function generateMetadata(): Promise<Metadata> {
const { translator } = useUIStore.getState();
return {
title: `${translator('apps.my-apps.title')} - Tipi`,
};
}
export default async function Page() {
const appsService = new AppServiceClass(db);
const installedApps = await appsService.installedApps();
const renderApp = (app: AppRouterOutput['installedApps'][number]) => {
const updateAvailable = Number(app.version) < Number(app.latestVersion);
if (app.info?.available) return <AppTile key={app.id} app={app.info} status={app.status} updateAvailable={updateAvailable} />;
return null;
};
return (
<>
{installedApps.length === 0 && <EmptyPage title="apps.my-apps.empty-title" subtitle="apps.my-apps.empty-subtitle" redirectPath="/app-store" actionLabel="apps.my-apps.empty-action" />}
<div className="row row-cards " data-testid="apps-list">
{installedApps?.map(renderApp)}
</div>
</>
);
}

View File

@ -1,4 +1,3 @@
// "use client"
import { IconApps, IconBrandAppstore, IconHome, IconSettings, Icon } from '@tabler/icons-react';
import clsx from 'clsx';
import { useTranslations } from 'next-intl';

View File

@ -0,0 +1,4 @@
.emptyImage {
height: 80px;
width: 80px;
}

View File

@ -0,0 +1,47 @@
'use client';
import clsx from 'clsx';
import Image from 'next/image';
import React from 'react';
import { Button } from '@/components/ui/Button';
import { useTranslations } from 'next-intl';
import { MessageKey } from '@/server/utils/errors';
import { useRouter } from 'next/navigation';
import styles from './EmptyPage.module.scss';
interface IProps {
title: MessageKey;
subtitle?: MessageKey;
actionLabel?: MessageKey;
redirectPath?: string;
}
export const EmptyPage: React.FC<IProps> = ({ title, subtitle, redirectPath, actionLabel }) => {
const t = useTranslations();
const router = useRouter();
return (
<div className="card empty">
<Image
src="/empty.svg"
alt="Empty box"
height="80"
width="80"
className={clsx(styles.emptyImage, 'mb-3')}
style={{
maxWidth: '100%',
height: '80px',
}}
/>
<p className="empty-title">{t(title)}</p>
{subtitle && <p className="empty-subtitle text-muted">{t(subtitle)}</p>}
<div className="empty-action">
{redirectPath && actionLabel && (
<Button data-testid="empty-page-action" onClick={() => router.push(redirectPath)} className="btn-primary">
{t(actionLabel)}
</Button>
)}
</div>
</div>
);
};

View File

@ -0,0 +1 @@
export { EmptyPage } from './EmptyPage';

View File

@ -5,11 +5,10 @@ import { Inter } from 'next/font/google';
import { cookies, headers } from 'next/headers';
import { getLocaleFromString } from '@/shared/internationalization/locales';
import merge from 'lodash.merge';
import { NextIntlClientProvider, createTranslator } from 'next-intl';
import { NextIntlClientProvider } from 'next-intl';
import './global.css';
import clsx from 'clsx';
import { useUIStore } from '@/client/state/uiStore';
const inter = Inter({
subsets: ['latin'],
@ -34,15 +33,9 @@ export default async function RootLayout({ children }: { children: React.ReactNo
const messages = (await import(`../client/messages/${locale}.json`)).default;
const mergedMessages = merge(englishMessages, messages);
const translator = createTranslator({
messages: mergedMessages,
locale,
});
useUIStore.getState().setTranslator(translator);
return (
<html lang={locale} className={clsx(inter.className, 'border-top-wide border-primary')}>
<NextIntlClientProvider locale="en" messages={mergedMessages}>
<NextIntlClientProvider locale={locale} messages={mergedMessages}>
<body>{children}</body>
</NextIntlClientProvider>
</html>

View File

@ -286,6 +286,7 @@
},
"header": {
"dashboard": "Dashboard",
"apps": "My Apps",
"my-apps": "My Apps",
"app-store": "App Store",
"settings": "Settings",

View File

@ -1,109 +0,0 @@
import React from 'react';
import { fireEvent, render, screen, waitFor } from '../../../../../../tests/test-utils';
import { createAppEntity } from '../../../../mocks/fixtures/app.fixtures';
import { getTRPCMock, getTRPCMockError } from '../../../../mocks/getTrpcMock';
import { server } from '../../../../mocks/server';
import { AppsPage } from './AppsPage';
const pushFn = jest.fn();
jest.mock('next/router', () => {
const actualRouter = jest.requireActual('next-router-mock');
return {
...actualRouter,
useRouter: () => ({
...actualRouter.useRouter(),
push: pushFn,
}),
};
});
describe('AppsPage', () => {
it('should render', async () => {
// Arrange
const app = createAppEntity({});
server.use(getTRPCMock({ path: ['app', 'installedApps'], response: [app] }));
render(<AppsPage />);
// Assert
await waitFor(() => {
expect(screen.getByTestId('apps-list')).toBeInTheDocument();
});
});
it('should render all installed apps', async () => {
// Arrange
const app1 = createAppEntity({});
const app2 = createAppEntity({});
server.use(getTRPCMock({ path: ['app', 'installedApps'], response: [app1, app2] }));
render(<AppsPage />);
// Assert
await waitFor(() => {
expect(screen.getByTestId('apps-list')).toBeInTheDocument();
});
const displayedAppIds = screen.getAllByTestId(/app-tile-/);
expect(displayedAppIds).toHaveLength(2);
});
it('Should not render app tile if app is not available', async () => {
// Arrange
const app = createAppEntity({ overridesInfo: { available: false } });
server.use(getTRPCMock({ path: ['app', 'installedApps'], response: [app] }));
render(<AppsPage />);
// Assert
await waitFor(() => {
expect(screen.getByTestId('apps-list')).toBeInTheDocument();
});
expect(screen.queryByTestId(/app-tile-/)).not.toBeInTheDocument();
});
});
describe('AppsPage - Empty', () => {
beforeEach(() => {
server.use(getTRPCMock({ path: ['app', 'installedApps'], response: [] }));
});
it('should render empty page if no app is installed', async () => {
// Arrange
render(<AppsPage />);
await waitFor(() => {
expect(screen.getByTestId('empty-page')).toBeInTheDocument();
});
// Assert
expect(screen.queryByTestId('apps-list')).not.toBeInTheDocument();
});
it('should trigger navigation to app store on click on action button', async () => {
// Arrange
render(<AppsPage />);
await waitFor(() => {
expect(screen.getByTestId('empty-page')).toBeInTheDocument();
});
// Act
const actionButton = screen.getByTestId('empty-page-action');
fireEvent.click(actionButton);
// Assert
expect(actionButton).toHaveTextContent('Go to app store');
expect(pushFn).toHaveBeenCalledWith('/app-store');
});
});
describe('AppsPage - Error', () => {
it('should render error page if an error occurs', async () => {
// Arrange
server.use(getTRPCMockError({ path: ['app', 'installedApps'], type: 'query', message: 'test-error' }));
render(<AppsPage />);
// Assert
await waitFor(() => {
expect(screen.getByTestId('error-page')).toBeInTheDocument();
});
expect(screen.getByText('test-error')).toHaveTextContent('test-error');
expect(screen.queryByTestId('apps-list')).not.toBeInTheDocument();
});
});

View File

@ -1,42 +0,0 @@
import React from 'react';
import { useRouter } from 'next/router';
import { NextPage } from 'next';
import { useTranslations } from 'next-intl';
import type { MessageKey } from '@/server/utils/errors';
import { AppTile } from '../../../../components/AppTile';
import { Layout } from '../../../../components/Layout';
import { EmptyPage } from '../../../../components/ui/EmptyPage';
import { ErrorPage } from '../../../../components/ui/ErrorPage';
import { trpc } from '../../../../utils/trpc';
import { AppRouterOutput } from '../../../../../server/routers/app/app.router';
export const AppsPage: NextPage = () => {
const t = useTranslations();
const { data, isLoading, error } = trpc.app.installedApps.useQuery();
const renderApp = (app: AppRouterOutput['installedApps'][number]) => {
const updateAvailable = Number(app.version) < Number(app.latestVersion);
if (app.info?.available) return <AppTile key={app.id} app={app.info} status={app.status} updateAvailable={updateAvailable} />;
return null;
};
const router = useRouter();
return (
<Layout title={t('apps.my-apps.title')}>
<div>
{Boolean(data?.length) && (
<div className="row row-cards" data-testid="apps-list">
{data?.map(renderApp)}
</div>
)}
{!isLoading && data?.length === 0 && (
<EmptyPage title={t('apps.my-apps.empty-title')} subtitle={t('apps.my-apps.empty-subtitle')} onAction={() => router.push('/app-store')} actionLabel={t('apps.my-apps.empty-action')} />
)}
{error && <ErrorPage error={t(error.data?.tError.message as MessageKey, { ...error.data?.tError?.variables })} />}
</div>
</Layout>
);
};

View File

@ -1 +0,0 @@
export { AppsPage } from './AppsPage';

View File

@ -1,6 +1,8 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
const COOKIE_MAX_AGE = 60 * 60 * 24; // 1 day
/**
* Middleware to set session ID in request headers
* @param {NextRequest} request - Request object
@ -27,6 +29,13 @@ export async function middleware(request: NextRequest) {
if (sessionId) {
response.headers.set('x-session-id', sessionId);
response.cookies.set('tipi.sid', sessionId, {
maxAge: COOKIE_MAX_AGE,
httpOnly: true,
secure: false,
sameSite: false,
});
}
return response;

View File

@ -1,14 +0,0 @@
import { getAuthedPageProps, getMessagesPageProps } from '@/utils/page-helpers';
import merge from 'lodash.merge';
import { GetServerSideProps } from 'next';
export { AppsPage as default } from '../../client/modules/Apps/pages/AppsPage';
export const getServerSideProps: GetServerSideProps = async (ctx) => {
const authedProps = await getAuthedPageProps(ctx);
const messagesProps = await getMessagesPageProps(ctx);
return merge(authedProps, messagesProps, {
props: {},
});
};

View File

@ -20,8 +20,8 @@ export const setSession = async (sessionId: string, userId: string, req: NextApi
const sessionKey = `session:${sessionId}`;
await cache.set(sessionKey, userId);
await cache.set(`session:${userId}:${sessionId}`, sessionKey);
await cache.set(sessionKey, userId, COOKIE_MAX_AGE * 7);
await cache.set(`session:${userId}:${sessionId}`, sessionKey, COOKIE_MAX_AGE * 7);
await cache.close();
};