feat: move my apps page to RSC
This commit is contained in:
parent
da31470fd7
commit
a8933e592e
18 changed files with 122 additions and 188 deletions
|
@ -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:
|
||||
|
|
18
pnpm-lock.yaml
generated
18
pnpm-lock.yaml
generated
|
@ -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:
|
||||
|
|
|
@ -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'>;
|
38
src/app/(dashboard)/apps/page.tsx
Normal file
38
src/app/(dashboard)/apps/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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
src/app/components/EmptyPage/EmptyPage.module.scss
Normal file
4
src/app/components/EmptyPage/EmptyPage.module.scss
Normal file
|
@ -0,0 +1,4 @@
|
|||
.emptyImage {
|
||||
height: 80px;
|
||||
width: 80px;
|
||||
}
|
47
src/app/components/EmptyPage/EmptyPage.tsx
Normal file
47
src/app/components/EmptyPage/EmptyPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
1
src/app/components/EmptyPage/index.ts
Normal file
1
src/app/components/EmptyPage/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { EmptyPage } from './EmptyPage';
|
|
@ -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>
|
||||
|
|
|
@ -286,6 +286,7 @@
|
|||
},
|
||||
"header": {
|
||||
"dashboard": "Dashboard",
|
||||
"apps": "My Apps",
|
||||
"my-apps": "My Apps",
|
||||
"app-store": "App Store",
|
||||
"settings": "Settings",
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -1 +0,0 @@
|
|||
export { AppsPage } from './AppsPage';
|
|
@ -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;
|
||||
|
|
|
@ -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: {},
|
||||
});
|
||||
};
|
|
@ -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();
|
||||
};
|
||||
|
|
Loading…
Add table
Reference in a new issue