feat(admin): E2E CRUD/i18n polish, IFT smoke workflow, fix edit payload wiping id. Refs EventHub/EventHubFrontAdmin#33 #37 #38 #39

This commit is contained in:
2026-07-14 23:34:31 +03:00
parent 836e4a7eea
commit 607209b35f
16 changed files with 345 additions and 128 deletions
+4 -1
View File
@@ -92,7 +92,10 @@ export function DenseRowList<TParams extends ServerTableParams>({
</button>
{item.actions && item.actions.length > 0 && (
<div className="shrink-0 pt-0.5" onClick={(e) => e.stopPropagation()}>
<RowActionsMenu actions={item.actions} />
<RowActionsMenu
actions={item.actions}
data-testid={`row-actions-${item.id}`}
/>
</div>
)}
</div>
@@ -20,6 +20,7 @@ export function ExploreViewToggle({ value, onChange, className }: ExploreViewTog
>
<Button
type="button"
data-testid="view-table"
variant={value === 'table' ? 'secondary' : 'ghost'}
size="sm"
className="h-8 gap-1.5 px-2.5"
@@ -31,6 +32,7 @@ export function ExploreViewToggle({ value, onChange, className }: ExploreViewTog
</Button>
<Button
type="button"
data-testid="view-rows"
variant={value === 'rows' ? 'secondary' : 'ghost'}
size="sm"
className="h-8 gap-1.5 px-2.5"
+11 -3
View File
@@ -23,9 +23,10 @@ export interface RowAction {
interface RowActionsMenuProps {
actions: RowAction[];
label?: string;
'data-testid'?: string;
}
export function RowActionsMenu({ actions, label }: RowActionsMenuProps) {
export function RowActionsMenu({ actions, label, 'data-testid': dataTestId }: RowActionsMenuProps) {
const { t } = useTranslation();
const menuLabel = label ?? t('common.actions');
if (actions.length === 0) return null;
@@ -33,11 +34,18 @@ export function RowActionsMenu({ actions, label }: RowActionsMenuProps) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label={menuLabel} title={menuLabel}>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
aria-label={menuLabel}
title={menuLabel}
data-testid={dataTestId}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuContent align="end" className="w-48" data-testid={dataTestId ? `${dataTestId}-menu` : undefined}>
{actions.map((action) => (
<Fragment key={action.label}>
{action.separatorBefore && <DropdownMenuSeparator />}
+30 -21
View File
@@ -118,8 +118,8 @@ const CalendarListPage: React.FC = () => {
const original = originalCalendarRef.current;
const cleanedValues: Record<string, unknown> = {};
const allKeys = new Set([...Object.keys(values), ...Object.keys(original)]);
allKeys.forEach((key) => {
// Only form fields — do not null-out entity keys absent from the form (e.g. id, owner_id).
Object.keys(values).forEach((key) => {
const newVal = (values as Record<string, unknown>)[key];
if (newVal === '' || newVal === undefined || newVal === null) {
const origVal = (original as unknown as Record<string, unknown>)[key];
@@ -146,19 +146,18 @@ const CalendarListPage: React.FC = () => {
});
useEffect(() => {
if (editingCalendar && editModal.open) {
originalCalendarRef.current = editingCalendar;
const clean = (val: unknown) => (val === '-' || val === 'undefined' ? '' : val);
editForm.reset({
title: clean(editingCalendar.title) as string,
description: clean(editingCalendar.description) as string,
type: clean(editingCalendar.type) as string,
status: clean(editingCalendar.status) as string,
category: clean(editingCalendar.category) as string,
reason: clean(editingCalendar.reason) as string,
});
}
}, [editingCalendar, editModal.open, editForm]);
if (!editModal.open || loadingCalendar || !editingCalendar) return;
originalCalendarRef.current = editingCalendar;
const clean = (val: unknown) => (val === '-' || val === 'undefined' ? '' : val);
editForm.reset({
title: clean(editingCalendar.title) as string,
description: clean(editingCalendar.description) as string,
type: clean(editingCalendar.type) as string,
status: clean(editingCalendar.status) as string,
category: clean(editingCalendar.category) as string,
reason: clean(editingCalendar.reason) as string,
});
}, [editingCalendar, editModal.open, loadingCalendar, editForm]);
const handleDelete = (id: string) => setDeleteConfirm({ open: true, calendarId: id });
@@ -399,7 +398,7 @@ const CalendarListPage: React.FC = () => {
</ExploreListShell>
<Dialog open={editModal.open} onOpenChange={(open) => !open && setEditModal({ open: false, calendarId: null })}>
<DialogContent className="max-w-lg">
<DialogContent className="max-w-lg" data-testid="dialog-edit-calendar">
<DialogHeader>
<DialogTitle>{t('calendars.editTitle')}</DialogTitle>
</DialogHeader>
@@ -424,7 +423,7 @@ const CalendarListPage: React.FC = () => {
name="type"
control={editForm.control}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<Select value={field.value || undefined} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder={t('common.selectType')} />
</SelectTrigger>
@@ -443,7 +442,7 @@ const CalendarListPage: React.FC = () => {
control={editForm.control}
rules={{ required: true }}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<Select value={field.value || undefined} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder={t('common.selectStatus')} />
</SelectTrigger>
@@ -470,7 +469,12 @@ const CalendarListPage: React.FC = () => {
<Button variant="outline" onClick={() => setEditModal({ open: false, calendarId: null })}>
{t('common.cancel')}
</Button>
<Button type="submit" form="edit-calendar-form" disabled={editHasErrors || updateCalendar.isPending}>
<Button
type="submit"
form="edit-calendar-form"
data-testid="btn-save"
disabled={editHasErrors || updateCalendar.isPending}
>
{t('common.save')}
</Button>
</DialogFooter>
@@ -478,7 +482,7 @@ const CalendarListPage: React.FC = () => {
</Dialog>
<Dialog open={deleteConfirm.open} onOpenChange={(open) => !open && setDeleteConfirm({ open: false, calendarId: null })}>
<DialogContent>
<DialogContent data-testid="dialog-delete-calendar">
<DialogHeader>
<DialogTitle>{t('calendars.deleteTitle')}</DialogTitle>
</DialogHeader>
@@ -487,7 +491,12 @@ const CalendarListPage: React.FC = () => {
<Button variant="outline" onClick={() => setDeleteConfirm({ open: false, calendarId: null })}>
{t('common.cancel')}
</Button>
<Button variant="destructive" onClick={confirmDelete} disabled={deleteCalendar.isPending}>
<Button
variant="destructive"
data-testid="btn-delete-confirm"
onClick={confirmDelete}
disabled={deleteCalendar.isPending}
>
{t('common.delete')}
</Button>
</DialogFooter>
+35 -26
View File
@@ -165,8 +165,8 @@ const EventListPage: React.FC = () => {
capacity: values.capacity === '' || values.capacity === undefined ? undefined : Number(values.capacity),
};
const allKeys = new Set([...Object.keys(processedValues), ...Object.keys(original)]);
allKeys.forEach((key) => {
// Only form fields — do not null-out entity keys absent from the form (e.g. id).
Object.keys(processedValues).forEach((key) => {
const newVal = processedValues[key];
if (newVal === '' || newVal === undefined || newVal === null) {
const origVal = (original as unknown as Record<string, unknown>)[key];
@@ -193,24 +193,23 @@ const EventListPage: React.FC = () => {
});
useEffect(() => {
if (editingEvent && editModal.open) {
originalEventRef.current = editingEvent;
const clean = (val: unknown) => (val === '-' || val === 'undefined' ? '' : val);
editForm.reset({
title: clean(editingEvent.title) as string,
description: clean(editingEvent.description) as string,
event_type: clean(editingEvent.event_type) as string,
status: clean(editingEvent.status) as string,
start_time: isoToDatetimeLocal(editingEvent.start_time),
duration: editingEvent.duration,
capacity: editingEvent.capacity ?? undefined,
specialist_id: clean(editingEvent.specialist_id) as string,
calendar_id: clean(editingEvent.calendar_id) as string,
online_link: clean(editingEvent.online_link) as string,
tags: (editingEvent.tags || []).join(', '),
});
}
}, [editingEvent, editModal.open, editForm]);
if (!editModal.open || loadingEvent || !editingEvent) return;
originalEventRef.current = editingEvent;
const clean = (val: unknown) => (val === '-' || val === 'undefined' ? '' : val);
editForm.reset({
title: clean(editingEvent.title) as string,
description: clean(editingEvent.description) as string,
event_type: clean(editingEvent.event_type) as string,
status: clean(editingEvent.status) as string,
start_time: isoToDatetimeLocal(editingEvent.start_time),
duration: editingEvent.duration,
capacity: editingEvent.capacity ?? undefined,
specialist_id: clean(editingEvent.specialist_id) as string,
calendar_id: clean(editingEvent.calendar_id) as string,
online_link: clean(editingEvent.online_link) as string,
tags: (editingEvent.tags || []).join(', '),
});
}, [editingEvent, editModal.open, loadingEvent, editForm]);
const handleDelete = (id: string) => setDeleteConfirm({ open: true, eventId: id });
@@ -473,7 +472,7 @@ const EventListPage: React.FC = () => {
</ExploreListShell>
<Dialog open={editModal.open} onOpenChange={(open) => !open && setEditModal({ open: false, eventId: null })}>
<DialogContent className="max-w-lg">
<DialogContent className="max-w-lg" data-testid="dialog-edit-event">
<DialogHeader>
<DialogTitle>{t('events.editTitle')}</DialogTitle>
</DialogHeader>
@@ -499,7 +498,7 @@ const EventListPage: React.FC = () => {
name="event_type"
control={editForm.control}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<Select value={field.value || undefined} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder={t('common.selectType')} />
</SelectTrigger>
@@ -518,7 +517,7 @@ const EventListPage: React.FC = () => {
control={editForm.control}
rules={{ required: true }}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<Select value={field.value || undefined} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder={t('common.selectStatus')} />
</SelectTrigger>
@@ -567,7 +566,12 @@ const EventListPage: React.FC = () => {
<Button variant="outline" onClick={() => setEditModal({ open: false, eventId: null })}>
{t('common.cancel')}
</Button>
<Button type="submit" form="edit-event-form" disabled={editHasErrors || updateEvent.isPending}>
<Button
type="submit"
form="edit-event-form"
data-testid="btn-save"
disabled={editHasErrors || updateEvent.isPending}
>
{t('common.save')}
</Button>
</DialogFooter>
@@ -575,7 +579,7 @@ const EventListPage: React.FC = () => {
</Dialog>
<Dialog open={deleteConfirm.open} onOpenChange={(open) => !open && setDeleteConfirm({ open: false, eventId: null })}>
<DialogContent>
<DialogContent data-testid="dialog-delete-event">
<DialogHeader>
<DialogTitle>{t('events.deleteTitle')}</DialogTitle>
</DialogHeader>
@@ -584,7 +588,12 @@ const EventListPage: React.FC = () => {
<Button variant="outline" onClick={() => setDeleteConfirm({ open: false, eventId: null })}>
{t('common.cancel')}
</Button>
<Button variant="destructive" onClick={confirmDelete} disabled={deleteEvent.isPending}>
<Button
variant="destructive"
data-testid="btn-delete-confirm"
onClick={confirmDelete}
disabled={deleteEvent.isPending}
>
{t('common.delete')}
</Button>
</DialogFooter>
+1
View File
@@ -365,6 +365,7 @@ const MonitoringPage: React.FC = () => {
<button
key={node}
type="button"
data-testid={`filter-monitoring-node-${node}`}
onClick={() => selectNode(node)}
className={cn(
'inline-flex items-center gap-2 rounded-full border px-3 py-1 text-sm transition-colors',
+9 -3
View File
@@ -248,6 +248,7 @@ const ReviewListPage: React.FC = () => {
cell: ({ row }) => (
<input
type="checkbox"
data-testid={`review-check-${row.original.id}`}
className="h-4 w-4 rounded border"
checked={selectedRowKeys.includes(row.original.id)}
onChange={() => toggleRow(row.original.id)}
@@ -530,7 +531,7 @@ const ReviewListPage: React.FC = () => {
open={bulkStatusModal.open}
onOpenChange={(open) => !open && setBulkStatusModal({ open: false, status: 'hidden' })}
>
<DialogContent>
<DialogContent data-testid="dialog-reviews-bulk-status">
<DialogHeader>
<DialogTitle>
{t('reviews.bulkStatusTitle', {
@@ -543,6 +544,7 @@ const ReviewListPage: React.FC = () => {
<div className="space-y-2">
<Label>{t('common.reason')}</Label>
<Textarea
data-testid="input-bulk-reason"
rows={3}
placeholder={t('reviews.bulkReasonPlaceholder')}
{...bulkStatusForm.register('reason')}
@@ -561,8 +563,12 @@ const ReviewListPage: React.FC = () => {
>
{t('common.cancel')}
</Button>
<Button type="submit" form="bulk-status-form" disabled={bulkUpdate.isPending}>
{t('common.apply')}
<Button
type="submit"
form="bulk-status-form"
data-testid="btn-save"
disabled={bulkUpdate.isPending}
> {t('common.apply')}
</Button>
</DialogFooter>
</DialogContent>
@@ -141,15 +141,14 @@ const SubscriptionListPage: React.FC = () => {
});
useEffect(() => {
if (editingSubscription && editModal.open) {
form.reset({
plan: editingSubscription.plan,
status: editingSubscription.status,
trial_used: editingSubscription.trial_used,
expires_at: toDatetimeLocal(editingSubscription.expires_at),
});
}
}, [editingSubscription, editModal.open, form]);
if (!editModal.open || loadingSubscription || !editingSubscription) return;
form.reset({
plan: editingSubscription.plan,
status: editingSubscription.status,
trial_used: editingSubscription.trial_used,
expires_at: toDatetimeLocal(editingSubscription.expires_at),
});
}, [editingSubscription, editModal.open, loadingSubscription, form]);
const handleEdit = (sub: Subscription) => setEditModal({ open: true, subscriptionId: sub.id });
@@ -364,7 +363,7 @@ const SubscriptionListPage: React.FC = () => {
open={editModal.open}
onOpenChange={(open) => !open && setEditModal({ open: false, subscriptionId: null })}
>
<DialogContent>
<DialogContent data-testid="dialog-edit-subscription">
<DialogHeader>
<DialogTitle>{t('subscriptions.editTitle')}</DialogTitle>
</DialogHeader>
@@ -382,7 +381,7 @@ const SubscriptionListPage: React.FC = () => {
control={form.control}
rules={{ required: true }}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<Select value={field.value || undefined} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder={t('common.selectPlan')} />
</SelectTrigger>
@@ -404,7 +403,7 @@ const SubscriptionListPage: React.FC = () => {
control={form.control}
rules={{ required: true }}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<Select value={field.value || undefined} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder={t('common.selectStatus')} />
</SelectTrigger>
@@ -448,8 +447,12 @@ const SubscriptionListPage: React.FC = () => {
<Button variant="outline" onClick={() => setEditModal({ open: false, subscriptionId: null })}>
{t('common.cancel')}
</Button>
<Button type="submit" form="edit-subscription-form" disabled={updateSubscription.isPending}>
{t('common.save')}
<Button
type="submit"
form="edit-subscription-form"
data-testid="btn-save"
disabled={updateSubscription.isPending}
> {t('common.save')}
</Button>
</DialogFooter>
</DialogContent>
@@ -459,7 +462,7 @@ const SubscriptionListPage: React.FC = () => {
open={deleteConfirm.open}
onOpenChange={(open) => !open && setDeleteConfirm({ open: false, subscriptionId: null })}
>
<DialogContent>
<DialogContent data-testid="dialog-delete-subscription">
<DialogHeader>
<DialogTitle>{t('subscriptions.deleteTitle')}</DialogTitle>
</DialogHeader>
@@ -468,7 +471,12 @@ const SubscriptionListPage: React.FC = () => {
<Button variant="outline" onClick={() => setDeleteConfirm({ open: false, subscriptionId: null })}>
{t('common.cancel')}
</Button>
<Button variant="destructive" onClick={confirmDelete} disabled={deleteSubscription.isPending}>
<Button
variant="destructive"
data-testid="btn-delete-confirm"
onClick={confirmDelete}
disabled={deleteSubscription.isPending}
>
{t('common.delete')}
</Button>
</DialogFooter>
+31 -22
View File
@@ -139,8 +139,8 @@ const UserListPage: React.FC = () => {
const original = originalUserRef.current;
const cleanedValues: Record<string, unknown> = {};
const allKeys = new Set([...Object.keys(values), ...Object.keys(original)]);
allKeys.forEach((key) => {
// Only form fields — do not null-out entity keys absent from the form (e.g. id, timestamps).
Object.keys(values).forEach((key) => {
const newVal = (values as Record<string, unknown>)[key];
if (newVal === '' || newVal === undefined || newVal === null) {
const origVal = (original as unknown as Record<string, unknown>)[key];
@@ -167,18 +167,17 @@ const UserListPage: React.FC = () => {
});
useEffect(() => {
if (editingUser && editModal.open) {
originalUserRef.current = editingUser;
const clean = (val: unknown) => (val === '-' || val === 'undefined' ? '' : val);
editForm.reset({
email: clean(editingUser.email) as string,
nickname: clean(editingUser.nickname) as string,
role: clean(editingUser.role) as string,
status: clean(editingUser.status) as string,
reason: clean(editingUser.reason) as string,
});
}
}, [editingUser, editModal.open, editForm]);
if (!editModal.open || loadingUser || !editingUser) return;
originalUserRef.current = editingUser;
const clean = (val: unknown) => (val === '-' || val === 'undefined' ? '' : val);
editForm.reset({
email: clean(editingUser.email) as string,
nickname: clean(editingUser.nickname) as string,
role: clean(editingUser.role) as string,
status: clean(editingUser.status) as string,
reason: clean(editingUser.reason) as string,
});
}, [editingUser, editModal.open, loadingUser, editForm]);
const handleStatusChange = (user: User) => {
if (user.status === 'deleted') return;
@@ -374,8 +373,8 @@ const UserListPage: React.FC = () => {
<Input {...editForm.register('email')} />
</div>
<div className="space-y-2">
<Label>{t('common.nickname')}</Label>
<Input {...editForm.register('nickname')} />
<Label htmlFor="edit-user-nickname">{t('common.nickname')}</Label>
<Input id="edit-user-nickname" {...editForm.register('nickname')} />
</div>
<div className="space-y-2">
<Label>{t('common.role')}</Label>
@@ -384,7 +383,7 @@ const UserListPage: React.FC = () => {
control={editForm.control}
rules={{ required: true }}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<Select value={field.value || undefined} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder={t('common.selectRole')} />
</SelectTrigger>
@@ -403,7 +402,7 @@ const UserListPage: React.FC = () => {
control={editForm.control}
rules={{ required: true }}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<Select value={field.value || undefined} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder={t('common.selectStatus')} />
</SelectTrigger>
@@ -426,7 +425,12 @@ const UserListPage: React.FC = () => {
<Button variant="outline" onClick={() => setEditModal({ open: false, userId: null })}>
{t('common.cancel')}
</Button>
<Button type="submit" form="edit-user-form" disabled={editHasErrors || updateUser.isPending}>
<Button
type="submit"
form="edit-user-form"
data-testid="btn-save"
disabled={editHasErrors || updateUser.isPending}
>
{t('common.save')}
</Button>
</DialogFooter>
@@ -439,7 +443,7 @@ const UserListPage: React.FC = () => {
!open && setStatusModal({ open: false, userId: null, newStatus: 'active', currentReason: '' })
}
>
<DialogContent>
<DialogContent data-testid="dialog-user-status">
<DialogHeader>
<DialogTitle>{t('users.statusTitle', { status: statusModal.newStatus })}</DialogTitle>
</DialogHeader>
@@ -470,7 +474,7 @@ const UserListPage: React.FC = () => {
</Dialog>
<Dialog open={deleteConfirm.open} onOpenChange={(open) => !open && setDeleteConfirm({ open: false, userId: null })}>
<DialogContent>
<DialogContent data-testid="dialog-delete-user">
<DialogHeader>
<DialogTitle>{t('users.deleteTitle')}</DialogTitle>
</DialogHeader>
@@ -479,7 +483,12 @@ const UserListPage: React.FC = () => {
<Button variant="outline" onClick={() => setDeleteConfirm({ open: false, userId: null })}>
{t('common.cancel')}
</Button>
<Button variant="destructive" onClick={confirmDelete} disabled={deleteUser.isPending}>
<Button
variant="destructive"
data-testid="btn-delete-confirm"
onClick={confirmDelete}
disabled={deleteUser.isPending}
>
{t('common.delete')}
</Button>
</DialogFooter>