feat: move app details to rsc

This commit is contained in:
Nicolas Meienberger 2023-09-28 10:00:45 +02:00 committed by Nicolas Meienberger
parent c60f77bf02
commit c4fb416903
44 changed files with 462 additions and 766 deletions

View File

@ -1,7 +1,7 @@
import React from 'react';
import { AppInfo } from '@runtipi/shared';
import { AppActions } from './AppActions';
import { cleanup, fireEvent, render, screen, waitFor, userEvent } from '../../../../../../tests/test-utils';
import { cleanup, fireEvent, render, screen, waitFor, userEvent } from '../../../../../../../tests/test-utils';
afterEach(cleanup);

View File

@ -5,8 +5,8 @@ import type { AppStatus } from '@/server/db/schema';
import { useTranslations } from 'next-intl';
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger } from '@/components/ui/DropdownMenu';
import { Button } from '../../../../components/ui/Button';
import { AppWithInfo } from '../../../../core/types';
import { AppWithInfo } from '@/client/core/types';
import { Button } from '@/components/ui/Button';
interface IProps {
app: AppWithInfo;
@ -52,6 +52,8 @@ export const AppActions: React.FC<IProps> = ({ app, status, localDomain, onInsta
const t = useTranslations('apps.app-details');
const hasSettings = Object.keys(info.form_fields).length > 0 || info.exposable;
const hostname = typeof window !== 'undefined' ? window.location.hostname : '';
const buttons: JSX.Element[] = [];
const StartButton = <ActionButton key="start" IconComponent={IconPlayerPlay} onClick={onStart} title={t('actions.start')} color="success" />;
@ -87,7 +89,7 @@ export const AppActions: React.FC<IProps> = ({ app, status, localDomain, onInsta
{!app.info.force_expose && (
<DropdownMenuItem onClick={() => onOpen('local')}>
<IconLockOff className="text-muted me-2" size={16} />
{window.location.hostname}:{app.info.port}
{hostname}:{app.info.port}
</DropdownMenuItem>
)}
</DropdownMenuGroup>

View File

@ -0,0 +1,219 @@
'use client';
import React from 'react';
import { toast } from 'react-hot-toast';
import { useTranslations } from 'next-intl';
import { AppRouterOutput } from '@/server/routers/app/app.router';
import { useDisclosure } from '@/client/hooks/useDisclosure';
import { useAction } from 'next-safe-action/hook';
import { installAppAction } from '@/actions/app-actions/install-app-action';
import { uninstallAppAction } from '@/actions/app-actions/uninstall-app-action';
import { stopAppAction } from '@/actions/app-actions/stop-app-action';
import { startAppAction } from '@/actions/app-actions/start-app-action';
import { updateAppAction } from '@/actions/app-actions/update-app-action';
import { updateAppConfigAction } from '@/actions/app-actions/update-app-config-action';
import { AppLogo } from '@/components/AppLogo';
import { AppStatus } from '@/components/AppStatus';
import { AppStatus as AppStatusEnum } from '@/server/db/schema';
import { castAppConfig } from '@/lib/helpers/castAppConfig';
import { InstallModal } from '../InstallModal';
import { StopModal } from '../StopModal';
import { UninstallModal } from '../UninstallModal';
import { UpdateModal } from '../UpdateModal';
import { UpdateSettingsModal } from '../UpdateSettingsModal/UpdateSettingsModal';
import { AppActions } from '../AppActions';
import { AppDetailsTabs } from '../AppDetailsTabs';
import { FormValues } from '../InstallForm';
interface IProps {
app: AppRouterOutput['getApp'];
localDomain?: string;
}
type OpenType = 'local' | 'domain' | 'local_domain';
export const AppDetailsContainer: React.FC<IProps> = ({ app, localDomain }) => {
const [customStatus, setCustomStatus] = React.useState<AppStatusEnum>(app.status);
const t = useTranslations();
const installDisclosure = useDisclosure();
const uninstallDisclosure = useDisclosure();
const stopDisclosure = useDisclosure();
const updateDisclosure = useDisclosure();
const updateSettingsDisclosure = useDisclosure();
const installMutation = useAction(installAppAction, {
onSuccess: (data) => {
if (!data.success) {
setCustomStatus(app.status);
toast.error(data.failure.reason);
} else {
setCustomStatus('running');
toast.success(t('apps.app-details.install-success'));
}
},
});
const uninstallMutation = useAction(uninstallAppAction, {
onSuccess: (data) => {
if (!data.success) {
setCustomStatus(app.status);
toast.error(data.failure.reason);
} else {
setCustomStatus('missing');
toast.success(t('apps.app-details.uninstall-success'));
}
},
});
const stopMutation = useAction(stopAppAction, {
onSuccess: (data) => {
if (!data.success) {
setCustomStatus(app.status);
toast.error(data.failure.reason);
} else {
setCustomStatus('stopped');
toast.success(t('apps.app-details.stop-success'));
}
},
});
const startMutation = useAction(startAppAction, {
onSuccess: (data) => {
if (!data.success) {
setCustomStatus(app.status);
toast.error(data.failure.reason);
} else {
setCustomStatus('running');
toast.success(t('apps.app-details.start-success'));
}
},
});
const updateMutation = useAction(updateAppAction, {
onSuccess: (data) => {
if (!data.success) {
setCustomStatus(app.status);
toast.error(data.failure.reason);
} else {
setCustomStatus('stopped');
toast.success(t('apps.app-details.update-success'));
}
},
});
const updateConfigMutation = useAction(updateAppConfigAction, {
onSuccess: (data) => {
if (!data.success) {
toast.error(data.failure.reason);
} else {
toast.success(t('apps.app-details.update-config-success'));
}
},
});
const updateAvailable = Number(app.version || 0) < Number(app?.latestVersion || 0);
const handleInstallSubmit = async (values: FormValues) => {
setCustomStatus('installing');
installDisclosure.close();
const { exposed, domain } = values;
installMutation.execute({ id: app.id, form: values, exposed, domain });
};
const handleUnistallSubmit = () => {
setCustomStatus('uninstalling');
uninstallDisclosure.close();
uninstallMutation.execute({ id: app.id });
};
const handleStopSubmit = () => {
setCustomStatus('stopping');
stopDisclosure.close();
stopMutation.execute({ id: app.id });
};
const handleStartSubmit = async () => {
setCustomStatus('starting');
startMutation.execute({ id: app.id });
};
const handleUpdateSettingsSubmit = async (values: FormValues) => {
updateSettingsDisclosure.close();
const { exposed, domain } = values;
updateConfigMutation.execute({ id: app.id, form: values, exposed, domain });
};
const handleUpdateSubmit = async () => {
setCustomStatus('updating');
updateDisclosure.close();
updateMutation.execute({ id: app.id });
};
const handleOpen = (type: OpenType) => {
let url = '';
const { https } = app.info;
const protocol = https ? 'https' : 'http';
if (typeof window !== 'undefined') {
// Current domain
const domain = window.location.hostname;
url = `${protocol}://${domain}:${app.info.port}${app.info.url_suffix || ''}`;
}
if (type === 'domain' && app.domain) {
url = `https://${app.domain}${app.info.url_suffix || ''}`;
}
if (type === 'local_domain') {
url = `https://${app.id}.${localDomain}`;
}
window.open(url, '_blank', 'noreferrer');
};
const newVersion = [app?.latestDockerVersion ? `${app?.latestDockerVersion}` : '', `(${String(app?.latestVersion)})`].join(' ');
return (
<div className="card" data-testid="app-details">
<InstallModal onSubmit={handleInstallSubmit} isOpen={installDisclosure.isOpen} onClose={installDisclosure.close} info={app.info} />
<StopModal onConfirm={handleStopSubmit} isOpen={stopDisclosure.isOpen} onClose={stopDisclosure.close} info={app.info} />
<UninstallModal onConfirm={handleUnistallSubmit} isOpen={uninstallDisclosure.isOpen} onClose={uninstallDisclosure.close} info={app.info} />
<UpdateModal onConfirm={handleUpdateSubmit} isOpen={updateDisclosure.isOpen} onClose={updateDisclosure.close} info={app.info} newVersion={newVersion} />
<UpdateSettingsModal
onSubmit={handleUpdateSettingsSubmit}
isOpen={updateSettingsDisclosure.isOpen}
onClose={updateSettingsDisclosure.close}
info={app.info}
config={castAppConfig(app?.config)}
exposed={app?.exposed}
domain={app?.domain || ''}
/>
<div className="card-header d-flex flex-column flex-md-row">
<AppLogo id={app.id} size={130} alt={app.info.name} />
<div className="w-100 d-flex flex-column ms-md-3 align-items-center align-items-md-start">
<div>
<span className="mt-1 me-1">{t('apps.app-details.version')}: </span>
<span className="badge bg-gray mt-2">{app.info.version}</span>
</div>
<span className="mt-1 text-muted text-center mb-2">{app.info.short_desc}</span>
<div className="mb-1">{customStatus !== 'missing' && <AppStatus status={customStatus} />}</div>
<AppActions
localDomain={localDomain}
updateAvailable={updateAvailable}
onUpdate={updateDisclosure.open}
onUpdateSettings={updateSettingsDisclosure.open}
onStop={stopDisclosure.open}
onCancel={stopDisclosure.open}
onUninstall={uninstallDisclosure.open}
onInstall={installDisclosure.open}
onOpen={handleOpen}
onStart={handleStartSubmit}
app={app}
status={customStatus}
/>
</div>
</div>
<AppDetailsTabs info={app.info} />
</div>
);
};

View File

@ -3,8 +3,8 @@ import React from 'react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useTranslations } from 'next-intl';
import { AppInfo } from '@runtipi/shared';
import { DataGrid, DataGridItem } from '../../../components/ui/DataGrid';
import Markdown from '../../../components/Markdown/Markdown';
import Markdown from '@/components/Markdown/Markdown';
import { DataGrid, DataGridItem } from '@/components/ui/DataGrid';
interface IProps {
info: AppInfo;

View File

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

View File

@ -2,7 +2,7 @@ import React from 'react';
import { faker } from '@faker-js/faker';
import { fromPartial } from '@total-typescript/shoehorn';
import { FormField } from '@runtipi/shared';
import { fireEvent, render, screen, waitFor } from '../../../../../../tests/test-utils';
import { fireEvent, render, screen, waitFor } from '../../../../../../../tests/test-utils';
import { InstallForm } from './InstallForm';
describe('Test: InstallForm', () => {

View File

@ -6,9 +6,9 @@ import { Tooltip } from 'react-tooltip';
import clsx from 'clsx';
import { useTranslations } from 'next-intl';
import { type FormField, type AppInfo } from '@runtipi/shared';
import { Button } from '../../../../components/ui/Button';
import { Switch } from '../../../../components/ui/Switch';
import { Input } from '../../../../components/ui/Input';
import { Switch } from '@/components/ui/Switch';
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
import { validateAppConfig } from '../../utils/validators';
interface IProps {

View File

@ -0,0 +1 @@
export { InstallForm, type FormValues } from './InstallForm';

View File

@ -1,7 +1,7 @@
import React from 'react';
import { AppInfo } from '@runtipi/shared';
import { InstallModal } from './InstallModal';
import { fireEvent, render, screen, waitFor } from '../../../../../../tests/test-utils';
import { fireEvent, render, screen, waitFor } from '../../../../../../../tests/test-utils';
describe('InstallModal', () => {
const app = {

View File

@ -2,8 +2,7 @@ import React from 'react';
import { Dialog, DialogContent, DialogDescription, DialogHeader } from '@/components/ui/Dialog';
import { useTranslations } from 'next-intl';
import { AppInfo } from '@runtipi/shared';
import { InstallForm } from '../InstallForm';
import { FormValues } from '../InstallForm/InstallForm';
import { InstallForm, FormValues } from '../InstallForm';
interface IProps {
info: AppInfo;

View File

@ -2,7 +2,7 @@ import React from 'react';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader } from '@/components/ui/Dialog';
import { useTranslations } from 'next-intl';
import { AppInfo } from '@runtipi/shared';
import { Button } from '../../../components/ui/Button';
import { Button } from '@/components/ui/Button';
interface IProps {
info: AppInfo;

View File

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

View File

@ -3,7 +3,7 @@ import React from 'react';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader } from '@/components/ui/Dialog';
import { useTranslations } from 'next-intl';
import { AppInfo } from '@runtipi/shared';
import { Button } from '../../../components/ui/Button';
import { Button } from '@/components/ui/Button';
interface IProps {
info: AppInfo;

View File

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

View File

@ -1,5 +1,5 @@
import React from 'react';
import { fireEvent, render, screen } from '../../../../../../tests/test-utils';
import { fireEvent, render, screen } from '../../../../../../../tests/test-utils';
import { UpdateModal } from './UpdateModal';
describe('UpdateModal', () => {

View File

@ -2,7 +2,7 @@ import React from 'react';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader } from '@/components/ui/Dialog';
import { useTranslations } from 'next-intl';
import { AppInfo } from '@runtipi/shared';
import { Button } from '../../../../components/ui/Button';
import { Button } from '@/components/ui/Button';
interface IProps {
newVersion: string;

View File

@ -2,8 +2,7 @@ import React from 'react';
import { Dialog, DialogContent, DialogDescription, DialogHeader } from '@/components/ui/Dialog';
import { useTranslations } from 'next-intl';
import { AppInfo } from '@runtipi/shared';
import { InstallForm } from './InstallForm';
import { FormValues } from './InstallForm/InstallForm';
import { InstallForm, type FormValues } from '../InstallForm';
interface IProps {
info: AppInfo;

View File

@ -0,0 +1,23 @@
import { AppServiceClass } from '@/server/services/apps/apps.service';
import React from 'react';
import { Metadata } from 'next';
import { db } from '@/server/db';
import { getTranslatorFromCookie } from '@/lib/get-translator';
import { getSettings } from '@/server/core/TipiConfig';
import { AppDetailsContainer } from './components/AppDetailsContainer/AppDetailsContainer';
export async function generateMetadata(): Promise<Metadata> {
const translator = await getTranslatorFromCookie();
return {
title: `${translator('apps.app-store.title')} - Tipi`,
};
}
export default async function AppDetailsPage({ params }: { params: { id: string } }) {
const appsService = new AppServiceClass(db);
const app = await appsService.getApp(params.id);
const settings = getSettings();
return <AppDetailsContainer app={app} localDomain={settings.localDomain} />;
}

View File

@ -26,7 +26,8 @@ export const PageTitle = () => {
);
};
const title = t(`header.${pathArray[pathArray.length - 1]}` as MessageKey);
const appTitle = apps.find((app) => app.id === pathArray[1])?.name;
const title = appTitle ?? t(`header.${pathArray[pathArray.length - 1]}` as MessageKey);
return (
<>

View File

@ -0,0 +1,36 @@
'use server';
import { z } from 'zod';
import { db } from '@/server/db';
import { action } from '@/lib/safe-action';
import { revalidatePath } from 'next/cache';
import { AppServiceClass } from '@/server/services/apps/apps.service';
import { handleActionError } from '../utils/handle-action-error';
const formSchema = z.object({}).catchall(z.any());
const input = z.object({
id: z.string(),
form: formSchema,
exposed: z.boolean().optional(),
domain: z.string().optional(),
});
/**
* Given an app id, installs the app.
*/
export const installAppAction = action(input, async ({ id, form, domain, exposed }) => {
try {
const appsService = new AppServiceClass(db);
await appsService.installApp(id, form, exposed, domain);
revalidatePath('/apps');
revalidatePath(`/app/${id}`);
revalidatePath(`/app-store/${id}`);
return { success: true };
} catch (e) {
return handleActionError(e);
}
});

View File

@ -0,0 +1,29 @@
'use server';
import { z } from 'zod';
import { db } from '@/server/db';
import { action } from '@/lib/safe-action';
import { revalidatePath } from 'next/cache';
import { AppServiceClass } from '@/server/services/apps/apps.service';
import { handleActionError } from '../utils/handle-action-error';
const input = z.object({ id: z.string() });
/**
* Given an app id, starts the app.
*/
export const startAppAction = action(input, async ({ id }) => {
try {
const appsService = new AppServiceClass(db);
await appsService.startApp(id);
revalidatePath('/apps');
revalidatePath(`/app/${id}`);
revalidatePath(`/app-store/${id}`);
return { success: true };
} catch (e) {
return handleActionError(e);
}
});

View File

@ -0,0 +1,29 @@
'use server';
import { z } from 'zod';
import { db } from '@/server/db';
import { action } from '@/lib/safe-action';
import { revalidatePath } from 'next/cache';
import { AppServiceClass } from '@/server/services/apps/apps.service';
import { handleActionError } from '../utils/handle-action-error';
const input = z.object({ id: z.string() });
/**
* Given an app id, stops the app.
*/
export const stopAppAction = action(input, async ({ id }) => {
try {
const appsService = new AppServiceClass(db);
await appsService.stopApp(id);
revalidatePath('/apps');
revalidatePath(`/app/${id}`);
revalidatePath(`/app-store/${id}`);
return { success: true };
} catch (e) {
return handleActionError(e);
}
});

View File

@ -0,0 +1,29 @@
'use server';
import { z } from 'zod';
import { db } from '@/server/db';
import { action } from '@/lib/safe-action';
import { revalidatePath } from 'next/cache';
import { AppServiceClass } from '@/server/services/apps/apps.service';
import { handleActionError } from '../utils/handle-action-error';
const input = z.object({ id: z.string() });
/**
* Given an app id, uninstalls the app.
*/
export const uninstallAppAction = action(input, async ({ id }) => {
try {
const appsService = new AppServiceClass(db);
await appsService.uninstallApp(id);
revalidatePath('/apps');
revalidatePath(`/app/${id}`);
revalidatePath(`/app-store/${id}`);
return { success: true };
} catch (e) {
return handleActionError(e);
}
});

View File

@ -0,0 +1,29 @@
'use server';
import { z } from 'zod';
import { db } from '@/server/db';
import { action } from '@/lib/safe-action';
import { revalidatePath } from 'next/cache';
import { AppServiceClass } from '@/server/services/apps/apps.service';
import { handleActionError } from '../utils/handle-action-error';
const input = z.object({ id: z.string() });
/**
* Given an app id, updates the app to the latest version
*/
export const updateAppAction = action(input, async ({ id }) => {
try {
const appsService = new AppServiceClass(db);
await appsService.updateApp(id);
revalidatePath('/apps');
revalidatePath(`/app/${id}`);
revalidatePath(`/app-store/${id}`);
return { success: true };
} catch (e) {
return handleActionError(e);
}
});

View File

@ -0,0 +1,36 @@
'use server';
import { z } from 'zod';
import { db } from '@/server/db';
import { action } from '@/lib/safe-action';
import { revalidatePath } from 'next/cache';
import { AppServiceClass } from '@/server/services/apps/apps.service';
import { handleActionError } from '../utils/handle-action-error';
const formSchema = z.object({}).catchall(z.any());
const input = z.object({
id: z.string(),
form: formSchema,
exposed: z.boolean().optional(),
domain: z.string().optional(),
});
/**
* Given an app id and form, updates the app config
*/
export const updateAppConfigAction = action(input, async ({ id, form, domain, exposed }) => {
try {
const appsService = new AppServiceClass(db);
await appsService.updateAppConfig(id, form, exposed, domain);
revalidatePath('/apps');
revalidatePath(`/app/${id}`);
revalidatePath(`/app-store/${id}`);
return { success: true };
} catch (e) {
return handleActionError(e);
}
});

View File

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

View File

@ -1,410 +0,0 @@
import React from 'react';
import { faker } from '@faker-js/faker';
import { fireEvent, render, screen, userEvent, waitFor } from '../../../../../../tests/test-utils';
import { createAppEntity } from '../../../../mocks/fixtures/app.fixtures';
import { getTRPCMock, getTRPCMockError } from '../../../../mocks/getTrpcMock';
import { server } from '../../../../mocks/server';
import { AppDetailsContainer } from './AppDetailsContainer';
describe('Test: AppDetailsContainer', () => {
describe('Test: UI', () => {
it('should render', async () => {
// Arrange
const app = createAppEntity({});
render(<AppDetailsContainer app={app} />);
// Assert
expect(screen.getByText(app.info.short_desc)).toBeInTheDocument();
});
it('should display update button when update is available', async () => {
// Arrange
const app = createAppEntity({ overrides: { version: 2, latestVersion: 3 } });
render(<AppDetailsContainer app={app} />);
// Assert
expect(screen.getByRole('button', { name: 'Update' })).toBeInTheDocument();
});
it('should display install button when app is not installed', async () => {
// Arrange
const app = createAppEntity({ overrides: { status: 'missing' } });
render(<AppDetailsContainer app={app} />);
// Assert
expect(screen.getByRole('button', { name: 'Install' })).toBeInTheDocument();
});
it('should display uninstall and start button when app is stopped', async () => {
// Arrange
const app = createAppEntity({ overrides: { status: 'stopped' } });
render(<AppDetailsContainer app={app} />);
// Assert
expect(screen.getByRole('button', { name: 'Remove' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Start' })).toBeInTheDocument();
});
it('should display stop, open and settings buttons when app is running', async () => {
// Arrange
const app = createAppEntity({ overrides: { status: 'running' } });
render(<AppDetailsContainer app={app} />);
// Assert
expect(screen.getByRole('button', { name: 'Stop' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Open' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Settings' })).toBeInTheDocument();
});
it('should not display update button when update is not available', async () => {
// Arrange
const app = createAppEntity({ overrides: { version: 3 }, overridesInfo: { tipi_version: 3 } });
render(<AppDetailsContainer app={app} />);
// Assert
expect(screen.queryByRole('button', { name: 'Update' })).not.toBeInTheDocument();
});
it('should not display open button when app has no_gui set to true', async () => {
// Arrange
const app = createAppEntity({ overridesInfo: { no_gui: true } });
render(<AppDetailsContainer app={app} />);
// Assert
expect(screen.queryByRole('button', { name: 'Open' })).not.toBeInTheDocument();
});
});
describe('Test: Open app', () => {
it('should call window.open with the correct url when open button is clicked', async () => {
// Arrange
const app = createAppEntity({});
const spy = jest.spyOn(window, 'open').mockImplementation(() => null);
render(<AppDetailsContainer app={app} />);
// Act
const openButton = screen.getByRole('button', { name: 'Open' });
await userEvent.type(openButton, '{arrowdown}');
await waitFor(() => {
expect(screen.getByText(/localhost:/)).toBeInTheDocument();
});
const openButtonItem = screen.getByText(/localhost:/);
await userEvent.click(openButtonItem);
// Assert
await waitFor(() => {
expect(spy).toHaveBeenCalledWith(`http://localhost:${app.info.port}`, '_blank', 'noreferrer');
});
spy.mockRestore();
});
it('should open with https when app info has https set to true', async () => {
// Arrange
const app = createAppEntity({ overridesInfo: { https: true } });
const spy = jest.spyOn(window, 'open').mockImplementation(() => null);
render(<AppDetailsContainer app={app} />);
// Act
const openButton = screen.getByRole('button', { name: 'Open' });
await userEvent.type(openButton, '{arrowdown}');
await waitFor(() => {
expect(screen.getByText(/localhost:/)).toBeInTheDocument();
});
const openButtonItem = screen.getByText(/localhost:/);
await userEvent.click(openButtonItem);
// Assert
await waitFor(() => {
expect(spy).toHaveBeenCalledWith(`https://localhost:${app.info.port}`, '_blank', 'noreferrer');
});
spy.mockRestore();
});
it('should open with domain when domain is clicked', async () => {
// Arrange
const app = createAppEntity({ overrides: { domain: 'test.com', exposed: true } });
const spy = jest.spyOn(window, 'open').mockImplementation(() => null);
render(<AppDetailsContainer app={app} />);
// Act
const openButton = screen.getByRole('button', { name: 'Open' });
await userEvent.type(openButton, '{arrowdown}');
await waitFor(() => {
expect(screen.getByText(/test.com/)).toBeInTheDocument();
});
const openButtonItem = screen.getByText(/test.com/);
await userEvent.click(openButtonItem);
// Assert
await waitFor(() => {
expect(spy).toHaveBeenCalledWith(`https://test.com`, '_blank', 'noreferrer');
});
spy.mockRestore();
});
it('should open with local domain when local domain is clicked', async () => {
// Arrange
const app = createAppEntity({});
const spy = jest.spyOn(window, 'open').mockImplementation(() => null);
render(<AppDetailsContainer app={app} />);
// Act
const openButton = screen.getByRole('button', { name: 'Open' });
await userEvent.type(openButton, '{arrowdown}');
await waitFor(() => {
expect(screen.getByText(/.tipi.lan/)).toBeInTheDocument();
});
const openButtonItem = screen.getByText(/.tipi.lan/);
await userEvent.click(openButtonItem);
// Assert
await waitFor(() => {
expect(spy).toHaveBeenCalledWith(`https://${app.id}.tipi.lan`, '_blank', 'noreferrer');
});
spy.mockRestore();
});
});
describe('Test: Install app', () => {
it('should display toast success when install success', async () => {
// Arrange
const app = createAppEntity({ overrides: { status: 'missing' } });
server.use(getTRPCMock({ path: ['app', 'installApp'], type: 'mutation', response: app }));
render(<AppDetailsContainer app={app} />);
const openModalButton = screen.getByRole('button', { name: 'Install' });
fireEvent.click(openModalButton);
// Act
const installButton = screen.getByRole('button', { name: 'Install' });
fireEvent.click(installButton);
await waitFor(() => {
expect(screen.getByText('App installed successfully')).toBeInTheDocument();
});
});
it('should display a toast error when install mutation fails', async () => {
// Arrange
const error = faker.lorem.sentence();
server.use(
getTRPCMockError({
path: ['app', 'installApp'],
type: 'mutation',
message: error,
}),
);
const app = createAppEntity({ overrides: { status: 'missing' } });
render(<AppDetailsContainer app={app} />);
const openModalButton = screen.getByRole('button', { name: 'Install' });
fireEvent.click(openModalButton);
// Act
const installButton = screen.getByRole('button', { name: 'Install' });
fireEvent.click(installButton);
await waitFor(() => {
expect(screen.getByText(error)).toBeInTheDocument();
});
});
});
describe('Test: Update app', () => {
it('should display toast success when update success', async () => {
// Arrange
const app = createAppEntity({ overrides: { version: 2, latestVersion: 3 } });
server.use(getTRPCMock({ path: ['app', 'updateApp'], type: 'mutation', response: app }));
render(<AppDetailsContainer app={app} />);
const openModalButton = screen.getByRole('button', { name: 'Update' });
fireEvent.click(openModalButton);
// Act
const modalUpdateButton = screen.getByRole('button', { name: 'Update' });
modalUpdateButton.click();
await waitFor(() => {
expect(screen.getByText('App updated successfully')).toBeInTheDocument();
});
});
it('should display a toast error when update mutation fails', async () => {
// Arrange
const error = faker.lorem.sentence();
server.use(getTRPCMockError({ path: ['app', 'updateApp'], type: 'mutation', message: error }));
const app = createAppEntity({ overrides: { version: 2, latestVersion: 3 }, overridesInfo: { tipi_version: 3 } });
render(<AppDetailsContainer app={app} />);
const openModalButton = screen.getByRole('button', { name: 'Update' });
fireEvent.click(openModalButton);
// Act
const modalUpdateButton = screen.getByRole('button', { name: 'Update' });
modalUpdateButton.click();
// Assert
await waitFor(() => {
expect(screen.getByText(error)).toBeInTheDocument();
});
});
});
describe('Test: Uninstall app', () => {
it('should display toast success when uninstall success', async () => {
// Arrange
const app = createAppEntity({ status: 'stopped' });
server.use(getTRPCMock({ path: ['app', 'uninstallApp'], type: 'mutation', response: { id: app.id, config: {}, status: 'missing' } }));
render(<AppDetailsContainer app={app} />);
const openModalButton = screen.getByRole('button', { name: 'Remove' });
fireEvent.click(openModalButton);
// Act
const modalUninstallButton = screen.getByRole('button', { name: 'Uninstall' });
modalUninstallButton.click();
// Assert
await waitFor(() => {
expect(screen.getByText('App uninstalled successfully')).toBeInTheDocument();
});
});
it('should display a toast error when uninstall mutation fails', async () => {
// Arrange
const error = faker.lorem.sentence();
server.use(getTRPCMockError({ path: ['app', 'uninstallApp'], type: 'mutation', message: error }));
const app = createAppEntity({ status: 'stopped' });
render(<AppDetailsContainer app={app} />);
const openModalButton = screen.getByRole('button', { name: 'Remove' });
fireEvent.click(openModalButton);
// Act
const modalUninstallButton = screen.getByRole('button', { name: 'Uninstall' });
modalUninstallButton.click();
// Assert
await waitFor(() => {
expect(screen.getByText(error)).toBeInTheDocument();
});
});
});
describe('Test: Start app', () => {
it('should display toast success when start success', async () => {
// Arrange
const app = createAppEntity({ status: 'stopped' });
server.use(getTRPCMock({ path: ['app', 'startApp'], type: 'mutation', response: app }));
render(<AppDetailsContainer app={app} />);
// Act
const startButton = screen.getByRole('button', { name: 'Start' });
startButton.click();
// Assert
await waitFor(() => {
expect(screen.getByText('App started successfully')).toBeInTheDocument();
});
});
it('should display a toast error when start mutation fails', async () => {
// Arrange
const error = faker.lorem.sentence();
server.use(getTRPCMockError({ path: ['app', 'startApp'], type: 'mutation', message: error }));
const app = createAppEntity({ status: 'stopped' });
render(<AppDetailsContainer app={app} />);
// Act
const startButton = screen.getByRole('button', { name: 'Start' });
startButton.click();
// Assert
await waitFor(() => {
expect(screen.getByText(error)).toBeInTheDocument();
});
});
});
describe('Test: Stop app', () => {
it('should display toast success when stop success', async () => {
// Arrange
const app = createAppEntity({ status: 'running' });
server.use(getTRPCMock({ path: ['app', 'stopApp'], type: 'mutation', response: app }));
render(<AppDetailsContainer app={app} />);
const openModalButton = screen.getByRole('button', { name: 'Stop' });
fireEvent.click(openModalButton);
// Act
const modalStopButton = screen.getByRole('button', { name: 'Stop' });
modalStopButton.click();
// Assert
await waitFor(() => {
expect(screen.getByText('App stopped successfully')).toBeInTheDocument();
});
});
it('should display a toast error when stop mutation fails', async () => {
// Arrange
const error = faker.lorem.sentence();
server.use(getTRPCMockError({ path: ['app', 'stopApp'], type: 'mutation', message: error }));
const app = createAppEntity({ status: 'running' });
render(<AppDetailsContainer app={app} />);
const openModalButton = screen.getByRole('button', { name: 'Stop' });
fireEvent.click(openModalButton);
// Act
const modalStopButton = screen.getByRole('button', { name: 'Stop' });
modalStopButton.click();
// Assert
await waitFor(() => {
expect(screen.getByText(error)).toBeInTheDocument();
});
});
});
describe('Test: Update app config', () => {
it('should display toast success when update config success', async () => {
// Arrange
const app = createAppEntity({ status: 'running', overridesInfo: { exposable: true } });
server.use(getTRPCMock({ path: ['app', 'updateAppConfig'], type: 'mutation', response: app }));
render(<AppDetailsContainer app={app} />);
const openModalButton = screen.getByRole('button', { name: 'Settings' });
fireEvent.click(openModalButton);
// Act
const configButton = screen.getByRole('button', { name: 'Update' });
configButton.click();
// Assert
await waitFor(() => {
expect(screen.getByText('App config updated successfully. Restart the app to apply the changes')).toBeInTheDocument();
});
});
it('should display a toast error when update config mutation fails', async () => {
// Arrange
const error = faker.lorem.sentence();
server.use(getTRPCMockError({ path: ['app', 'updateAppConfig'], type: 'mutation', message: error }));
const app = createAppEntity({ status: 'running', overridesInfo: { exposable: true } });
render(<AppDetailsContainer app={app} />);
const openModalButton = screen.getByRole('button', { name: 'Settings' });
fireEvent.click(openModalButton);
// Act
const configButton = screen.getByRole('button', { name: 'Update' });
configButton.click();
// Assert
await waitFor(() => {
expect(screen.getByText(error)).toBeInTheDocument();
});
});
});
});

View File

@ -1,208 +0,0 @@
import React from 'react';
import { toast } from 'react-hot-toast';
import { useTranslations } from 'next-intl';
import type { MessageKey } from '@/server/utils/errors';
import { useDisclosure } from '../../../../hooks/useDisclosure';
import { AppLogo } from '../../../../components/AppLogo/AppLogo';
import { AppStatus } from '../../../../components/AppStatus';
import { AppActions } from '../../components/AppActions';
import { AppDetailsTabs } from '../../components/AppDetailsTabs';
import { InstallModal } from '../../components/InstallModal';
import { StopModal } from '../../components/StopModal';
import { UninstallModal } from '../../components/UninstallModal';
import { UpdateModal } from '../../components/UpdateModal';
import { UpdateSettingsModal } from '../../components/UpdateSettingsModal';
import { FormValues } from '../../components/InstallForm/InstallForm';
import { trpc } from '../../../../utils/trpc';
import { AppRouterOutput } from '../../../../../server/routers/app/app.router';
import { castAppConfig } from '../../helpers/castAppConfig';
interface IProps {
app: AppRouterOutput['getApp'];
}
type OpenType = 'local' | 'domain' | 'local_domain';
export const AppDetailsContainer: React.FC<IProps> = ({ app }) => {
const t = useTranslations();
const installDisclosure = useDisclosure();
const uninstallDisclosure = useDisclosure();
const stopDisclosure = useDisclosure();
const updateDisclosure = useDisclosure();
const updateSettingsDisclosure = useDisclosure();
const getSettings = trpc.system.getSettings.useQuery();
const utils = trpc.useContext();
const invalidate = () => {
utils.app.installedApps.invalidate();
utils.app.getApp.invalidate({ id: app.id });
};
const install = trpc.app.installApp.useMutation({
onMutate: () => {
utils.app.getApp.setData({ id: app.id }, { ...app, status: 'installing' });
installDisclosure.close();
},
onSuccess: () => {
invalidate();
toast.success(t('apps.app-details.install-success'));
},
onError: (e) => {
invalidate();
toast.error(t(e.data?.tError.message as MessageKey, { ...e.data?.tError?.variables }));
},
});
const uninstall = trpc.app.uninstallApp.useMutation({
onMutate: () => {
utils.app.getApp.setData({ id: app.id }, { ...app, status: 'uninstalling' });
uninstallDisclosure.close();
},
onSuccess: () => {
invalidate();
toast.success(t('apps.app-details.uninstall-success'));
},
onError: (e) => toast.error(t(e.data?.tError.message as MessageKey, { ...e.data?.tError?.variables })),
});
const stop = trpc.app.stopApp.useMutation({
onMutate: () => {
utils.app.getApp.setData({ id: app.id }, { ...app, status: 'stopping' });
stopDisclosure.close();
},
onSuccess: () => {
invalidate();
toast.success(t('apps.app-details.stop-success'));
},
onError: (e) => toast.error(t(e.data?.tError.message as MessageKey, { ...e.data?.tError?.variables })),
});
const update = trpc.app.updateApp.useMutation({
onMutate: () => {
utils.app.getApp.setData({ id: app.id }, { ...app, status: 'updating' });
updateDisclosure.close();
},
onSuccess: () => {
invalidate();
toast.success(t('apps.app-details.update-success'));
},
onError: (e) => toast.error(t(e.data?.tError.message as MessageKey, { ...e.data?.tError?.variables })),
});
const start = trpc.app.startApp.useMutation({
onMutate: () => {
utils.app.getApp.setData({ id: app.id }, { ...app, status: 'starting' });
},
onSuccess: () => {
invalidate();
toast.success(t('apps.app-details.start-success'));
},
onError: (e) => toast.error(t(e.data?.tError.message as MessageKey, { ...e.data?.tError?.variables })),
});
const updateConfig = trpc.app.updateAppConfig.useMutation({
onMutate: () => updateSettingsDisclosure.close(),
onSuccess: () => {
invalidate();
toast.success(t('apps.app-details.update-config-success'));
},
onError: (e) => toast.error(t(e.data?.tError.message as MessageKey, { ...e.data?.tError?.variables })),
});
const updateAvailable = Number(app.version || 0) < Number(app?.latestVersion || 0);
const handleInstallSubmit = async (values: FormValues) => {
const { exposed, domain } = values;
install.mutate({ id: app.id, form: values, exposed, domain });
};
const handleUnistallSubmit = () => {
uninstall.mutate({ id: app.id });
};
const handleStopSubmit = () => {
stop.mutate({ id: app.id });
};
const handleStartSubmit = async () => {
start.mutate({ id: app.id });
};
const handleUpdateSettingsSubmit = async (values: FormValues) => {
const { exposed, domain } = values;
updateConfig.mutate({ id: app.id, form: values, exposed, domain });
};
const handleUpdateSubmit = async () => {
update.mutate({ id: app.id });
};
const handleOpen = (type: OpenType) => {
let url = '';
const { https } = app.info;
const protocol = https ? 'https' : 'http';
if (typeof window !== 'undefined') {
// Current domain
const domain = window.location.hostname;
url = `${protocol}://${domain}:${app.info.port}${app.info.url_suffix || ''}`;
}
if (type === 'domain' && app.domain) {
url = `https://${app.domain}${app.info.url_suffix || ''}`;
}
if (type === 'local_domain') {
url = `https://${app.id}.${getSettings.data?.localDomain}`;
}
window.open(url, '_blank', 'noreferrer');
};
const newVersion = [app?.latestDockerVersion ? `${app?.latestDockerVersion}` : '', `(${String(app?.latestVersion)})`].join(' ');
return (
<div className="card" data-testid="app-details">
<InstallModal onSubmit={handleInstallSubmit} isOpen={installDisclosure.isOpen} onClose={installDisclosure.close} info={app.info} />
<StopModal onConfirm={handleStopSubmit} isOpen={stopDisclosure.isOpen} onClose={stopDisclosure.close} info={app.info} />
<UninstallModal onConfirm={handleUnistallSubmit} isOpen={uninstallDisclosure.isOpen} onClose={uninstallDisclosure.close} info={app.info} />
<UpdateModal onConfirm={handleUpdateSubmit} isOpen={updateDisclosure.isOpen} onClose={updateDisclosure.close} info={app.info} newVersion={newVersion} />
<UpdateSettingsModal
onSubmit={handleUpdateSettingsSubmit}
isOpen={updateSettingsDisclosure.isOpen}
onClose={updateSettingsDisclosure.close}
info={app.info}
config={castAppConfig(app?.config)}
exposed={app?.exposed}
domain={app?.domain || ''}
/>
<div className="card-header d-flex flex-column flex-md-row">
<AppLogo id={app.id} size={130} alt={app.info.name} />
<div className="w-100 d-flex flex-column ms-md-3 align-items-center align-items-md-start">
<div>
<span className="mt-1 me-1">{t('apps.app-details.version')}: </span>
<span className="badge bg-gray mt-2">{app.info.version}</span>
</div>
<span className="mt-1 text-muted text-center mb-2">{app.info.short_desc}</span>
<div className="mb-1">{app.status !== 'missing' && <AppStatus status={app.status} />}</div>
<AppActions
localDomain={getSettings.data?.localDomain}
updateAvailable={updateAvailable}
onUpdate={updateDisclosure.open}
onUpdateSettings={updateSettingsDisclosure.open}
onStop={stopDisclosure.open}
onCancel={stopDisclosure.open}
onUninstall={uninstallDisclosure.open}
onInstall={installDisclosure.open}
onOpen={handleOpen}
onStart={handleStartSubmit}
app={app}
status={app.status}
/>
</div>
</div>
<AppDetailsTabs info={app.info} />
</div>
);
};

View File

@ -1,58 +0,0 @@
import React from 'react';
import { render, screen, waitFor } from '../../../../../../tests/test-utils';
import { AppWithInfo } from '../../../../core/types';
import { createAppEntity } from '../../../../mocks/fixtures/app.fixtures';
import { getTRPCMock } from '../../../../mocks/getTrpcMock';
import { server } from '../../../../mocks/server';
import { AppDetailsPage } from './AppDetailsPage';
describe('AppDetailsPage', () => {
it('should render', async () => {
// Arrange
render(<AppDetailsPage appId="nothing" />);
// Assert
await waitFor(() => {
expect(screen.getByTestId('app-details')).toBeInTheDocument();
});
});
it('should set the breadcrumb prop of the Layout component to an array containing two elements with the correct name and href properties', async () => {
// Arrange
const app = createAppEntity({}) as AppWithInfo;
server.use(
getTRPCMock({
path: ['app', 'getApp'],
response: app,
}),
);
jest.mock('next/router', () => {
const actualRouter = jest.requireActual('next-router-mock');
return {
...actualRouter,
useRouter: () => ({
...actualRouter.useRouter(),
pathname: `/apps/${app.id}`,
}),
};
});
render(<AppDetailsPage appId={app.id} />);
await waitFor(() => {
expect(screen.getByTestId('app-details')).toBeInTheDocument();
});
// Act
const breadcrumbs = await screen.findAllByTestId('breadcrumb-item');
const breadcrumbsLinks = await screen.findAllByTestId('breadcrumb-link');
// Assert
expect(breadcrumbs[0]).toHaveTextContent('Apps');
expect(breadcrumbsLinks[0]).toHaveAttribute('href', '/apps');
expect(breadcrumbs[1]).toHaveTextContent(app.info.name);
expect(breadcrumbsLinks[1]).toHaveAttribute('href', `/apps/${app.id}`);
});
});

View File

@ -1,42 +0,0 @@
import { NextPage } from 'next';
import React from 'react';
import { useRouter } from 'next/router';
import { useTranslations } from 'next-intl';
import type { MessageKey } from '@/server/utils/errors';
import { Layout } from '../../../../components/Layout';
import { ErrorPage } from '../../../../components/ui/ErrorPage';
import { trpc } from '../../../../utils/trpc';
import { AppDetailsContainer } from '../../containers/AppDetailsContainer/AppDetailsContainer';
interface IProps {
appId: string;
}
type Path = { refSlug: string; refTitle: string };
const paths: Record<string, Path> = {
'app-store': { refSlug: 'app-store', refTitle: 'App Store' },
apps: { refSlug: 'apps', refTitle: 'Apps' },
};
export const AppDetailsPage: NextPage<IProps> = ({ appId }) => {
const router = useRouter();
const t = useTranslations();
const basePath = router.pathname.split('/').slice(1)[0];
const { refSlug, refTitle } = paths[basePath || 'apps'] || { refSlug: 'apps', refTitle: 'Apps' };
const { data, error } = trpc.app.getApp.useQuery({ id: appId });
const breadcrumb = [
{ name: refTitle, href: `/${refSlug}` },
{ name: data?.info?.name || '', href: `/${refSlug}/${data?.id}`, current: true },
];
// TODO: add loading state
return (
<Layout title={data?.info.name || ''} breadcrumbs={breadcrumb}>
{data?.info && <AppDetailsContainer app={data} />}
{error && <ErrorPage error={t(error.data?.tError.message as MessageKey, { ...error.data?.tError.variables })} />}
</Layout>
);
};

View File

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

View File

@ -28,7 +28,7 @@ describe('<OtpForm />', () => {
it('should prompt for password when disabling 2FA', async () => {
// arrange
server.use(getTRPCMock({ path: ['auth', 'me'], response: { totpEnabled: true, id: 12, username: 'test', locale: 'en' } }));
server.use(getTRPCMock({ path: ['auth', 'me'], response: { totpEnabled: true, id: 12, username: 'test', locale: 'en', operator: true } }));
render(<OtpForm />);
const twoFactorAuthButton = screen.getByRole('switch', { name: /Enable two-factor authentication/i });
await waitFor(() => {
@ -46,7 +46,7 @@ describe('<OtpForm />', () => {
it('should show show error toast if password is incorrect while enabling 2FA', async () => {
// arrange
server.use(getTRPCMock({ path: ['auth', 'me'], response: { totpEnabled: false, id: 12, username: 'test', locale: 'en' } }));
server.use(getTRPCMock({ path: ['auth', 'me'], response: { totpEnabled: false, id: 12, username: 'test', locale: 'en', operator: true } }));
server.use(getTRPCMockError({ path: ['auth', 'getTotpUri'], type: 'mutation', message: 'Invalid password' }));
render(<OtpForm />);
const twoFactorAuthButton = screen.getByRole('switch', { name: /Enable two-factor authentication/i });
@ -74,7 +74,7 @@ describe('<OtpForm />', () => {
it('should show show error toast if password is incorrect while disabling 2FA', async () => {
// arrange
server.use(getTRPCMock({ path: ['auth', 'me'], response: { locale: 'en', totpEnabled: true, id: 12, username: 'test' } }));
server.use(getTRPCMock({ path: ['auth', 'me'], response: { locale: 'en', totpEnabled: true, id: 12, username: 'test', operator: true } }));
server.use(getTRPCMockError({ path: ['auth', 'disableTotp'], type: 'mutation', message: 'Invalid password' }));
render(<OtpForm />);
@ -103,7 +103,7 @@ describe('<OtpForm />', () => {
it('should show success toast if password is correct while disabling 2FA', async () => {
// arrange
server.use(getTRPCMock({ path: ['auth', 'me'], response: { locale: 'en', totpEnabled: true, id: 12, username: 'test' } }));
server.use(getTRPCMock({ path: ['auth', 'me'], response: { locale: 'en', totpEnabled: true, id: 12, username: 'test', operator: true } }));
server.use(getTRPCMock({ path: ['auth', 'disableTotp'], type: 'mutation', response: true }));
render(<OtpForm />);
@ -262,7 +262,7 @@ describe('<OtpForm />', () => {
it('can close the disable modal by clicking on the esc key', async () => {
// arrange
server.use(getTRPCMock({ path: ['auth', 'me'], response: { locale: 'en', totpEnabled: true, username: '', id: 1 } }));
server.use(getTRPCMock({ path: ['auth', 'me'], response: { locale: 'en', totpEnabled: true, username: '', id: 1, operator: true } }));
render(<OtpForm />);
const twoFactorAuthButton = screen.getByRole('switch', { name: /Enable two-factor authentication/i });
await waitFor(() => {

View File

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

View File

@ -2,8 +2,8 @@ import fs from 'fs-extra';
import waitForExpect from 'wait-for-expect';
import { TestDatabase, clearDatabase, closeDatabase, createDatabase } from '@/server/tests/test-utils';
import { faker } from '@faker-js/faker';
import { castAppConfig } from '@/client/modules/Apps/helpers/castAppConfig';
import { waitUntilFinishedMock } from '@/tests/server/jest.setup';
import { castAppConfig } from '@/lib/helpers/castAppConfig';
import { AppServiceClass } from './apps.service';
import { EventDispatcher } from '../../core/EventDispatcher';
import { getAllApps, getAppById, updateApp, createAppConfig, insertApp } from '../../tests/apps.factory';

View File

@ -3,9 +3,9 @@ import { App } from '@/server/db/schema';
import { AppQueries } from '@/server/queries/apps/apps.queries';
import { TranslatedError } from '@/server/utils/errors';
import { Database } from '@/server/db';
import { castAppConfig } from '@/client/modules/Apps/helpers/castAppConfig';
import { AppInfo } from '@runtipi/shared';
import { EventDispatcher } from '@/server/core/EventDispatcher/EventDispatcher';
import { castAppConfig } from '@/lib/helpers/castAppConfig';
import { checkAppRequirements, getAvailableApps, getAppInfo, getUpdateInfo } from './apps.helpers';
import { getConfig } from '../../core/TipiConfig';
import { Logger } from '../../core/Logger';