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:
@@ -0,0 +1,57 @@
|
||||
name: E2E IFT smoke (admin)
|
||||
|
||||
# После успешного Deploy IFT — smoke против живого IFT (без API-моков).
|
||||
# Secrets (repo EventHubFrontAdmin): E2E_ADMIN_EMAIL, E2E_ADMIN_PASSWORD;
|
||||
# опционально E2E_IFT_BASE_URL (default https://admin-ui.ift.eventhub.local).
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Deploy IFT (admin)"]
|
||||
types: [completed]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: eventhub-frontadmin-e2e-ift
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
ift-smoke:
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }}
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- run: npm ci
|
||||
|
||||
- name: Install Playwright browser
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: IFT smoke
|
||||
env:
|
||||
E2E_TARGET: ift
|
||||
E2E_ADMIN_EMAIL: ${{ secrets.E2E_ADMIN_EMAIL }}
|
||||
E2E_ADMIN_PASSWORD: ${{ secrets.E2E_ADMIN_PASSWORD }}
|
||||
E2E_IFT_BASE_URL: ${{ secrets.E2E_IFT_BASE_URL }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${E2E_ADMIN_EMAIL:-}" ] || [ -z "${E2E_ADMIN_PASSWORD:-}" ]; then
|
||||
echo "::error::Нужны secrets E2E_ADMIN_EMAIL и E2E_ADMIN_PASSWORD"
|
||||
exit 1
|
||||
fi
|
||||
npm run test:e2e:ift
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: playwright-ift-report
|
||||
path: playwright-report/
|
||||
retention-days: 7
|
||||
@@ -23,3 +23,20 @@ Helper: `src/lib/testid.ts`.
|
||||
- kebab-case; dynamic id = API entity id (or banned word)
|
||||
- Prefer a11y for login fields
|
||||
- Do not decorate decorative wrappers
|
||||
|
||||
## Запуск
|
||||
|
||||
| Команда | Назначение |
|
||||
|---------|------------|
|
||||
| `npm run test:e2e` | mock-suite (CI / PR), API через Playwright route |
|
||||
| `npm run test:e2e:ift` | IFT smoke (живой API), см. ниже |
|
||||
|
||||
### IFT secrets (Gitea → Settings → Secrets)
|
||||
|
||||
| Secret | Обязателен | Назначение |
|
||||
|--------|------------|------------|
|
||||
| `E2E_ADMIN_EMAIL` | да | логин smoke-админа на IFT |
|
||||
| `E2E_ADMIN_PASSWORD` | да | пароль |
|
||||
| `E2E_IFT_BASE_URL` | нет | default `https://admin-ui.ift.eventhub.local` |
|
||||
|
||||
Workflow `e2e-ift.yml` стартует после успешного `Deploy IFT (admin)`.
|
||||
|
||||
@@ -85,8 +85,9 @@ export function createContentSeed() {
|
||||
{
|
||||
id: 'sub-e2e-1',
|
||||
user_id: 'user-e2e-1',
|
||||
plan: 'pro',
|
||||
plan: 'monthly',
|
||||
status: 'active',
|
||||
trial_used: false,
|
||||
started_at: now,
|
||||
expires_at: '2027-01-01T00:00:00Z',
|
||||
auto_renew: true,
|
||||
@@ -96,8 +97,8 @@ export function createContentSeed() {
|
||||
];
|
||||
|
||||
const bannedWords = [
|
||||
{ word: 'spamword', added_by: 'admin-e2e-1', created_at: now },
|
||||
{ word: 'badword', added_by: 'admin-e2e-1', created_at: now },
|
||||
{ word: 'spamword', added_by: 'admin-e2e-1', added_at: now },
|
||||
{ word: 'badword', added_by: 'admin-e2e-1', added_at: now },
|
||||
];
|
||||
|
||||
const admins = [
|
||||
|
||||
@@ -70,7 +70,9 @@ export async function tryHandleContentApi(
|
||||
return true;
|
||||
}
|
||||
if (method === 'PUT') {
|
||||
content.users[idx] = { ...content.users[idx], ...(postData() as object) };
|
||||
const patch = (postData() || {}) as Record<string, unknown>;
|
||||
delete patch.id;
|
||||
content.users[idx] = { ...content.users[idx], ...patch, id };
|
||||
await json(route, 200, content.users[idx]);
|
||||
return true;
|
||||
}
|
||||
@@ -114,7 +116,9 @@ export async function tryHandleContentApi(
|
||||
return true;
|
||||
}
|
||||
if (method === 'PUT') {
|
||||
content.calendars[idx] = { ...content.calendars[idx], ...(postData() as object) };
|
||||
const patch = (postData() || {}) as Record<string, unknown>;
|
||||
delete patch.id;
|
||||
content.calendars[idx] = { ...content.calendars[idx], ...patch, id };
|
||||
await json(route, 200, content.calendars[idx]);
|
||||
return true;
|
||||
}
|
||||
@@ -155,7 +159,9 @@ export async function tryHandleContentApi(
|
||||
return true;
|
||||
}
|
||||
if (method === 'PUT') {
|
||||
content.events[idx] = { ...content.events[idx], ...(postData() as object) };
|
||||
const patch = (postData() || {}) as Record<string, unknown>;
|
||||
delete patch.id;
|
||||
content.events[idx] = { ...content.events[idx], ...patch, id };
|
||||
await json(route, 200, content.events[idx]);
|
||||
return true;
|
||||
}
|
||||
@@ -197,7 +203,9 @@ export async function tryHandleContentApi(
|
||||
return true;
|
||||
}
|
||||
if (method === 'PUT') {
|
||||
content.subscriptions[idx] = { ...content.subscriptions[idx], ...(postData() as object) };
|
||||
const patch = (postData() || {}) as Record<string, unknown>;
|
||||
delete patch.id;
|
||||
content.subscriptions[idx] = { ...content.subscriptions[idx], ...patch, id };
|
||||
await json(route, 200, content.subscriptions[idx]);
|
||||
return true;
|
||||
}
|
||||
@@ -221,7 +229,7 @@ export async function tryHandleContentApi(
|
||||
if (method === 'POST' && path.endsWith('/v1/admin/banned-words')) {
|
||||
const payload = postData() as { word?: string };
|
||||
const word = (payload.word || '').trim();
|
||||
if (word) content.bannedWords.push({ word, added_by: 'admin-e2e-1', created_at: '2026-07-14T12:00:00Z' });
|
||||
if (word) content.bannedWords.push({ word, added_by: 'admin-e2e-1', added_at: '2026-07-14T12:00:00Z' });
|
||||
await json(route, 200, { ok: true });
|
||||
return true;
|
||||
}
|
||||
@@ -293,19 +301,30 @@ export async function tryHandleContentApi(
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---- monitoring enrichment ----
|
||||
// ---- monitoring ----
|
||||
if (method === 'GET' && path.includes('/v1/admin/nodes/metrics')) {
|
||||
await json(route, 200, {
|
||||
nodes: [
|
||||
{
|
||||
node: 'node-a',
|
||||
cpu: 12,
|
||||
memory_used_mb: 512,
|
||||
memory_total_mb: 2048,
|
||||
ts: '2026-07-14T12:00:00Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
await json(route, 200, [
|
||||
{
|
||||
active_sessions: 3,
|
||||
io_in: 10,
|
||||
io_out: 20,
|
||||
memory_atom: 1,
|
||||
memory_binary: 2,
|
||||
memory_ets: 3,
|
||||
memory_processes: 4,
|
||||
memory_total: 2048,
|
||||
mnesia_commits: 1,
|
||||
mnesia_failures: 0,
|
||||
node: 'node-a',
|
||||
process_count: 100,
|
||||
run_queue: 0,
|
||||
table_sizes: { users: 10 },
|
||||
timestamp: '2026-07-14T12:00:00Z',
|
||||
ws_connections: 5,
|
||||
cpu_utilization: 12,
|
||||
memory_available: 1024,
|
||||
},
|
||||
]);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,42 +1,101 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
import { seedAuthenticatedSession } from '../helpers/session';
|
||||
|
||||
async function openRowAction(page: Page, id: string, label: RegExp) {
|
||||
await page.getByTestId(`row-actions-${id}`).click();
|
||||
await page.getByRole('menuitem', { name: label }).click();
|
||||
}
|
||||
|
||||
/** Radix Select in edit dialogs — ensure option chosen so Save enables. */
|
||||
async function pickDialogOption(page: Page, dialogTestId: string, comboboxIndex: number, option: string) {
|
||||
const dialog = page.getByTestId(dialogTestId);
|
||||
await dialog.getByRole('combobox').nth(comboboxIndex).click();
|
||||
await page.getByRole('option', { name: option, exact: true }).click();
|
||||
}
|
||||
|
||||
test.describe('content explore (mock)', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await seedAuthenticatedSession(page);
|
||||
});
|
||||
|
||||
test('users: список, поиск, detail', async ({ page }) => {
|
||||
test('users: список, поиск, detail, edit, delete', async ({ page }) => {
|
||||
await page.goto('/users');
|
||||
await expect(page.getByTestId('page-users')).toBeVisible();
|
||||
await page.getByTestId('view-rows').click();
|
||||
await expect(page.getByTestId('row-user-e2e-1')).toBeVisible();
|
||||
await page.getByTestId('filter-users-search-input').fill('Alice');
|
||||
await page.getByTestId('filter-users-search').locator('button[type="submit"]').click();
|
||||
await expect(page.getByTestId('row-user-e2e-1')).toBeVisible();
|
||||
|
||||
await openRowAction(page, 'user-e2e-1', /редакт|edit/i);
|
||||
await expect(page.getByTestId('dialog-edit-user')).toBeVisible();
|
||||
await page.getByLabel(/ник|nickname/i).fill('Alice E2E');
|
||||
await pickDialogOption(page, 'dialog-edit-user', 0, 'user');
|
||||
await pickDialogOption(page, 'dialog-edit-user', 1, 'active');
|
||||
await expect(page.getByTestId('btn-save')).toBeEnabled();
|
||||
await page.getByTestId('btn-save').click();
|
||||
await expect(page.getByTestId('dialog-edit-user')).toHaveCount(0);
|
||||
|
||||
await page.goto('/users/user-e2e-1');
|
||||
await expect(page.getByTestId('page-user-detail')).toBeVisible();
|
||||
|
||||
await page.goto('/users');
|
||||
await page.getByTestId('view-rows').click();
|
||||
await openRowAction(page, 'user-e2e-2', /удал|delete/i);
|
||||
await expect(page.getByTestId('dialog-delete-user')).toBeVisible();
|
||||
await page.getByTestId('btn-delete-confirm').click();
|
||||
await expect(page.getByTestId('row-user-e2e-2')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('calendars: список и detail', async ({ page }) => {
|
||||
test('calendars: edit и detail', async ({ page }) => {
|
||||
await page.goto('/calendars');
|
||||
await expect(page.getByTestId('page-calendars')).toBeVisible();
|
||||
await page.getByTestId('view-rows').click();
|
||||
await expect(page.getByTestId('row-cal-e2e-1')).toBeVisible();
|
||||
await openRowAction(page, 'cal-e2e-1', /редакт|edit/i);
|
||||
await expect(page.getByTestId('dialog-edit-calendar')).toBeVisible();
|
||||
await page.getByTestId('dialog-edit-calendar').locator('input').first().fill('E2E Calendar');
|
||||
await pickDialogOption(page, 'dialog-edit-calendar', 0, 'personal');
|
||||
await pickDialogOption(page, 'dialog-edit-calendar', 1, 'active');
|
||||
await expect(page.getByTestId('btn-save')).toBeEnabled();
|
||||
await page.getByTestId('btn-save').click();
|
||||
await expect(page.getByTestId('dialog-edit-calendar')).toHaveCount(0);
|
||||
await page.goto('/calendars/cal-e2e-1');
|
||||
await expect(page.getByTestId('page-calendar-detail')).toBeVisible();
|
||||
});
|
||||
|
||||
test('events: список и detail', async ({ page }) => {
|
||||
test('events: edit и detail', async ({ page }) => {
|
||||
await page.goto('/events');
|
||||
await expect(page.getByTestId('page-events')).toBeVisible();
|
||||
await page.getByTestId('view-rows').click();
|
||||
await expect(page.getByTestId('row-evt-e2e-1')).toBeVisible();
|
||||
await openRowAction(page, 'evt-e2e-1', /редакт|edit/i);
|
||||
await expect(page.getByTestId('dialog-edit-event')).toBeVisible();
|
||||
await page.getByTestId('dialog-edit-event').locator('input').first().fill('E2E Workout');
|
||||
await pickDialogOption(page, 'dialog-edit-event', 0, 'single');
|
||||
await pickDialogOption(page, 'dialog-edit-event', 1, 'active');
|
||||
await expect(page.getByTestId('btn-save')).toBeEnabled();
|
||||
// Tall dialog: footer can sit outside viewport; submit via form API.
|
||||
await page.getByTestId('dialog-edit-event').locator('form').evaluate((form) => {
|
||||
(form as HTMLFormElement).requestSubmit();
|
||||
});
|
||||
await expect(page.getByTestId('dialog-edit-event')).toHaveCount(0);
|
||||
await page.goto('/events/evt-e2e-1');
|
||||
await expect(page.getByTestId('page-event-detail')).toBeVisible();
|
||||
});
|
||||
|
||||
test('subscriptions: список', async ({ page }) => {
|
||||
test('subscriptions: edit и delete', async ({ page }) => {
|
||||
await page.goto('/subscriptions');
|
||||
await expect(page.getByTestId('page-subscriptions')).toBeVisible();
|
||||
await page.getByTestId('view-rows').click();
|
||||
await expect(page.getByTestId('row-sub-e2e-1')).toBeVisible();
|
||||
await openRowAction(page, 'sub-e2e-1', /редакт|edit/i);
|
||||
await expect(page.getByTestId('dialog-edit-subscription')).toBeVisible();
|
||||
await pickDialogOption(page, 'dialog-edit-subscription', 0, 'monthly');
|
||||
await pickDialogOption(page, 'dialog-edit-subscription', 1, 'active');
|
||||
await page.getByTestId('btn-save').click();
|
||||
await expect(page.getByTestId('dialog-edit-subscription')).toHaveCount(0);
|
||||
await openRowAction(page, 'sub-e2e-1', /удал|delete/i);
|
||||
await expect(page.getByTestId('dialog-delete-subscription')).toBeVisible();
|
||||
await page.getByTestId('btn-delete-confirm').click();
|
||||
await expect(page.getByTestId('row-sub-e2e-1')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('banned-words: список и add', async ({ page }) => {
|
||||
|
||||
@@ -75,10 +75,16 @@ test.describe('reviews explore (mock)', () => {
|
||||
await seedAuthenticatedSession(page);
|
||||
});
|
||||
|
||||
test('страница reviews рендерится, bulk отключён без выбора', async ({ page }) => {
|
||||
test('страница reviews и bulk hide', async ({ page }) => {
|
||||
await page.goto('/reviews');
|
||||
await expect(page.getByTestId('page-reviews')).toBeVisible();
|
||||
await page.getByTestId('view-table').click();
|
||||
await expect(page.getByText('Great event')).toBeVisible();
|
||||
await expect(page.getByTestId('btn-reviews-bulk-hidden')).toBeDisabled();
|
||||
await page.getByTestId('review-check-rev-vis-1').check();
|
||||
await page.getByTestId('btn-reviews-bulk-hidden').click();
|
||||
await expect(page.getByTestId('dialog-reviews-bulk-status')).toBeVisible();
|
||||
await page.getByTestId('input-bulk-reason').fill('e2e hide');
|
||||
await page.getByTestId('btn-save').click();
|
||||
await expect(page.getByTestId('dialog-reviews-bulk-status')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,11 +24,12 @@ test.describe('system / overview (mock)', () => {
|
||||
await expect(page.getByTestId('row-audit-e2e-1')).toBeVisible();
|
||||
});
|
||||
|
||||
test('monitoring: страница и range', async ({ page }) => {
|
||||
test('monitoring: страница, range и node', async ({ page }) => {
|
||||
await page.goto('/monitoring');
|
||||
await expect(page.getByTestId('page-monitoring')).toBeVisible();
|
||||
await page.getByTestId('filter-monitoring-range-30').click();
|
||||
await expect(page.getByTestId('filter-monitoring-range-30')).toBeVisible();
|
||||
await page.getByTestId('filter-monitoring-node-node-a').click();
|
||||
await expect(page).toHaveURL(/node=node-a/);
|
||||
});
|
||||
|
||||
test('profile: view и edit', async ({ page }) => {
|
||||
@@ -47,13 +48,15 @@ test.describe('system / overview (mock)', () => {
|
||||
});
|
||||
|
||||
test.describe('i18n (mock)', () => {
|
||||
test('переключение языка через меню', async ({ page }) => {
|
||||
test('переключение языка через меню меняет UI', async ({ page }) => {
|
||||
await seedAuthenticatedSession(page);
|
||||
await page.goto('/dashboard');
|
||||
await page.getByTestId('menu-user').click();
|
||||
await expect(page.getByTestId('menu-profile')).toHaveText(/Мой профиль/i);
|
||||
await page.getByTestId('lang-en').click();
|
||||
// profile language mutation returns user with language=en; menu stays open or reopens
|
||||
await page.getByTestId('menu-user').click().catch(() => undefined);
|
||||
await expect(page.getByTestId('lang-en')).toBeVisible();
|
||||
// Dropdown may close; reopen after store + i18n sync from PUT /me
|
||||
await expect(page.getByText(/Профиль обновлён|Profile updated/i)).toBeVisible({ timeout: 15_000 });
|
||||
await page.getByTestId('menu-user').click();
|
||||
await expect(page.getByTestId('menu-profile')).toHaveText(/My profile/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user