feat(frontend): i18n simulation pages (SimulationFormPage, SimulationList)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,13 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { ChevronDown, Plus } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { extractApiError } from '@/api/client';
|
||||
import type { SimulationTemplate } from '@/api/types';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useEngagementSimulations, useCreateSimulation } from '@/hooks/useSimulations';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { formatDateTime } from '@/lib/format';
|
||||
import { LoadingState } from './LoadingState';
|
||||
import { ErrorState } from './ErrorState';
|
||||
import { EmptyState } from './EmptyState';
|
||||
@@ -16,12 +18,8 @@ interface SimulationListProps {
|
||||
engagementId: number;
|
||||
}
|
||||
|
||||
function formatDate(value: string | null): string {
|
||||
if (!value) return '—';
|
||||
return value.replace('T', ' ').slice(0, 16);
|
||||
}
|
||||
|
||||
function NewSimulationDropdown({ engagementId }: { engagementId: number }): JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { push } = useToast();
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -61,10 +59,10 @@ function NewSimulationDropdown({ engagementId }: { engagementId: number }): JSX.
|
||||
try {
|
||||
const sim = await createMutation.mutateAsync({ name: template.name, template_id: template.id });
|
||||
setShowPicker(false);
|
||||
push(`Created "${sim.name}" from template`, 'success');
|
||||
push(t('simulation.form.toast.created'), 'success');
|
||||
navigate(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
} catch (err) {
|
||||
push(extractApiError(err, 'Could not create simulation from template'), 'error');
|
||||
push(extractApiError(err, t('simulation.form.error.create')), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -77,7 +75,7 @@ function NewSimulationDropdown({ engagementId }: { engagementId: number }): JSX.
|
||||
onClick={handleBlank}
|
||||
data-testid="new-simulation-btn"
|
||||
>
|
||||
<Plus size={14} aria-hidden /> New
|
||||
<Plus size={14} aria-hidden /> {t('simulation.list.new')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -133,16 +131,17 @@ function NewSimulationDropdown({ engagementId }: { engagementId: number }): JSX.
|
||||
}
|
||||
|
||||
export function SimulationList({ engagementId }: SimulationListProps): JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, isError, error, refetch } = useEngagementSimulations(engagementId);
|
||||
const { canEditEngagements } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (isLoading) return <LoadingState label="Loading simulations…" />;
|
||||
if (isLoading) return <LoadingState label={t('common.loading')} />;
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<ErrorState
|
||||
message={extractApiError(error, 'Could not load simulations')}
|
||||
message={extractApiError(error, t('simulation.list.error'))}
|
||||
onRetry={() => refetch()}
|
||||
/>
|
||||
);
|
||||
@@ -151,8 +150,8 @@ export function SimulationList({ engagementId }: SimulationListProps): JSX.Eleme
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="No simulations yet"
|
||||
description="Create the first simulation to start tracking red team tests."
|
||||
title={t('simulation.list.empty.title')}
|
||||
description={t('simulation.list.empty.desc')}
|
||||
action={
|
||||
canEditEngagements ? (
|
||||
<NewSimulationDropdown engagementId={engagementId} />
|
||||
@@ -165,7 +164,7 @@ export function SimulationList({ engagementId }: SimulationListProps): JSX.Eleme
|
||||
return (
|
||||
<div className="flex flex-col gap-md">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-[24px] font-medium text-ink">Simulations</h2>
|
||||
<h2 className="text-[24px] font-medium text-ink">{t('engagement.detail.tabs.simulations')}</h2>
|
||||
{canEditEngagements ? (
|
||||
<NewSimulationDropdown engagementId={engagementId} />
|
||||
) : null}
|
||||
@@ -175,10 +174,10 @@ export function SimulationList({ engagementId }: SimulationListProps): JSX.Eleme
|
||||
<table className="table-compact w-full text-left">
|
||||
<thead className="bg-cloud border-b border-hairline">
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>MITRE</th>
|
||||
<th>Status</th>
|
||||
<th>Executed at</th>
|
||||
<th>{t('simulation.list.col.name')}</th>
|
||||
<th>{t('simulation.list.col.mitre')}</th>
|
||||
<th>{t('simulation.list.col.status')}</th>
|
||||
<th>{t('simulation.list.col.executedAt')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -202,8 +201,8 @@ export function SimulationList({ engagementId }: SimulationListProps): JSX.Eleme
|
||||
<td className="text-charcoal font-mono">
|
||||
{(() => {
|
||||
const items = [
|
||||
...(sim.tactics ?? []).map((t) => t.id),
|
||||
...sim.techniques.map((t) => t.id),
|
||||
...(sim.tactics ?? []).map((tactic) => tactic.id),
|
||||
...sim.techniques.map((tech) => tech.id),
|
||||
];
|
||||
if (items.length === 0) return '—';
|
||||
if (items.length === 1) return items[0];
|
||||
@@ -214,7 +213,7 @@ export function SimulationList({ engagementId }: SimulationListProps): JSX.Eleme
|
||||
<SimulationStatusBadge status={sim.status} />
|
||||
</td>
|
||||
<td className="text-charcoal font-mono">
|
||||
{formatDate(sim.executed_at)}
|
||||
{sim.executed_at ? formatDateTime(sim.executed_at) : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user