Sfoglia il codice sorgente

feat: move my apps page to RSC

Nicolas Meienberger 1 anno fa
parent
commit
a8933e592e

+ 1 - 1
docker-compose.dev.yml

@@ -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:

+ 12 - 6
pnpm-lock.yaml

@@ -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:

+ 0 - 0
src/client/components/AppTile/AppTile.module.scss → src/app/(dashboard)/apps/components/AppTile.module.scss


+ 5 - 3
src/client/components/AppTile/AppTile.tsx → src/app/(dashboard)/apps/components/AppTile.tsx

@@ -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'>;

+ 0 - 0
src/client/components/AppTile/index.tsx → src/app/(dashboard)/apps/components/index.tsx


+ 38 - 0
src/app/(dashboard)/apps/page.tsx

@@ -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>
+    </>
+  );
+}

+ 0 - 1
src/app/(dashboard)/components/NavBar/NavBar.tsx

@@ -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';

+ 4 - 0
src/app/components/EmptyPage/EmptyPage.module.scss

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

+ 47 - 0
src/app/components/EmptyPage/EmptyPage.tsx

@@ -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>
+  );
+};

+ 1 - 0
src/app/components/EmptyPage/index.ts

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

+ 2 - 9
src/app/layout.tsx

@@ -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>

+ 1 - 0
src/client/messages/en.json

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

+ 0 - 109
src/client/modules/Apps/pages/AppsPage/AppsPage.test.tsx

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

+ 0 - 42
src/client/modules/Apps/pages/AppsPage/AppsPage.tsx

@@ -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>
-  );
-};

+ 0 - 1
src/client/modules/Apps/pages/AppsPage/index.ts

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

+ 9 - 0
src/middleware.ts

@@ -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;

+ 0 - 14
src/pages/apps/index.tsx

@@ -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: {},
-  });
-};

+ 2 - 2
src/server/common/session.helpers.ts

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