feat: add translations strings for my-apps page
This commit is contained in:
parent
3b239e9b00
commit
91e48f8e01
17 changed files with 314 additions and 167 deletions
|
@ -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',
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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':
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 && (
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
Loading…
Add table
Reference in a new issue