feat(admin): E2E content/system mock-сьюты, row testids, IFT smoke. Refs EventHub/EventHubFrontAdmin#33 #37 #38 #39

This commit is contained in:
2026-07-14 22:44:22 +03:00
parent a3468518b3
commit 836e4a7eea
23 changed files with 831 additions and 223 deletions
+7 -3
View File
@@ -126,15 +126,19 @@ export function DataTable<TData, TParams extends ServerTableParams>({
</TableRow>
))
) : table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
table.getRowModel().rows.map((row) => {
const original = row.original as { id?: string; word?: string };
const rowKey = original.id ?? original.word;
return (
<TableRow key={row.id} data-testid={rowKey ? `row-${rowKey}` : undefined}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
);
})
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center text-muted-foreground">
+2 -1
View File
@@ -60,7 +60,7 @@ export function DenseRowList<TParams extends ServerTableParams>({
) : (
<ul className="divide-y">
{items.map((item) => (
<li key={item.id}>
<li key={item.id} data-testid={`row-${item.id}`}>
<div
className={cn(
'group flex items-start gap-3 px-4 py-3 transition-colors',
@@ -69,6 +69,7 @@ export function DenseRowList<TParams extends ServerTableParams>({
>
<button
type="button"
data-testid={`row-activate-${item.id}`}
className={cn(
'min-w-0 flex-1 text-left',
!item.onActivate && 'cursor-default'
+4
View File
@@ -11,6 +11,7 @@ interface ExploreSearchProps {
placeholder?: string;
className?: string;
children?: React.ReactNode;
'data-testid'?: string;
}
export function ExploreSearch({
@@ -20,10 +21,12 @@ export function ExploreSearch({
placeholder,
className,
children,
'data-testid': dataTestId,
}: ExploreSearchProps) {
const { t } = useTranslation();
return (
<form
data-testid={dataTestId}
className={cn('flex flex-wrap items-center gap-2', className)}
onSubmit={(e) => {
e.preventDefault();
@@ -33,6 +36,7 @@ export function ExploreSearch({
<div className="relative min-w-[200px] max-w-md flex-1">
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
data-testid={dataTestId ? `${dataTestId}-input` : undefined}
className="pl-8"
placeholder={placeholder ?? t('common.searchPlaceholder')}
value={value}
+9 -4
View File
@@ -118,7 +118,7 @@ const AdminDetailPage: React.FC = () => {
const titleName = !isBadValue(admin.nickname) ? admin.nickname : admin.email || admin.id;
return (
<>
<div data-testid="page-admin-detail">
<PageHeader
title={t('admins.detailTitle', { name: titleName })}
breadcrumbs={[
@@ -127,11 +127,16 @@ const AdminDetailPage: React.FC = () => {
]}
actions={
<>
<Button variant="outline" size="sm" onClick={() => setEditModal(true)}>
<Button
variant="outline"
size="sm"
data-testid="btn-edit-admin"
onClick={() => setEditModal(true)}
>
<Pencil className="h-4 w-4" />
{t('common.edit')}
</Button>
<Button variant="outline" onClick={() => navigate('/admins')}>
<Button variant="outline" data-testid="btn-back" onClick={() => navigate('/admins')}>
<ArrowLeft className="h-4 w-4" />
{t('common.backToList')}
</Button>
@@ -301,7 +306,7 @@ const AdminDetailPage: React.FC = () => {
</DialogFooter>
</DialogContent>
</Dialog>
</>
</div>
);
};
+3 -2
View File
@@ -236,12 +236,13 @@ const AdminListPage: React.FC = () => {
return (
<>
<ExploreListShell
data-testid="page-admins"
title={t('admins.title')}
stats={exploreStats}
viewMode={viewMode}
onViewModeChange={setViewMode}
actions={
<Button onClick={() => setCreateModal(true)}>
<Button data-testid="btn-create-admin" onClick={() => setCreateModal(true)}>
<Plus className="mr-2 h-4 w-4" />
{t('admins.add')}
</Button>
@@ -261,7 +262,7 @@ const AdminListPage: React.FC = () => {
</ExploreListShell>
<Dialog open={createModal} onOpenChange={setCreateModal}>
<DialogContent>
<DialogContent data-testid="dialog-create-admin">
<DialogHeader>
<DialogTitle>{t('admins.createTitle')}</DialogTitle>
</DialogHeader>
+3 -2
View File
@@ -186,13 +186,14 @@ const AuditPage: React.FC = () => {
);
return (
<div className="space-y-6">
<div data-testid="page-audit" className="space-y-6">
<ListPageHeader title={t('audit.title')} description={t('audit.description')} />
<div className="flex flex-wrap items-end gap-4">
<div className="flex flex-wrap items-end gap-4" data-testid="filter-audit">
<div className="space-y-2">
<Label>{t('audit.searchAdmin')}</Label>
<Input
data-testid="filter-audit-admin-search"
placeholder={t('audit.searchAdminPlaceholder')}
value={adminSearch}
onChange={(e) => setAdminSearch(e.target.value)}
+15 -3
View File
@@ -118,7 +118,7 @@ const BannedWordsPage: React.FC = () => {
);
return (
<div className="space-y-6">
<div data-testid="page-banned-words" className="space-y-6">
<ListPageHeader
title={t('bannedWords.title')}
description={t('bannedWords.description')}
@@ -126,24 +126,36 @@ const BannedWordsPage: React.FC = () => {
<div className="flex flex-wrap items-center gap-2">
<Input
data-testid="input-banned-word"
placeholder={t('bannedWords.placeholder')}
value={newWord}
onChange={(e) => setNewWord(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
className="w-[200px]"
/>
<Button onClick={handleAdd} disabled={addWord.isPending || !newWord.trim()}>
<Button
data-testid="btn-add-banned-word"
onClick={handleAdd}
disabled={addWord.isPending || !newWord.trim()}
>
{t('common.add')}
</Button>
<div className="flex items-center gap-2">
<Input
data-testid="filter-banned-words-search"
placeholder={t('explore.searchWord')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
className="w-[200px]"
/>
<Button variant="outline" size="icon" className="h-9 w-9" onClick={handleSearch}>
<Button
data-testid="btn-search-banned-words"
variant="outline"
size="icon"
className="h-9 w-9"
onClick={handleSearch}
>
<Search className="h-4 w-4" />
</Button>
</div>
+158 -158
View File
@@ -1,158 +1,158 @@
import React from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import dayjs from 'dayjs';
import { ArrowLeft } from 'lucide-react';
import { useCalendar } from '@/hooks/useCalendars';
import { formatDisplayValue } from '@/lib/utils';
import { PageHeader } from '@/components/PageHeader';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
const isBadValue = (val: unknown) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const DetailValue: React.FC<{ value: unknown; emptyLabel: string }> = ({ value, emptyLabel }) => {
const text = formatDisplayValue(value);
if (text === '-') return <>{emptyLabel}</>;
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
return <pre className="overflow-auto rounded-md bg-muted p-2 text-xs">{text}</pre>;
}
return <>{text}</>;
};
const CalendarDetailPage: React.FC = () => {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: calendar, isLoading } = useCalendar(id || '');
const formatDate = (dateStr: string) => {
if (isBadValue(dateStr)) return t('common.emDash');
const d = dayjs(dateStr);
return d.isValid() ? d.format('DD.MM.YYYY HH:mm') : dateStr;
};
if (isLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-96 w-full" />
</div>
);
}
if (!calendar) return <p className="text-muted-foreground">{t('calendars.notFound')}</p>;
const emDash = t('common.emDash');
const titleLabel = calendar.title || calendar.id;
return (
<>
<PageHeader
title={t('calendars.detailTitle', { name: titleLabel })}
breadcrumbs={[
{ label: t('calendars.title'), href: '/calendars' },
{ label: String(titleLabel) },
]}
actions={
<Button variant="outline" onClick={() => navigate('/calendars')}>
<ArrowLeft className="h-4 w-4" />
{t('common.backToList')}
</Button>
}
/>
<Card>
<CardContent className="space-y-6">
<dl className="grid gap-3 text-sm">
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.id')}</dt>
<dd>{calendar.id}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.title')}</dt>
<dd>{calendar.title}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.shortName')}</dt>
import React from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import dayjs from 'dayjs';
import { ArrowLeft } from 'lucide-react';
import { useCalendar } from '@/hooks/useCalendars';
import { formatDisplayValue } from '@/lib/utils';
import { PageHeader } from '@/components/PageHeader';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
const isBadValue = (val: unknown) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const DetailValue: React.FC<{ value: unknown; emptyLabel: string }> = ({ value, emptyLabel }) => {
const text = formatDisplayValue(value);
if (text === '-') return <>{emptyLabel}</>;
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
return <pre className="overflow-auto rounded-md bg-muted p-2 text-xs">{text}</pre>;
}
return <>{text}</>;
};
const CalendarDetailPage: React.FC = () => {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: calendar, isLoading } = useCalendar(id || '');
const formatDate = (dateStr: string) => {
if (isBadValue(dateStr)) return t('common.emDash');
const d = dayjs(dateStr);
return d.isValid() ? d.format('DD.MM.YYYY HH:mm') : dateStr;
};
if (isLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-96 w-full" />
</div>
);
}
if (!calendar) return <p className="text-muted-foreground">{t('calendars.notFound')}</p>;
const emDash = t('common.emDash');
const titleLabel = calendar.title || calendar.id;
return (
<div data-testid="page-calendar-detail">
<PageHeader
title={t('calendars.detailTitle', { name: titleLabel })}
breadcrumbs={[
{ label: t('calendars.title'), href: '/calendars' },
{ label: String(titleLabel) },
]}
actions={
<Button variant="outline" data-testid="btn-back" onClick={() => navigate('/calendars')}>
<ArrowLeft className="h-4 w-4" />
{t('common.backToList')}
</Button>
}
/>
<Card>
<CardContent className="space-y-6">
<dl className="grid gap-3 text-sm">
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.id')}</dt>
<dd>{calendar.id}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.title')}</dt>
<dd>{calendar.title}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.shortName')}</dt>
<dd><DetailValue value={calendar.short_name} emptyLabel={emDash} /></dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.description')}</dt>
<dd><DetailValue value={calendar.description} emptyLabel={emDash} /></dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.type')}</dt>
<dd>
<Badge variant={calendar.type === 'commercial' ? 'warning' : 'default'}>{calendar.type}</Badge>
</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.status')}</dt>
<dd>
<Badge variant={calendar.status === 'active' ? 'success' : calendar.status === 'frozen' ? 'warning' : 'destructive'}>
{calendar.status}
</Badge>
</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.reason')}</dt>
<dd><DetailValue value={calendar.reason} emptyLabel={emDash} /></dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.category')}</dt>
<dd><DetailValue value={calendar.category} emptyLabel={emDash} /></dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.owner')}</dt>
<dd>
{!isBadValue(calendar.owner_id) ? (
<Link to={`/users/${calendar.owner_id}`} className="text-primary hover:underline">
{calendar.owner_id}
</Link>
) : emDash}
</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.color')}</dt>
<dd><DetailValue value={calendar.color} emptyLabel={emDash} /></dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.image')}</dt>
<dd><DetailValue value={calendar.image_url} emptyLabel={emDash} /></dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.confirmation')}</dt>
<dd><DetailValue value={calendar.confirmation} emptyLabel={emDash} /></dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('events.tags')}</dt>
<dd><DetailValue value={calendar.tags} emptyLabel={emDash} /></dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.rating')}</dt>
<dd>{calendar.rating_avg} ({calendar.rating_count})</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.settings')}</dt>
<dd><DetailValue value={calendar.settings} emptyLabel={emDash} /></dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.createdAt')}</dt>
<dd>{formatDate(calendar.created_at)}</dd>
</div>
<div className="grid grid-cols-[140px_1fr] gap-2">
<dt className="text-muted-foreground">{t('common.updatedAt')}</dt>
<dd>{formatDate(calendar.updated_at)}</dd>
</div>
</dl>
</CardContent>
</Card>
</div>
);
};
export default CalendarDetailPage;
+2
View File
@@ -286,12 +286,14 @@ const CalendarListPage: React.FC = () => {
return (
<>
<ExploreListShell
data-testid="page-calendars"
title={t('calendars.title')}
stats={exploreStats}
viewMode={viewMode}
onViewModeChange={setViewMode}
toolbar={
<ExploreSearch
data-testid="filter-calendars-search"
value={searchDraft}
onChange={setSearchDraft}
onSubmit={applySearch}
+3 -3
View File
@@ -61,7 +61,7 @@ const EventDetailPage: React.FC = () => {
const titleLabel = event.title || event.id;
return (
<>
<div data-testid="page-event-detail">
<PageHeader
title={t('events.detailTitle', { name: titleLabel })}
breadcrumbs={[
@@ -69,7 +69,7 @@ const EventDetailPage: React.FC = () => {
{ label: String(titleLabel) },
]}
actions={
<Button variant="outline" onClick={() => navigate('/events')}>
<Button variant="outline" data-testid="btn-back" onClick={() => navigate('/events')}>
<ArrowLeft className="h-4 w-4" />
{t('common.backToList')}
</Button>
@@ -190,7 +190,7 @@ const EventDetailPage: React.FC = () => {
</CardContent>
</Card>
</>
</div>
);
};
+2
View File
@@ -376,6 +376,7 @@ const EventListPage: React.FC = () => {
return (
<>
<ExploreListShell
data-testid="page-events"
title={t('events.title')}
stats={exploreStats}
viewMode={viewMode}
@@ -384,6 +385,7 @@ const EventListPage: React.FC = () => {
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
<div className="min-w-0 flex-1">
<ExploreSearch
data-testid="filter-events-search"
value={searchDraft}
onChange={setSearchDraft}
onSubmit={applySearch}
+3 -2
View File
@@ -286,7 +286,7 @@ const MonitoringPage: React.FC = () => {
const healthy = summary.online > 0 && summary.cpuPeak < 85;
return (
<div className="space-y-6">
<div data-testid="page-monitoring" className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="flex flex-wrap items-center gap-2">
@@ -303,11 +303,12 @@ const MonitoringPage: React.FC = () => {
{t('monitoring.subtitle', { minutes })}
</p>
</div>
<div className="flex flex-wrap gap-1.5">
<div className="flex flex-wrap gap-1.5" data-testid="filter-monitoring-range">
{TIME_RANGE_KEYS.map((r) => (
<Button
key={r.value}
size="sm"
data-testid={`filter-monitoring-range-${r.value}`}
variant={minutes === r.value ? 'default' : 'outline'}
onClick={() => setMinutes(r.value)}
>
+4 -4
View File
@@ -150,7 +150,7 @@ const ProfilePage: React.FC = () => {
const emDash = t('common.emDash');
return (
<Card className="max-w-2xl">
<Card data-testid="page-profile" className="max-w-2xl">
<CardHeader>
<CardTitle>{t('profile.title')}</CardTitle>
<CardDescription>{t('profile.description')}</CardDescription>
@@ -232,7 +232,7 @@ const ProfilePage: React.FC = () => {
</div>
</dl>
<Button onClick={startEditing}>
<Button data-testid="btn-edit-profile" onClick={startEditing}>
<Pencil className="h-4 w-4" />
{t('profile.edit')}
</Button>
@@ -309,10 +309,10 @@ const ProfilePage: React.FC = () => {
)}
</div>
<div className="flex gap-2">
<Button type="submit" disabled={updateProfile.isPending}>
<Button type="submit" data-testid="btn-save-profile" disabled={updateProfile.isPending}>
{updateProfile.isPending ? t('common.saving') : t('common.save')}
</Button>
<Button type="button" variant="outline" onClick={() => setEditing(false)}>
<Button type="button" data-testid="btn-cancel-profile" variant="outline" onClick={() => setEditing(false)}>
{t('common.cancel')}
</Button>
</div>
@@ -277,12 +277,13 @@ const SubscriptionListPage: React.FC = () => {
return (
<>
<ExploreListShell
data-testid="page-subscriptions"
title={t('subscriptions.title')}
stats={exploreStats}
viewMode={viewMode}
onViewModeChange={setViewMode}
toolbar={
<div className="flex flex-wrap gap-2">
<div className="flex flex-wrap gap-2" data-testid="filter-subscriptions">
<Select
value={params.plan ?? ALL}
onValueChange={(val) =>
+2 -2
View File
@@ -62,7 +62,7 @@ const UserDetailPage: React.FC = () => {
const emDash = t('common.emDash');
return (
<div>
<div data-testid="page-user-detail">
<PageHeader
title={String(titleName)}
description={`ID ${user.id}`}
@@ -71,7 +71,7 @@ const UserDetailPage: React.FC = () => {
{ label: String(titleName) },
]}
actions={
<Button variant="outline" onClick={() => navigate('/users')}>
<Button variant="outline" data-testid="btn-back" onClick={() => navigate('/users')}>
<ArrowLeft className="h-4 w-4" />
{t('common.backToList')}
</Button>
+3 -1
View File
@@ -329,12 +329,14 @@ const UserListPage: React.FC = () => {
return (
<>
<ExploreListShell
data-testid="page-users"
title={t('users.title')}
stats={exploreStats}
viewMode={viewMode}
onViewModeChange={setViewMode}
toolbar={
<ExploreSearch
data-testid="filter-users-search"
value={searchDraft}
onChange={setSearchDraft}
onSubmit={applySearch}
@@ -356,7 +358,7 @@ const UserListPage: React.FC = () => {
</ExploreListShell>
<Dialog open={editModal.open} onOpenChange={(open) => !open && setEditModal({ open: false, userId: null })}>
<DialogContent>
<DialogContent data-testid="dialog-edit-user">
<DialogHeader>
<DialogTitle>{t('users.editTitle')}</DialogTitle>
</DialogHeader>