feat: add translations strings for my-apps page

This commit is contained in:
Nicolas Meienberger 2023-05-09 22:34:41 +02:00 committed by Nicolas Meienberger
parent 3b239e9b00
commit 91e48f8e01
17 changed files with 314 additions and 167 deletions

View file

@ -2,10 +2,12 @@ import clsx from 'clsx';
import React from 'react';
import { Tooltip } from 'react-tooltip';
import type { AppStatus as AppStatusEnum } from '@/server/db/schema';
import { useTranslations } from 'next-intl';
import styles from './AppStatus.module.scss';
export const AppStatus: React.FC<{ status: AppStatusEnum; lite?: boolean }> = ({ status, lite }) => {
const formattedStatus = `${status[0]?.toUpperCase()}${status.substring(1, status.length).toLowerCase()}`;
const t = useTranslations('apps');
const formattedStatus = t(`status-${status}`);
const classes = clsx('status-dot status-gray', {
'status-dot-animated status-green': status === 'running',

View file

@ -3,6 +3,7 @@ import React from 'react';
import { IconDownload } from '@tabler/icons-react';
import { Tooltip } from 'react-tooltip';
import type { AppStatus as AppStatusEnum } from '@/server/db/schema';
import { useTranslations } from 'next-intl';
import { AppStatus } from '../AppStatus';
import { AppLogo } from '../AppLogo/AppLogo';
import { limitText } from '../../modules/AppStore/helpers/table.helpers';
@ -11,35 +12,39 @@ import { AppInfo } from '../../core/types';
type AppTileInfo = Pick<AppInfo, 'id' | 'name' | 'description' | 'short_desc'>;
export const AppTile: React.FC<{ app: AppTileInfo; status: AppStatusEnum; updateAvailable: boolean }> = ({ app, status, updateAvailable }) => (
<div data-testid={`app-tile-${app.id}`} className="col-sm-6 col-lg-4">
<div className="card card-sm card-link">
<Link href={`/apps/${app.id}`} className="nav-link" passHref>
<div className="card-body">
<div className="d-flex align-items-center">
<span className="me-3">
<AppLogo alt={`${app.name} logo`} className="mr-3 group-hover:scale-105 transition-all" id={app.id} size={60} />
</span>
<div>
<div className="d-flex h-3 align-items-center">
<span className="h4 me-2 mt-1 fw-bolder">{app.name}</span>
<div className={styles.statusContainer}>
<AppStatus lite status={status} />
export const AppTile: React.FC<{ app: AppTileInfo; status: AppStatusEnum; updateAvailable: boolean }> = ({ app, status, updateAvailable }) => {
const t = useTranslations('apps');
return (
<div data-testid={`app-tile-${app.id}`} className="col-sm-6 col-lg-4">
<div className="card card-sm card-link">
<Link href={`/apps/${app.id}`} className="nav-link" passHref>
<div className="card-body">
<div className="d-flex align-items-center">
<span className="me-3">
<AppLogo alt={`${app.name} logo`} className="mr-3 group-hover:scale-105 transition-all" id={app.id} size={60} />
</span>
<div>
<div className="d-flex h-3 align-items-center">
<span className="h4 me-2 mt-1 fw-bolder">{app.name}</span>
<div className={styles.statusContainer}>
<AppStatus lite status={status} />
</div>
</div>
<div className="text-muted">{limitText(app.short_desc, 50)}</div>
</div>
<div className="text-muted">{limitText(app.short_desc, 50)}</div>
</div>
</div>
</div>
{updateAvailable && (
<>
<Tooltip anchorSelect=".updateAvailable">Update available</Tooltip>
<div className="updateAvailable ribbon bg-green ribbon-top">
<IconDownload size={20} />
</div>
</>
)}
</Link>
{updateAvailable && (
<>
<Tooltip anchorSelect=".updateAvailable">{t('update-available')}</Tooltip>
<div className="updateAvailable ribbon bg-green ribbon-top">
<IconDownload size={20} />
</div>
</>
)}
</Link>
</div>
</div>
</div>
);
);
};

View file

@ -88,6 +88,86 @@
}
}
},
"apps": {
"status-running": "Running",
"status-stopped": "Stopped",
"status-starting": "Starting",
"status-stopping": "Stopping",
"status-updating": "Updating",
"status-missing": "Missing",
"status-installing": "Installing",
"status-uninstalling": "Uninstalling",
"update-available": "Update available",
"my-apps": {
"title": "My Apps",
"empty-title": "No app installed",
"empty-subtitle": "Install an app from the app store to get started",
"empty-action": "Go to app store"
},
"app-store": {},
"app-details": {
"install-success": "App installed successfully",
"uninstall-success": "App uninstalled successfully",
"stop-success": "App stopped successfully",
"update-success": "App updated successfully",
"start-success": "App started successfully",
"update-config-success": "App config updated successfully. Restart the app to apply the changes",
"version": "Version",
"actions": {
"start": "Start",
"remove": "Remove",
"settings": "Settings",
"stop": "Stop",
"open": "Open",
"loading": "Loading",
"cancel": "Cancel",
"install": "Install",
"update": "Update"
},
"install-form": {
"title": "Install {name}",
"expose-app": "Expose app",
"domain-name": "Domain name",
"domain-name-hint": "Make sure this exact domain contains an A record pointing to your IP.",
"choose-option": "Choose an option...",
"sumbit-install": "Install",
"submit-update": "Update",
"errors": {
"required": "{label} is required",
"regex": "{label} must match the pattern {pattern}",
"max-length": "{label} must be less than {max} characters",
"min-length": "{label} must be at least {min} characters",
"between-length": "{label} must be between {min} and {max} characters",
"invalid-email": "{label} must be a valid email address",
"number": "{label} must be a number",
"fqdn": "{label} must be a valid domain",
"ip": "{label} must be a valid IP address",
"fqdnip": "{label} must be a valid domain or IP address",
"url": "{label} must be a valid URL"
}
},
"stop-form": {
"title": "Stop {name} ?",
"subtitle": "All data will be retained",
"submit": "Stop"
},
"uninstall-form": {
"title": "Uninstall {name} ?",
"subtitle": "All data for this app will be lost.",
"warning": "Are you sure? This action cannot be undone.",
"submit": "Uninstall"
},
"update-form": {
"title": "Update {name} ?",
"subtitle1": "Update app to latest verion :",
"subtitle2": "This will reset your custom configuration (e.g. changes in docker-compose.yml)",
"submit": "Update"
},
"update-settings-form": {
"title": "Update {name} config"
}
}
},
"header": {
"dashboard": "Dashboard",
"my-apps": "My Apps",

View file

@ -3,6 +3,7 @@ import clsx from 'clsx';
import React from 'react';
import type { AppStatus } from '@/server/db/schema';
import { useTranslations } from 'next-intl';
import { Button } from '../../../../components/ui/Button';
import { AppInfo } from '../../../../core/types';
@ -43,19 +44,20 @@ const ActionButton: React.FC<BtnProps> = (props) => {
};
export const AppActions: React.FC<IProps> = ({ info, status, onInstall, onUninstall, onStart, onStop, onOpen, onUpdate, onCancel, updateAvailable, onUpdateSettings }) => {
const t = useTranslations('apps.app-details');
const hasSettings = Object.keys(info.form_fields).length > 0 || info.exposable;
const buttons: JSX.Element[] = [];
const StartButton = <ActionButton key="start" IconComponent={IconPlayerPlay} onClick={onStart} title="Start" color="success" />;
const RemoveButton = <ActionButton key="remove" IconComponent={IconTrash} onClick={onUninstall} title="Remove" color="danger" />;
const SettingsButton = <ActionButton key="settings" IconComponent={IconSettings} onClick={onUpdateSettings} title="Settings" />;
const StopButton = <ActionButton key="stop" IconComponent={IconPlayerPause} onClick={onStop} title="Stop" color="danger" />;
const OpenButton = <ActionButton key="open" IconComponent={IconExternalLink} onClick={onOpen} title="Open" />;
const LoadingButtion = <ActionButton key="loading" loading onClick={() => null} color="success" title="Loading" />;
const CancelButton = <ActionButton key="cancel" IconComponent={IconX} onClick={onCancel} title="Cancel" />;
const InstallButton = <ActionButton key="install" onClick={onInstall} title="Install" color="success" />;
const UpdateButton = <ActionButton key="update" IconComponent={IconDownload} onClick={onUpdate} width={null} title="Update" color="success" />;
const StartButton = <ActionButton key="start" IconComponent={IconPlayerPlay} onClick={onStart} title={t('actions.start')} color="success" />;
const RemoveButton = <ActionButton key="remove" IconComponent={IconTrash} onClick={onUninstall} title={t('actions.remove')} color="danger" />;
const SettingsButton = <ActionButton key="settings" IconComponent={IconSettings} onClick={onUpdateSettings} title={t('actions.settings')} />;
const StopButton = <ActionButton key="stop" IconComponent={IconPlayerPause} onClick={onStop} title={t('actions.stop')} color="danger" />;
const OpenButton = <ActionButton key="open" IconComponent={IconExternalLink} onClick={onOpen} title={t('actions.open')} />;
const LoadingButtion = <ActionButton key="loading" loading onClick={() => null} color="success" title={t('actions.loading')} />;
const CancelButton = <ActionButton key="cancel" IconComponent={IconX} onClick={onCancel} title={t('actions.cancel')} />;
const InstallButton = <ActionButton key="install" onClick={onInstall} title={t('actions.install')} color="success" />;
const UpdateButton = <ActionButton key="update" IconComponent={IconDownload} onClick={onUpdate} width={null} title={t('actions.update')} color="success" />;
switch (status) {
case 'stopped':

View file

@ -4,6 +4,7 @@ import { Controller, useForm } from 'react-hook-form';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/Select';
import { Tooltip } from 'react-tooltip';
import clsx from 'clsx';
import { useTranslations } from 'next-intl';
import { Button } from '../../../../components/ui/Button';
import { Switch } from '../../../../components/ui/Switch';
import { Input } from '../../../../components/ui/Input';
@ -28,6 +29,7 @@ const hiddenTypes = ['random'];
const typeFilter = (field: FormField) => !hiddenTypes.includes(field.type);
export const InstallForm: React.FC<IProps> = ({ formFields, info, onSubmit, initalValues, loading }) => {
const t = useTranslations('apps.app-details.install-form');
const {
register,
handleSubmit,
@ -87,7 +89,7 @@ export const InstallForm: React.FC<IProps> = ({ formFields, info, onSubmit, init
render={({ field: { onChange, value, ref, ...props } }) => (
<Select value={value as string} defaultValue={field.default as string} onValueChange={onChange} {...props}>
<SelectTrigger className="mb-3" error={errors[field.env_variable]?.message} label={label}>
<SelectValue placeholder="Choose an option..." />
<SelectValue placeholder={t('choose-option')} />
</SelectTrigger>
<SelectContent>
{field.options?.map((option) => (
@ -122,15 +124,13 @@ export const InstallForm: React.FC<IProps> = ({ formFields, info, onSubmit, init
name="exposed"
defaultValue={false}
render={({ field: { onChange, value, ref, ...props } }) => (
<Switch className="mb-3" disabled={info.force_expose} ref={ref} checked={value} onCheckedChange={onChange} {...props} label="Expose app" />
<Switch className="mb-3" disabled={info.force_expose} ref={ref} checked={value} onCheckedChange={onChange} {...props} label={t('expose-app')} />
)}
/>
{watchExposed && (
<div className="mb-3">
<Input {...register('domain')} label="Domain name" error={errors.domain?.message} disabled={loading} placeholder="Domain name" />
<span className="text-muted">
Make sure this exact domain contains an <strong>A</strong> record pointing to your IP.
</span>
<Input {...register('domain')} label={t('domain-name')} error={errors.domain?.message} disabled={loading} placeholder={t('domain-name')} />
<span className="text-muted">{t('domain-name-hint')}</span>
</div>
)}
</>
@ -155,7 +155,7 @@ export const InstallForm: React.FC<IProps> = ({ formFields, info, onSubmit, init
{formFields.filter(typeFilter).map(renderField)}
{info.exposable && renderExposeForm()}
<Button loading={loading} type="submit" className="btn-success">
{initalValues ? 'Update' : 'Install'}
{initalValues ? t('submit-update') : t('sumbit-install')}
</Button>
</form>
);

View file

@ -1,5 +1,6 @@
import React from 'react';
import { Dialog, DialogContent, DialogDescription, DialogHeader } from '@/components/ui/Dialog';
import { useTranslations } from 'next-intl';
import { InstallForm } from '../InstallForm';
import { AppInfo } from '../../../../core/types';
import { FormValues } from '../InstallForm/InstallForm';
@ -11,15 +12,19 @@ interface IProps {
onSubmit: (values: FormValues) => void;
}
export const InstallModal: React.FC<IProps> = ({ info, isOpen, onClose, onSubmit }) => (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent>
<DialogHeader>
<h5 className="modal-title">Install {info.name}</h5>
</DialogHeader>
<DialogDescription>
<InstallForm onSubmit={onSubmit} formFields={info.form_fields} info={info} />
</DialogDescription>
</DialogContent>
</Dialog>
);
export const InstallModal: React.FC<IProps> = ({ info, isOpen, onClose, onSubmit }) => {
const t = useTranslations('apps.app-details.install-form');
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent>
<DialogHeader>
<h5 className="modal-title">{t('title', { name: info.name })}</h5>
</DialogHeader>
<DialogDescription>
<InstallForm onSubmit={onSubmit} formFields={info.form_fields} info={info} />
</DialogDescription>
</DialogContent>
</Dialog>
);
};

View file

@ -1,5 +1,6 @@
import React from 'react';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader } from '@/components/ui/Dialog';
import { useTranslations } from 'next-intl';
import { Button } from '../../../components/ui/Button';
import { AppInfo } from '../../../core/types';
@ -10,20 +11,24 @@ interface IProps {
onConfirm: () => void;
}
export const StopModal: React.FC<IProps> = ({ info, isOpen, onClose, onConfirm }) => (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent size="sm">
<DialogHeader>
<h5 className="modal-title">Stop {info.name} ?</h5>
</DialogHeader>
<DialogDescription>
<div className="text-muted">All data will be retained</div>
</DialogDescription>
<DialogFooter>
<Button onClick={onConfirm} className="btn-danger">
Stop
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
export const StopModal: React.FC<IProps> = ({ info, isOpen, onClose, onConfirm }) => {
const t = useTranslations('apps.app-details.stop-form');
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent size="sm">
<DialogHeader>
<h5 className="modal-title">{t('title', { name: info.name })}</h5>
</DialogHeader>
<DialogDescription>
<div className="text-muted">{t('subtitle')}</div>
</DialogDescription>
<DialogFooter>
<Button onClick={onConfirm} className="btn-danger">
{t('submit')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View file

@ -1,6 +1,7 @@
import { IconAlertTriangle } from '@tabler/icons-react';
import React from 'react';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader } from '@/components/ui/Dialog';
import { useTranslations } from 'next-intl';
import { Button } from '../../../components/ui/Button';
import { AppInfo } from '../../../core/types';
@ -11,22 +12,26 @@ interface IProps {
onConfirm: () => void;
}
export const UninstallModal: React.FC<IProps> = ({ info, isOpen, onClose, onConfirm }) => (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent type="danger" size="sm">
<DialogHeader>
<h5 className="modal-title">Uninstall {info.name} ?</h5>
</DialogHeader>
<DialogDescription className="text-center py-4">
<IconAlertTriangle className="icon mb-2 text-danger icon-lg" />
<h3>Are you sure?</h3>
<div className="text-muted">All data for this app will be lost.</div>
</DialogDescription>
<DialogFooter>
<Button onClick={onConfirm} className="btn-danger">
Uninstall
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
export const UninstallModal: React.FC<IProps> = ({ info, isOpen, onClose, onConfirm }) => {
const t = useTranslations('apps.app-details.uninstall-form');
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent type="danger" size="sm">
<DialogHeader>
<h5 className="modal-title">{t('title', { name: info.name })}</h5>
</DialogHeader>
<DialogDescription className="text-center py-4">
<IconAlertTriangle className="icon mb-2 text-danger icon-lg" />
<h3>{t('warning')}</h3>
<div className="text-muted">{t('subtitle')}</div>
</DialogDescription>
<DialogFooter>
<Button onClick={onConfirm} className="btn-danger">
{t('submit')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View file

@ -1,5 +1,6 @@
import React from 'react';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader } from '@/components/ui/Dialog';
import { useTranslations } from 'next-intl';
import { Button } from '../../../../components/ui/Button';
import { AppInfo } from '../../../../core/types';
@ -11,23 +12,27 @@ interface IProps {
onConfirm: () => void;
}
export const UpdateModal: React.FC<IProps> = ({ info, newVersion, isOpen, onClose, onConfirm }) => (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent size="sm">
<DialogHeader>
<h5 className="modal-title">Update {info.name} ?</h5>
</DialogHeader>
<DialogDescription>
<div className="text-muted">
Update app to latest verion : <b>{newVersion}</b> ?<br />
This will reset your custom configuration (e.g. changes in docker-compose.yml)
</div>
</DialogDescription>
<DialogFooter>
<Button onClick={onConfirm} className="btn-success">
Update
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
export const UpdateModal: React.FC<IProps> = ({ info, newVersion, isOpen, onClose, onConfirm }) => {
const t = useTranslations('apps.app-details.update-form');
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent size="sm">
<DialogHeader>
<h5 className="modal-title">{t('title', { name: info.name })}</h5>
</DialogHeader>
<DialogDescription>
<div className="text-muted">
{t('subtitle1')} <b>{newVersion}</b> ?<br />
{t('subtitle2')}
</div>
</DialogDescription>
<DialogFooter>
<Button onClick={onConfirm} className="btn-success">
{t('submit')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View file

@ -1,5 +1,6 @@
import React from 'react';
import { Dialog, DialogContent, DialogDescription, DialogHeader } from '@/components/ui/Dialog';
import { useTranslations } from 'next-intl';
import { InstallForm } from './InstallForm';
import { AppInfo } from '../../../core/types';
import { FormValues } from './InstallForm/InstallForm';
@ -14,15 +15,19 @@ interface IProps {
onSubmit: (values: FormValues) => void;
}
export const UpdateSettingsModal: React.FC<IProps> = ({ info, config, isOpen, onClose, onSubmit, exposed, domain }) => (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent>
<DialogHeader>
<h5 className="modal-title">Update {info.name} config</h5>
</DialogHeader>
<DialogDescription>
<InstallForm onSubmit={onSubmit} formFields={info.form_fields} info={info} initalValues={{ ...config, exposed, domain }} />
</DialogDescription>
</DialogContent>
</Dialog>
);
export const UpdateSettingsModal: React.FC<IProps> = ({ info, config, isOpen, onClose, onSubmit, exposed, domain }) => {
const t = useTranslations('apps.app-details.update-settings-form');
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent>
<DialogHeader>
<h5 className="modal-title">{t('title', { name: info.name })}</h5>
</DialogHeader>
<DialogDescription>
<InstallForm onSubmit={onSubmit} formFields={info.form_fields} info={info} initalValues={{ ...config, exposed, domain }} />
</DialogDescription>
</DialogContent>
</Dialog>
);
};

View file

@ -1,4 +1,5 @@
import React from 'react';
import { faker } from '@faker-js/faker';
import { fireEvent, render, screen, waitFor } from '../../../../../../tests/test-utils';
import { createAppEntity } from '../../../../mocks/fixtures/app.fixtures';
import { getTRPCMock, getTRPCMockError } from '../../../../mocks/getTrpcMock';
@ -126,11 +127,12 @@ describe('Test: AppDetailsContainer', () => {
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: 'my big error',
message: error,
}),
);
@ -144,7 +146,7 @@ describe('Test: AppDetailsContainer', () => {
fireEvent.click(installButton);
await waitFor(() => {
expect(screen.getByText('Failed to install app: my big error')).toBeInTheDocument();
expect(screen.getByText(error)).toBeInTheDocument();
});
});
});
@ -169,7 +171,8 @@ describe('Test: AppDetailsContainer', () => {
it('should display a toast error when update mutation fails', async () => {
// Arrange
server.use(getTRPCMockError({ path: ['app', 'updateApp'], type: 'mutation', message: 'my big error' }));
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' });
@ -181,7 +184,7 @@ describe('Test: AppDetailsContainer', () => {
// Assert
await waitFor(() => {
expect(screen.getByText('Failed to update app: my big error')).toBeInTheDocument();
expect(screen.getByText(error)).toBeInTheDocument();
});
});
});
@ -207,7 +210,8 @@ describe('Test: AppDetailsContainer', () => {
it('should display a toast error when uninstall mutation fails', async () => {
// Arrange
server.use(getTRPCMockError({ path: ['app', 'uninstallApp'], type: 'mutation', message: 'my big error' }));
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' });
@ -219,7 +223,7 @@ describe('Test: AppDetailsContainer', () => {
// Assert
await waitFor(() => {
expect(screen.getByText('Failed to uninstall app: my big error')).toBeInTheDocument();
expect(screen.getByText(error)).toBeInTheDocument();
});
});
});
@ -243,7 +247,8 @@ describe('Test: AppDetailsContainer', () => {
it('should display a toast error when start mutation fails', async () => {
// Arrange
server.use(getTRPCMockError({ path: ['app', 'startApp'], type: 'mutation', message: 'my big error' }));
const error = faker.lorem.sentence();
server.use(getTRPCMockError({ path: ['app', 'startApp'], type: 'mutation', message: error }));
const app = createAppEntity({ status: 'stopped' });
render(<AppDetailsContainer app={app} />);
@ -253,7 +258,7 @@ describe('Test: AppDetailsContainer', () => {
// Assert
await waitFor(() => {
expect(screen.getByText('Failed to start app: my big error')).toBeInTheDocument();
expect(screen.getByText(error)).toBeInTheDocument();
});
});
});
@ -279,7 +284,8 @@ describe('Test: AppDetailsContainer', () => {
it('should display a toast error when stop mutation fails', async () => {
// Arrange
server.use(getTRPCMockError({ path: ['app', 'stopApp'], type: 'mutation', message: 'my big error' }));
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' });
@ -291,7 +297,7 @@ describe('Test: AppDetailsContainer', () => {
// Assert
await waitFor(() => {
expect(screen.getByText('Failed to stop app: my big error')).toBeInTheDocument();
expect(screen.getByText(error)).toBeInTheDocument();
});
});
});
@ -317,7 +323,8 @@ describe('Test: AppDetailsContainer', () => {
it('should display a toast error when update config mutation fails', async () => {
// Arrange
server.use(getTRPCMockError({ path: ['app', 'updateAppConfig'], type: 'mutation', message: 'my big error' }));
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' });
@ -329,7 +336,7 @@ describe('Test: AppDetailsContainer', () => {
// Assert
await waitFor(() => {
expect(screen.getByText('Failed to update app config: my big error')).toBeInTheDocument();
expect(screen.getByText(error)).toBeInTheDocument();
});
});
});

View file

@ -1,5 +1,7 @@
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';
@ -20,6 +22,7 @@ interface IProps {
}
export const AppDetailsContainer: React.FC<IProps> = ({ app }) => {
const t = useTranslations();
const installDisclosure = useDisclosure();
const uninstallDisclosure = useDisclosure();
const stopDisclosure = useDisclosure();
@ -40,11 +43,12 @@ export const AppDetailsContainer: React.FC<IProps> = ({ app }) => {
},
onSuccess: () => {
invalidate();
toast.success('App installed successfully');
toast.success(t('apps.app-details.install-success'));
},
onError: (e) => {
invalidate();
toast.error(`Failed to install app: ${e.message}`);
const toastMessage = t(e.data?.translatedError || (e.message as MessageKey));
toast.error(toastMessage);
},
});
@ -55,9 +59,9 @@ export const AppDetailsContainer: React.FC<IProps> = ({ app }) => {
},
onSuccess: () => {
invalidate();
toast.success('App uninstalled successfully');
toast.success(t('apps.app-details.uninstall-success'));
},
onError: (e) => toast.error(`Failed to uninstall app: ${e.message}`),
onError: (e) => toast.error(t(e.data?.translatedError || (e.message as MessageKey))),
});
const stop = trpc.app.stopApp.useMutation({
@ -67,9 +71,9 @@ export const AppDetailsContainer: React.FC<IProps> = ({ app }) => {
},
onSuccess: () => {
invalidate();
toast.success('App stopped successfully');
toast.success(t('apps.app-details.stop-success'));
},
onError: (e) => toast.error(`Failed to stop app: ${e.message}`),
onError: (e) => toast.error(t(e.data?.translatedError || (e.message as MessageKey))),
});
const update = trpc.app.updateApp.useMutation({
@ -79,9 +83,9 @@ export const AppDetailsContainer: React.FC<IProps> = ({ app }) => {
},
onSuccess: () => {
invalidate();
toast.success('App updated successfully');
toast.success(t('apps.app-details.update-success'));
},
onError: (e) => toast.error(`Failed to update app: ${e.message}`),
onError: (e) => toast.error(t(e.data?.translatedError || (e.message as MessageKey))),
});
const start = trpc.app.startApp.useMutation({
@ -90,18 +94,18 @@ export const AppDetailsContainer: React.FC<IProps> = ({ app }) => {
},
onSuccess: () => {
invalidate();
toast.success('App started successfully');
toast.success(t('apps.app-details.start-success'));
},
onError: (e) => toast.error(`Failed to start app: ${e.message}`),
onError: (e) => toast.error(t(e.data?.translatedError || (e.message as MessageKey))),
});
const updateConfig = trpc.app.updateAppConfig.useMutation({
onMutate: () => updateSettingsDisclosure.close(),
onSuccess: () => {
invalidate();
toast.success('App config updated successfully. Restart the app to apply the changes');
toast.success(t('apps.app-details.update-config-success'));
},
onError: (e) => toast.error(`Failed to update app config: ${e.message}`),
onError: (e) => toast.error(t(e.data?.translatedError || (e.message as MessageKey))),
});
const updateAvailable = Number(app.version || 0) < Number(app?.latestVersion || 0);
@ -164,7 +168,7 @@ export const AppDetailsContainer: React.FC<IProps> = ({ app }) => {
<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">Version: </span>
<span className="mt-1 me-1">{t('apps.app-details.version')}: </span>
<span className="badge bg-gray mt-2">{app.info.version}</span>
</div>
{app.domain && (

View file

@ -1,6 +1,8 @@
import { NextPage } from 'next';
import React from 'react';
import { useRouter } from 'next/router';
import type { MessageKey } from '@/server/utils/errors';
import { useTranslations } from 'next-intl';
import { Layout } from '../../../../components/Layout';
import { ErrorPage } from '../../../../components/ui/ErrorPage';
import { trpc } from '../../../../utils/trpc';
@ -18,6 +20,7 @@ const paths: Record<string, Path> = {
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' };
@ -31,9 +34,9 @@ export const AppDetailsPage: NextPage<IProps> = ({ appId }) => {
// TODO: add loading state
return (
<Layout title={data?.info.name} breadcrumbs={breadcrumb}>
<Layout title={data?.info.name || ''} breadcrumbs={breadcrumb}>
{data?.info && <AppDetailsContainer app={data} />}
{error && <ErrorPage error={error.message} />}
{error && <ErrorPage error={t(error.data?.translatedError || (error.message as MessageKey))} />}
</Layout>
);
};

View file

@ -1,6 +1,8 @@
import React from 'react';
import { useRouter } from 'next/router';
import { NextPage } from 'next';
import { useTranslations } from 'next-intl';
import { MessageKey } from '@/server/utils/errors';
import { AppTile } from '../../../../components/AppTile';
import { Layout } from '../../../../components/Layout';
import { EmptyPage } from '../../../../components/ui/EmptyPage';
@ -9,6 +11,7 @@ import { trpc } from '../../../../utils/trpc';
import { AppRouterOutput } from '../../../../../server/routers/app/app.router';
export const AppsPage: NextPage = () => {
const t = useTranslations();
const { data, isLoading, error } = trpc.app.installedApps.useQuery();
const renderApp = (app: AppRouterOutput['installedApps'][number]) => {
@ -22,7 +25,7 @@ export const AppsPage: NextPage = () => {
const router = useRouter();
return (
<Layout title="My Apps">
<Layout title={t('apps.my-apps.title')}>
<div>
{Boolean(data?.length) && (
<div className="row row-cards" data-testid="apps-list">
@ -30,9 +33,9 @@ export const AppsPage: NextPage = () => {
</div>
)}
{!isLoading && data?.length === 0 && (
<EmptyPage title="No app installed" subtitle="Install an app from the app store to get started" onAction={() => router.push('/app-store')} actionLabel="Go to app store" />
<EmptyPage title={t('apps.my-apps.empty-title')} subtitle={t('apps.my-apps.empty-subtitle')} onAction={() => router.push('/app-store')} actionLabel={t('apps.my-apps.empty-action')} />
)}
{error && <ErrorPage error={error.message} />}
{error && <ErrorPage error={t(error.data?.translatedError || (error.message as MessageKey))} />}
</div>
</Layout>
);

View file

@ -1,9 +1,12 @@
import validator from 'validator';
import { useUIStore } from '@/client/state/uiStore';
import type { FormField } from '../../../../core/types';
export const validateField = (field: FormField, value: string | undefined | boolean): string | undefined => {
const { translator } = useUIStore.getState();
if (field.required && !value) {
return `${field.label} is required`;
return translator('apps.app-details.install-form.errors.required', { label: field.label });
}
if (!value || typeof value !== 'string') {
@ -11,51 +14,51 @@ export const validateField = (field: FormField, value: string | undefined | bool
}
if (field.regex && !validator.matches(value, field.regex)) {
return field.pattern_error || `${field.label} must match the pattern ${field.regex}`;
return field.pattern_error || translator('apps.app-details.install-form.errors.regex', { label: field.label, pattern: field.regex });
}
switch (field.type) {
case 'text':
if (field.max && value.length > field.max) {
return `${field.label} must be less than ${field.max} characters`;
return translator('apps.app-details.install-form.errors.max-length', { label: field.label, max: field.max });
}
if (field.min && value.length < field.min) {
return `${field.label} must be at least ${field.min} characters`;
return translator('apps.app-details.install-form.errors.min-length', { label: field.label, min: field.min });
}
break;
case 'password':
if (!validator.isLength(value, { min: field.min || 0, max: field.max || 100 })) {
return `${field.label} must be between ${String(field.min)} and ${String(field.max)} characters`;
return translator('apps.app-details.install-form.errors.between-length', { label: field.label, min: field.min, max: field.max });
}
break;
case 'email':
if (!validator.isEmail(value)) {
return `${field.label} must be a valid email address`;
return translator('apps.app-details.install-form.errors.invalid-email', { label: field.label });
}
break;
case 'number':
if (!validator.isNumeric(value)) {
return `${field.label} must be a number`;
return translator('apps.app-details.install-form.errors.number', { label: field.label });
}
break;
case 'fqdn':
if (!validator.isFQDN(value)) {
return `${field.label} must be a valid domain`;
return translator('apps.app-details.install-form.errors.fqdn', { label: field.label });
}
break;
case 'ip':
if (!validator.isIP(value)) {
return `${field.label} must be a valid IP address`;
return translator('apps.app-details.install-form.errors.ip', { label: field.label });
}
break;
case 'fqdnip':
if (!validator.isFQDN(value || '') && !validator.isIP(value)) {
return `${field.label} must be a valid domain or IP address`;
return translator('apps.app-details.install-form.errors.fqdnip', { label: field.label });
}
break;
case 'url':
if (!validator.isURL(value)) {
return `${field.label} must be a valid URL`;
return translator('apps.app-details.install-form.errors.url', { label: field.label });
}
break;
default:
@ -67,7 +70,8 @@ export const validateField = (field: FormField, value: string | undefined | bool
const validateDomain = (domain?: string | boolean): string | undefined => {
if (typeof domain !== 'string' || !validator.isFQDN(domain || '')) {
return `${String(domain)} must be a valid domain`;
const { translator } = useUIStore.getState();
return translator('apps.app-details.install-form.errors.fqdn', { label: String(domain) });
}
return undefined;

View file

@ -1,8 +1,13 @@
import { createTranslator } from 'next-intl';
import { create } from 'zustand';
import englishMessages from '../messages/en.json';
const defaultTranslator = createTranslator({ locale: 'en', messages: englishMessages });
type UIStore = {
menuItem: string;
darkMode: boolean;
translator: typeof defaultTranslator;
setMenuItem: (menuItem: string) => void;
setDarkMode: (darkMode: boolean) => void;
};
@ -10,6 +15,7 @@ type UIStore = {
export const useUIStore = create<UIStore>((set) => ({
menuItem: 'dashboard',
darkMode: false,
translator: defaultTranslator,
setDarkMode: (darkMode: boolean) => {
if (darkMode) {
localStorage.setItem('darkMode', darkMode.toString());

View file

@ -2,6 +2,8 @@ import nookies from 'nookies';
import { GetServerSideProps } from 'next';
import merge from 'lodash.merge';
import { getLocaleFromString } from '@/shared/internationalization/locales';
import { createTranslator } from 'next-intl';
import { useUIStore } from '../state/uiStore';
export const getAuthedPageProps: GetServerSideProps = async (ctx) => {
const { userId } = ctx.req.session;
@ -38,10 +40,14 @@ export const getMessagesPageProps: GetServerSideProps = async (ctx) => {
}
const messages = (await import(`../messages/${locale}.json`)).default;
const mergedMessages = merge(englishMessages, messages);
const translator = createTranslator({ locale, messages: mergedMessages });
useUIStore.setState({ translator });
return {
props: {
messages: merge(englishMessages, messages),
messages: mergedMessages,
},
};
};