feat: move app details to rsc
This commit is contained in:
parent
c60f77bf02
commit
3bf7f65b3d
44 changed files with 462 additions and 766 deletions
|
@ -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);
|
||||
|
|
@ -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>
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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;
|
|
@ -0,0 +1 @@
|
|||
export { AppDetailsTabs } from './AppDetailsTabs';
|
|
@ -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', () => {
|
|
@ -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 {
|
|
@ -0,0 +1 @@
|
|||
export { InstallForm, type FormValues } from './InstallForm';
|
|
@ -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 = {
|
|
@ -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;
|
|
@ -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;
|
|
@ -0,0 +1 @@
|
|||
export { StopModal } from './StopModal';
|
|
@ -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;
|
|
@ -0,0 +1 @@
|
|||
export { UninstallModal } from './UninstallModal';
|
|
@ -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', () => {
|
|
@ -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;
|
|
@ -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;
|
23
src/app/(dashboard)/app-store/[id]/page.tsx
Normal file
23
src/app/(dashboard)/app-store/[id]/page.tsx
Normal 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} />;
|
||||
}
|
|
@ -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 (
|
||||
<>
|
||||
|
|
36
src/app/actions/app-actions/install-app-action.ts
Normal file
36
src/app/actions/app-actions/install-app-action.ts
Normal 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);
|
||||
}
|
||||
});
|
29
src/app/actions/app-actions/start-app-action.ts
Normal file
29
src/app/actions/app-actions/start-app-action.ts
Normal 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);
|
||||
}
|
||||
});
|
29
src/app/actions/app-actions/stop-app-action.ts
Normal file
29
src/app/actions/app-actions/stop-app-action.ts
Normal 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);
|
||||
}
|
||||
});
|
29
src/app/actions/app-actions/uninstall-app-action.ts
Normal file
29
src/app/actions/app-actions/uninstall-app-action.ts
Normal 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);
|
||||
}
|
||||
});
|
29
src/app/actions/app-actions/update-app-action.ts
Normal file
29
src/app/actions/app-actions/update-app-action.ts
Normal 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);
|
||||
}
|
||||
});
|
36
src/app/actions/app-actions/update-app-config-action.ts
Normal file
36
src/app/actions/app-actions/update-app-config-action.ts
Normal 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);
|
||||
}
|
||||
});
|
|
@ -1 +0,0 @@
|
|||
export { InstallForm } from './InstallForm';
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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}`);
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -1 +0,0 @@
|
|||
export { AppDetailsPage } from './AppDetailsPage';
|
|
@ -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(() => {
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
};
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
|
Loading…
Add table
Reference in a new issue