feat(admin): E2E mock-сьюты inbox/reports/reviews + testids. Refs EventHub/EventHubFrontAdmin#33 #36
This commit is contained in:
+192
-7
@@ -1,4 +1,5 @@
|
|||||||
import type { Page, Route } from '@playwright/test';
|
import type { Page, Route } from '@playwright/test';
|
||||||
|
import { createModerationSeed, type ModerationState } from './moderationSeed';
|
||||||
|
|
||||||
export type MockAdmin = {
|
export type MockAdmin = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -27,13 +28,21 @@ async function json(route: Route, status: number, body: unknown, headers: Record
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function listSlice<T>(items: T[], url: URL): { rows: T[]; total: number } {
|
||||||
|
const limit = Number(url.searchParams.get('limit') || 50);
|
||||||
|
const offset = Number(url.searchParams.get('offset') || 0);
|
||||||
|
return { rows: items.slice(offset, offset + limit), total: items.length };
|
||||||
|
}
|
||||||
|
|
||||||
type MockOptions = {
|
type MockOptions = {
|
||||||
user?: MockAdmin;
|
user?: MockAdmin;
|
||||||
|
moderation?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Default admin API mocks for Control Center shell / login. */
|
/** Default admin API mocks for Control Center shell / login / moderation. */
|
||||||
export async function installAdminApiMocks(page: Page, options: MockOptions = {}) {
|
export async function installAdminApiMocks(page: Page, options: MockOptions = {}) {
|
||||||
let currentUser: MockAdmin = { ...MOCK_ADMIN, ...options.user };
|
let currentUser: MockAdmin = { ...MOCK_ADMIN, ...options.user };
|
||||||
|
const moderation: ModerationState | null = options.moderation === false ? null : createModerationSeed();
|
||||||
|
|
||||||
await page.route('**/v1/admin/**', async (route) => {
|
await page.route('**/v1/admin/**', async (route) => {
|
||||||
const req = route.request();
|
const req = route.request();
|
||||||
@@ -82,13 +91,13 @@ export async function installAdminApiMocks(page: Page, options: MockOptions = {}
|
|||||||
await json(route, 200, {
|
await json(route, 200, {
|
||||||
users_total: 10,
|
users_total: 10,
|
||||||
events_total: 5,
|
events_total: 5,
|
||||||
reviews_total: 2,
|
reviews_total: moderation?.reviews.length ?? 0,
|
||||||
calendars_total: 3,
|
calendars_total: 3,
|
||||||
tickets_total: 1,
|
tickets_total: moderation?.tickets.length ?? 0,
|
||||||
tickets_open: 1,
|
tickets_open: moderation?.tickets.filter((t) => t.status === 'open').length ?? 0,
|
||||||
reports_total: 2,
|
reports_total: moderation?.reports.length ?? 0,
|
||||||
reports_pending: 1,
|
reports_pending: moderation?.reports.filter((r) => r.status === 'pending').length ?? 0,
|
||||||
reports_reviewed: 1,
|
reports_reviewed: moderation?.reports.filter((r) => r.status === 'reviewed').length ?? 0,
|
||||||
avg_report_resolution_h: 1.5,
|
avg_report_resolution_h: 1.5,
|
||||||
avg_ticket_resolution_h: 2,
|
avg_ticket_resolution_h: 2,
|
||||||
events_by_day: [],
|
events_by_day: [],
|
||||||
@@ -106,6 +115,180 @@ export async function installAdminApiMocks(page: Page, options: MockOptions = {}
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (moderation) {
|
||||||
|
// ---- reports ----
|
||||||
|
if (method === 'GET' && path.endsWith('/v1/admin/reports/stats')) {
|
||||||
|
await json(route, 200, {
|
||||||
|
total_reports: moderation.reports.length,
|
||||||
|
reports_by_target_type: {},
|
||||||
|
reports_by_status: {
|
||||||
|
pending: moderation.reports.filter((r) => r.status === 'pending').length,
|
||||||
|
reviewed: moderation.reports.filter((r) => r.status === 'reviewed').length,
|
||||||
|
dismissed: moderation.reports.filter((r) => r.status === 'dismissed').length,
|
||||||
|
},
|
||||||
|
top_targets_by_reports: [],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'GET' && path.endsWith('/v1/admin/reports')) {
|
||||||
|
let rows = [...moderation.reports];
|
||||||
|
const status = url.searchParams.get('status');
|
||||||
|
if (status) rows = rows.filter((r) => r.status === status);
|
||||||
|
const pageRows = listSlice(rows, url);
|
||||||
|
await json(route, 200, pageRows.rows, { 'X-Total-Count': String(pageRows.total) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reportMatch = path.match(/\/v1\/admin\/reports\/([^/]+)$/);
|
||||||
|
if (reportMatch) {
|
||||||
|
const id = decodeURIComponent(reportMatch[1]);
|
||||||
|
const idx = moderation.reports.findIndex((r) => r.id === id);
|
||||||
|
if (idx < 0) {
|
||||||
|
await json(route, 404, { error: 'not_found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (method === 'GET') {
|
||||||
|
await json(route, 200, moderation.reports[idx]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (method === 'PUT') {
|
||||||
|
const payload = (req.postDataJSON() || {}) as { status?: MockReportStatus };
|
||||||
|
moderation.reports[idx] = {
|
||||||
|
...moderation.reports[idx],
|
||||||
|
...payload,
|
||||||
|
resolved_at: payload.status ? '2026-07-14T13:00:00Z' : moderation.reports[idx].resolved_at,
|
||||||
|
resolved_by: payload.status ? currentUser.id : moderation.reports[idx].resolved_by,
|
||||||
|
};
|
||||||
|
await json(route, 200, moderation.reports[idx]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- tickets ----
|
||||||
|
if (method === 'GET' && path.endsWith('/v1/admin/tickets/stats')) {
|
||||||
|
await json(route, 200, {
|
||||||
|
total_tickets: moderation.tickets.length,
|
||||||
|
open: moderation.tickets.filter((t) => t.status === 'open').length,
|
||||||
|
in_progress: moderation.tickets.filter((t) => t.status === 'in_progress').length,
|
||||||
|
resolved: moderation.tickets.filter((t) => t.status === 'resolved').length,
|
||||||
|
closed: moderation.tickets.filter((t) => t.status === 'closed').length,
|
||||||
|
total_errors: moderation.tickets.reduce((s, t) => s + t.count, 0),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'GET' && path.endsWith('/v1/admin/tickets')) {
|
||||||
|
const pageRows = listSlice(moderation.tickets, url);
|
||||||
|
await json(route, 200, pageRows.rows, { 'X-Total-Count': String(pageRows.total) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ticketMatch = path.match(/\/v1\/admin\/tickets\/([^/]+)$/);
|
||||||
|
if (ticketMatch) {
|
||||||
|
const id = decodeURIComponent(ticketMatch[1]);
|
||||||
|
const idx = moderation.tickets.findIndex((t) => t.id === id);
|
||||||
|
if (idx < 0) {
|
||||||
|
await json(route, 404, { error: 'not_found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (method === 'GET') {
|
||||||
|
await json(route, 200, moderation.tickets[idx]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (method === 'PUT') {
|
||||||
|
const payload = (req.postDataJSON() || {}) as Partial<(typeof moderation.tickets)[0]>;
|
||||||
|
moderation.tickets[idx] = { ...moderation.tickets[idx], ...payload };
|
||||||
|
await json(route, 200, moderation.tickets[idx]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- reviews ----
|
||||||
|
if (method === 'GET' && path.endsWith('/v1/admin/reviews/stats')) {
|
||||||
|
await json(route, 200, {
|
||||||
|
total_reviews: moderation.reviews.length,
|
||||||
|
reviews_by_target_type: {},
|
||||||
|
reviews_by_status: {
|
||||||
|
visible: moderation.reviews.filter((r) => r.status === 'visible').length,
|
||||||
|
hidden: moderation.reviews.filter((r) => r.status === 'hidden').length,
|
||||||
|
deleted: moderation.reviews.filter((r) => r.status === 'deleted').length,
|
||||||
|
},
|
||||||
|
top_targets_by_reviews: [],
|
||||||
|
top_targets_by_positive_reviews: [],
|
||||||
|
top_targets_by_negative_reviews: [],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'GET' && path.endsWith('/v1/admin/reviews')) {
|
||||||
|
const pageRows = listSlice(moderation.reviews, url);
|
||||||
|
await json(route, 200, pageRows.rows, { 'X-Total-Count': String(pageRows.total) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'PATCH' && path.endsWith('/v1/admin/reviews')) {
|
||||||
|
const updates = (req.postDataJSON() || []) as Array<{ id: string; status: string; reason?: string }>;
|
||||||
|
for (const u of updates) {
|
||||||
|
const idx = moderation.reviews.findIndex((r) => r.id === u.id);
|
||||||
|
if (idx >= 0) {
|
||||||
|
moderation.reviews[idx] = {
|
||||||
|
...moderation.reviews[idx],
|
||||||
|
status: u.status as (typeof moderation.reviews)[0]['status'],
|
||||||
|
reason: u.reason ?? moderation.reviews[idx].reason,
|
||||||
|
updated_at: '2026-07-14T13:00:00Z',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await json(route, 200, updates.length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reviewMatch = path.match(/\/v1\/admin\/reviews\/([^/]+)$/);
|
||||||
|
if (reviewMatch) {
|
||||||
|
const id = decodeURIComponent(reviewMatch[1]);
|
||||||
|
const idx = moderation.reviews.findIndex((r) => r.id === id);
|
||||||
|
if (idx < 0) {
|
||||||
|
await json(route, 404, { error: 'not_found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (method === 'GET') {
|
||||||
|
await json(route, 200, moderation.reviews[idx]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (method === 'PUT') {
|
||||||
|
const payload = (req.postDataJSON() || {}) as Partial<(typeof moderation.reviews)[0]>;
|
||||||
|
moderation.reviews[idx] = {
|
||||||
|
...moderation.reviews[idx],
|
||||||
|
...payload,
|
||||||
|
updated_at: '2026-07-14T13:00:00Z',
|
||||||
|
};
|
||||||
|
await json(route, 200, moderation.reviews[idx]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// soft stubs for linked entities in inbox
|
||||||
|
if (method === 'GET' && /\/v1\/admin\/users\/[^/]+$/.test(path)) {
|
||||||
|
await json(route, 200, {
|
||||||
|
id: 'user-1',
|
||||||
|
email: 'user1@eventhub.local',
|
||||||
|
nickname: 'User One',
|
||||||
|
role: 'user',
|
||||||
|
status: 'active',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (method === 'GET' && /\/v1\/admin\/events\/[^/]+$/.test(path)) {
|
||||||
|
await json(route, 200, {
|
||||||
|
id: 'evt-1',
|
||||||
|
title: 'E2E Event',
|
||||||
|
status: 'active',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (method === 'GET') {
|
if (method === 'GET') {
|
||||||
await json(route, 200, [], { 'X-Total-Count': '0' });
|
await json(route, 200, [], { 'X-Total-Count': '0' });
|
||||||
return;
|
return;
|
||||||
@@ -114,3 +297,5 @@ export async function installAdminApiMocks(page: Page, options: MockOptions = {}
|
|||||||
await json(route, 200, { ok: true });
|
await json(route, 200, { ok: true });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MockReportStatus = 'pending' | 'reviewed' | 'dismissed';
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
/** Stateful moderation fixtures for mock E2E. */
|
||||||
|
|
||||||
|
export type MockReport = {
|
||||||
|
id: string;
|
||||||
|
reporter_id: string;
|
||||||
|
target_type: 'calendar' | 'event' | 'review';
|
||||||
|
target_id: string;
|
||||||
|
reason: string;
|
||||||
|
status: 'pending' | 'reviewed' | 'dismissed';
|
||||||
|
created_at: string;
|
||||||
|
resolved_at: string | null;
|
||||||
|
resolved_by: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MockTicket = {
|
||||||
|
id: string;
|
||||||
|
reporter_id: string;
|
||||||
|
error_hash: string;
|
||||||
|
error_message: string;
|
||||||
|
stacktrace: string;
|
||||||
|
context: string;
|
||||||
|
count: number;
|
||||||
|
first_seen: string;
|
||||||
|
last_seen: string;
|
||||||
|
status: 'open' | 'in_progress' | 'resolved' | 'closed';
|
||||||
|
assigned_to: string | null;
|
||||||
|
resolution_note: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MockReview = {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
target_type: 'calendar' | 'event';
|
||||||
|
target_id: string;
|
||||||
|
rating: number;
|
||||||
|
comment: string;
|
||||||
|
status: 'visible' | 'hidden' | 'deleted';
|
||||||
|
reason: string | null;
|
||||||
|
likes: number;
|
||||||
|
dislikes: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const now = '2026-07-14T12:00:00Z';
|
||||||
|
|
||||||
|
export function createModerationSeed() {
|
||||||
|
const reports: MockReport[] = [
|
||||||
|
{
|
||||||
|
id: 'rpt-pending-1',
|
||||||
|
reporter_id: 'user-1',
|
||||||
|
target_type: 'event',
|
||||||
|
target_id: 'evt-1',
|
||||||
|
reason: 'Spam content',
|
||||||
|
status: 'pending',
|
||||||
|
created_at: now,
|
||||||
|
resolved_at: null,
|
||||||
|
resolved_by: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rpt-pending-2',
|
||||||
|
reporter_id: 'user-2',
|
||||||
|
target_type: 'calendar',
|
||||||
|
target_id: 'cal-1',
|
||||||
|
reason: 'Offensive title',
|
||||||
|
status: 'pending',
|
||||||
|
created_at: now,
|
||||||
|
resolved_at: null,
|
||||||
|
resolved_by: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rpt-done-1',
|
||||||
|
reporter_id: 'user-1',
|
||||||
|
target_type: 'review',
|
||||||
|
target_id: 'rev-1',
|
||||||
|
reason: 'Duplicate',
|
||||||
|
status: 'reviewed',
|
||||||
|
created_at: now,
|
||||||
|
resolved_at: now,
|
||||||
|
resolved_by: 'admin-e2e-1',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const tickets: MockTicket[] = [
|
||||||
|
{
|
||||||
|
id: 'tkt-open-1',
|
||||||
|
reporter_id: 'user-1',
|
||||||
|
error_hash: 'hash-open-1',
|
||||||
|
error_message: 'NullPointer in join',
|
||||||
|
stacktrace: 'at joiner:42',
|
||||||
|
context: '{}',
|
||||||
|
count: 3,
|
||||||
|
first_seen: now,
|
||||||
|
last_seen: now,
|
||||||
|
status: 'open',
|
||||||
|
assigned_to: null,
|
||||||
|
resolution_note: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tkt-prog-1',
|
||||||
|
reporter_id: 'user-2',
|
||||||
|
error_hash: 'hash-prog-1',
|
||||||
|
error_message: 'Timeout on sync',
|
||||||
|
stacktrace: 'at sync:10',
|
||||||
|
context: '{}',
|
||||||
|
count: 1,
|
||||||
|
first_seen: now,
|
||||||
|
last_seen: now,
|
||||||
|
status: 'in_progress',
|
||||||
|
assigned_to: 'admin-e2e-1',
|
||||||
|
resolution_note: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const reviews: MockReview[] = [
|
||||||
|
{
|
||||||
|
id: 'rev-vis-1',
|
||||||
|
user_id: 'user-1',
|
||||||
|
target_type: 'event',
|
||||||
|
target_id: 'evt-1',
|
||||||
|
rating: 5,
|
||||||
|
comment: 'Great event',
|
||||||
|
status: 'visible',
|
||||||
|
reason: null,
|
||||||
|
likes: 2,
|
||||||
|
dislikes: 0,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rev-vis-2',
|
||||||
|
user_id: 'user-2',
|
||||||
|
target_type: 'calendar',
|
||||||
|
target_id: 'cal-1',
|
||||||
|
rating: 1,
|
||||||
|
comment: 'Bad calendar',
|
||||||
|
status: 'visible',
|
||||||
|
reason: null,
|
||||||
|
likes: 0,
|
||||||
|
dislikes: 1,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return { reports, tickets, reviews };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ModerationState = ReturnType<typeof createModerationSeed>;
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { seedAuthenticatedSession } from '../helpers/session';
|
||||||
|
|
||||||
|
test.describe('inbox reports (mock)', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await seedAuthenticatedSession(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('очередь pending, select и reviewed', async ({ page }) => {
|
||||||
|
await page.goto('/inbox/reports');
|
||||||
|
await expect(page.getByTestId('page-inbox-reports')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('report-row-rpt-pending-1')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('report-row-rpt-done-1')).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.getByTestId('report-select-rpt-pending-1').click();
|
||||||
|
await expect(page.getByTestId('inbox-report-detail')).toBeVisible();
|
||||||
|
await page.getByTestId('btn-report-reviewed').click();
|
||||||
|
await expect(page.getByTestId('report-row-rpt-pending-1')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('filter all показывает reviewed', async ({ page }) => {
|
||||||
|
await page.goto('/inbox/reports');
|
||||||
|
await page.getByTestId('filter-all').click();
|
||||||
|
await expect(page.getByTestId('report-row-rpt-done-1')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bulk dismissed', async ({ page }) => {
|
||||||
|
await page.goto('/inbox/reports');
|
||||||
|
await page.getByTestId('report-check-rpt-pending-1').check();
|
||||||
|
await page.getByTestId('report-check-rpt-pending-2').check();
|
||||||
|
await page.getByTestId('btn-bulk-dismissed').click();
|
||||||
|
await expect(page.getByTestId('report-row-rpt-pending-1')).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId('report-row-rpt-pending-2')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('inbox tickets (mock)', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await seedAuthenticatedSession(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('open → in_progress → resolved', async ({ page }) => {
|
||||||
|
await page.goto('/inbox/tickets');
|
||||||
|
await expect(page.getByTestId('page-inbox-tickets')).toBeVisible();
|
||||||
|
await page.getByTestId('ticket-row-tkt-open-1').click();
|
||||||
|
await page.getByTestId('btn-ticket-in-progress').click();
|
||||||
|
await expect(page.getByTestId('btn-ticket-resolved')).toBeVisible();
|
||||||
|
await page.getByTestId('btn-ticket-resolved').click();
|
||||||
|
// resolved leaves active queue
|
||||||
|
await expect(page.getByTestId('ticket-row-tkt-open-1')).toHaveCount(0);
|
||||||
|
await page.getByTestId('filter-all').click();
|
||||||
|
await expect(page.getByTestId('ticket-row-tkt-open-1')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('reports explore (mock)', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await seedAuthenticatedSession(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('список и detail reviewed с confirm', async ({ page }) => {
|
||||||
|
await page.goto('/reports');
|
||||||
|
await expect(page.getByTestId('page-reports')).toBeVisible();
|
||||||
|
await page.goto('/reports/rpt-pending-1');
|
||||||
|
await expect(page.getByTestId('page-report-detail')).toBeVisible();
|
||||||
|
await page.getByTestId('btn-report-reviewed').click();
|
||||||
|
await expect(page.getByTestId('dialog-confirm-report-status')).toBeVisible();
|
||||||
|
await page.getByTestId('btn-confirm-status').click();
|
||||||
|
await expect(page.getByTestId('btn-report-reviewed')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('reviews explore (mock)', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await seedAuthenticatedSession(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('страница reviews рендерится, bulk отключён без выбора', async ({ page }) => {
|
||||||
|
await page.goto('/reviews');
|
||||||
|
await expect(page.getByTestId('page-reviews')).toBeVisible();
|
||||||
|
await expect(page.getByText('Great event')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('btn-reviews-bulk-hidden')).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -28,6 +28,7 @@ interface ExploreListShellProps {
|
|||||||
onViewModeChange: (mode: ExploreViewMode) => void;
|
onViewModeChange: (mode: ExploreViewMode) => void;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
'data-testid'?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ExploreListShell({
|
export function ExploreListShell({
|
||||||
@@ -41,10 +42,11 @@ export function ExploreListShell({
|
|||||||
onViewModeChange,
|
onViewModeChange,
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
|
'data-testid': dataTestId,
|
||||||
}: ExploreListShellProps) {
|
}: ExploreListShellProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div className={cn('space-y-4', className)}>
|
<div className={cn('space-y-4', className)} data-testid={dataTestId}>
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div className="min-w-0 space-y-1">
|
<div className="min-w-0 space-y-1">
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ const ReportInboxPage: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[calc(100vh-7rem)] min-h-[480px] flex-col gap-4">
|
<div data-testid="page-inbox-reports" className="flex h-[calc(100vh-7rem)] min-h-[480px] flex-col gap-4">
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">{t('inbox.reportsTitle')}</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">{t('inbox.reportsTitle')}</h1>
|
||||||
@@ -164,6 +164,7 @@ const ReportInboxPage: React.FC = () => {
|
|||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
|
data-testid="btn-bulk-reviewed"
|
||||||
disabled={bulkPending}
|
disabled={bulkPending}
|
||||||
onClick={() => void handleBulk('reviewed')}
|
onClick={() => void handleBulk('reviewed')}
|
||||||
>
|
>
|
||||||
@@ -172,6 +173,7 @@ const ReportInboxPage: React.FC = () => {
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
data-testid="btn-bulk-dismissed"
|
||||||
disabled={bulkPending}
|
disabled={bulkPending}
|
||||||
onClick={() => void handleBulk('dismissed')}
|
onClick={() => void handleBulk('dismissed')}
|
||||||
>
|
>
|
||||||
@@ -182,6 +184,7 @@ const ReportInboxPage: React.FC = () => {
|
|||||||
<Button
|
<Button
|
||||||
variant={filter === 'pending' ? 'default' : 'outline'}
|
variant={filter === 'pending' ? 'default' : 'outline'}
|
||||||
size="sm"
|
size="sm"
|
||||||
|
data-testid="filter-pending"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setFilter('pending');
|
setFilter('pending');
|
||||||
setCheckedIds(new Set());
|
setCheckedIds(new Set());
|
||||||
@@ -192,6 +195,7 @@ const ReportInboxPage: React.FC = () => {
|
|||||||
<Button
|
<Button
|
||||||
variant={filter === 'all' ? 'default' : 'outline'}
|
variant={filter === 'all' ? 'default' : 'outline'}
|
||||||
size="sm"
|
size="sm"
|
||||||
|
data-testid="filter-all"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setFilter('all');
|
setFilter('all');
|
||||||
setCheckedIds(new Set());
|
setCheckedIds(new Set());
|
||||||
@@ -233,6 +237,7 @@ const ReportInboxPage: React.FC = () => {
|
|||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<div
|
<div
|
||||||
key={item.id}
|
key={item.id}
|
||||||
|
data-testid={`report-row-${item.id}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex w-full items-center gap-2 px-2 py-1.5 transition-colors hover:bg-muted/60',
|
'flex w-full items-center gap-2 px-2 py-1.5 transition-colors hover:bg-muted/60',
|
||||||
selectedId === item.id && 'bg-muted'
|
selectedId === item.id && 'bg-muted'
|
||||||
@@ -240,6 +245,7 @@ const ReportInboxPage: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
data-testid={`report-check-${item.id}`}
|
||||||
className="size-3.5 shrink-0 accent-primary"
|
className="size-3.5 shrink-0 accent-primary"
|
||||||
checked={checkedIds.has(item.id)}
|
checked={checkedIds.has(item.id)}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -251,6 +257,7 @@ const ReportInboxPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
data-testid={`report-select-${item.id}`}
|
||||||
onClick={() => selectReport(item.id)}
|
onClick={() => selectReport(item.id)}
|
||||||
className="flex min-w-0 flex-1 items-center gap-2 text-left"
|
className="flex min-w-0 flex-1 items-center gap-2 text-left"
|
||||||
>
|
>
|
||||||
@@ -284,7 +291,7 @@ const ReportInboxPage: React.FC = () => {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
) : report ? (
|
) : report ? (
|
||||||
<>
|
<>
|
||||||
<CardHeader>
|
<CardHeader data-testid="inbox-report-detail">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<CardTitle className="text-lg">{t('inbox.reportCard', { id: report.id })}</CardTitle>
|
<CardTitle className="text-lg">{t('inbox.reportCard', { id: report.id })}</CardTitle>
|
||||||
<Badge variant={reportBadgeVariant(report.status)}>
|
<Badge variant={reportBadgeVariant(report.status)}>
|
||||||
@@ -346,10 +353,19 @@ const ReportInboxPage: React.FC = () => {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
{report.status === 'pending' && (
|
{report.status === 'pending' && (
|
||||||
<CardFooter className="gap-2 border-t bg-muted/30 p-4">
|
<CardFooter className="gap-2 border-t bg-muted/30 p-4">
|
||||||
<Button onClick={() => handleStatus('reviewed')} disabled={updateReport.isPending}>
|
<Button
|
||||||
|
data-testid="btn-report-reviewed"
|
||||||
|
onClick={() => handleStatus('reviewed')}
|
||||||
|
disabled={updateReport.isPending}
|
||||||
|
>
|
||||||
{t('common.reviewed')} <kbd className="ml-1 rounded border px-1 text-[10px] opacity-70">1</kbd>
|
{t('common.reviewed')} <kbd className="ml-1 rounded border px-1 text-[10px] opacity-70">1</kbd>
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="secondary" onClick={() => handleStatus('dismissed')} disabled={updateReport.isPending}>
|
<Button
|
||||||
|
data-testid="btn-report-dismissed"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => handleStatus('dismissed')}
|
||||||
|
disabled={updateReport.isPending}
|
||||||
|
>
|
||||||
{t('common.dismissed')} <kbd className="ml-1 rounded border px-1 text-[10px] opacity-70">2</kbd>
|
{t('common.dismissed')} <kbd className="ml-1 rounded border px-1 text-[10px] opacity-70">2</kbd>
|
||||||
</Button>
|
</Button>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
|
|||||||
@@ -127,20 +127,35 @@ const TicketInboxPage: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[calc(100vh-7rem)] min-h-[480px] flex-col gap-4">
|
<div data-testid="page-inbox-tickets" className="flex h-[calc(100vh-7rem)] min-h-[480px] flex-col gap-4">
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">{t('inbox.ticketsTitle')}</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">{t('inbox.ticketsTitle')}</h1>
|
||||||
<p className="text-sm text-muted-foreground">{t('inbox.ticketsHints')}</p>
|
<p className="text-sm text-muted-foreground">{t('inbox.ticketsHints')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-auto flex flex-wrap gap-2">
|
<div className="ml-auto flex flex-wrap gap-2">
|
||||||
<Button variant={filter === 'active' ? 'default' : 'outline'} size="sm" onClick={() => setFilter('active')}>
|
<Button
|
||||||
|
variant={filter === 'active' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
data-testid="filter-active"
|
||||||
|
onClick={() => setFilter('active')}
|
||||||
|
>
|
||||||
{t('inbox.active')}
|
{t('inbox.active')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant={filter === 'mine' ? 'default' : 'outline'} size="sm" onClick={() => setFilter('mine')}>
|
<Button
|
||||||
|
variant={filter === 'mine' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
data-testid="filter-mine"
|
||||||
|
onClick={() => setFilter('mine')}
|
||||||
|
>
|
||||||
{t('inbox.mine')}
|
{t('inbox.mine')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant={filter === 'all' ? 'default' : 'outline'} size="sm" onClick={() => setFilter('all')}>
|
<Button
|
||||||
|
variant={filter === 'all' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
data-testid="filter-all"
|
||||||
|
onClick={() => setFilter('all')}
|
||||||
|
>
|
||||||
{t('inbox.all')}
|
{t('inbox.all')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -167,6 +182,7 @@ const TicketInboxPage: React.FC = () => {
|
|||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
type="button"
|
type="button"
|
||||||
|
data-testid={`ticket-row-${item.id}`}
|
||||||
onClick={() => selectTicket(item.id)}
|
onClick={() => selectTicket(item.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors hover:bg-muted/60',
|
'flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors hover:bg-muted/60',
|
||||||
@@ -253,11 +269,20 @@ const TicketInboxPage: React.FC = () => {
|
|||||||
{(ticket.status === 'open' || ticket.status === 'in_progress') && (
|
{(ticket.status === 'open' || ticket.status === 'in_progress') && (
|
||||||
<CardFooter className="gap-2 border-t bg-muted/30 p-4">
|
<CardFooter className="gap-2 border-t bg-muted/30 p-4">
|
||||||
{ticket.status === 'open' && (
|
{ticket.status === 'open' && (
|
||||||
<Button variant="secondary" onClick={handleInProgress} disabled={updateTicket.isPending}>
|
<Button
|
||||||
|
data-testid="btn-ticket-in-progress"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={handleInProgress}
|
||||||
|
disabled={updateTicket.isPending}
|
||||||
|
>
|
||||||
{t('common.inProgress')} <kbd className="ml-1 rounded border px-1 text-[10px] opacity-70">2</kbd>
|
{t('common.inProgress')} <kbd className="ml-1 rounded border px-1 text-[10px] opacity-70">2</kbd>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button onClick={handleResolve} disabled={updateTicket.isPending}>
|
<Button
|
||||||
|
data-testid="btn-ticket-resolved"
|
||||||
|
onClick={handleResolve}
|
||||||
|
disabled={updateTicket.isPending}
|
||||||
|
>
|
||||||
{t('common.resolved')} <kbd className="ml-1 rounded border px-1 text-[10px] opacity-70">1</kbd>
|
{t('common.resolved')} <kbd className="ml-1 rounded border px-1 text-[10px] opacity-70">1</kbd>
|
||||||
</Button>
|
</Button>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ const ReportDetailPage: React.FC = () => {
|
|||||||
: t('common.dismissed');
|
: t('common.dismissed');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div data-testid="page-report-detail">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={t('reports.detailTitle', { id: report.id })}
|
title={t('reports.detailTitle', { id: report.id })}
|
||||||
breadcrumbs={[
|
breadcrumbs={[
|
||||||
@@ -171,10 +171,19 @@ const ReportDetailPage: React.FC = () => {
|
|||||||
|
|
||||||
{report.status === 'pending' && (
|
{report.status === 'pending' && (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button onClick={() => setConfirmStatus('reviewed')} disabled={updateReport.isPending}>
|
<Button
|
||||||
|
data-testid="btn-report-reviewed"
|
||||||
|
onClick={() => setConfirmStatus('reviewed')}
|
||||||
|
disabled={updateReport.isPending}
|
||||||
|
>
|
||||||
{t('common.reviewed')}
|
{t('common.reviewed')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="destructive" onClick={() => setConfirmStatus('dismissed')} disabled={updateReport.isPending}>
|
<Button
|
||||||
|
data-testid="btn-report-dismissed"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => setConfirmStatus('dismissed')}
|
||||||
|
disabled={updateReport.isPending}
|
||||||
|
>
|
||||||
{t('common.dismissed')}
|
{t('common.dismissed')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -184,7 +193,7 @@ const ReportDetailPage: React.FC = () => {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Dialog open={confirmStatus !== null} onOpenChange={(open) => !open && setConfirmStatus(null)}>
|
<Dialog open={confirmStatus !== null} onOpenChange={(open) => !open && setConfirmStatus(null)}>
|
||||||
<DialogContent>
|
<DialogContent data-testid="dialog-confirm-report-status">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t('common.confirmMarkAs', { status: statusLabel })}</DialogTitle>
|
<DialogTitle>{t('common.confirmMarkAs', { status: statusLabel })}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -192,13 +201,13 @@ const ReportDetailPage: React.FC = () => {
|
|||||||
<Button variant="outline" onClick={() => setConfirmStatus(null)}>
|
<Button variant="outline" onClick={() => setConfirmStatus(null)}>
|
||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleConfirm} disabled={updateReport.isPending}>
|
<Button data-testid="btn-confirm-status" onClick={handleConfirm} disabled={updateReport.isPending}>
|
||||||
{updateReport.isPending ? t('common.saving') : t('common.yes')}
|
{updateReport.isPending ? t('common.saving') : t('common.yes')}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -250,6 +250,7 @@ const ReportListPage: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ExploreListShell
|
<ExploreListShell
|
||||||
|
data-testid="page-reports"
|
||||||
title={t('reports.title')}
|
title={t('reports.title')}
|
||||||
stats={exploreStats}
|
stats={exploreStats}
|
||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
@@ -342,6 +343,7 @@ const ReportListPage: React.FC = () => {
|
|||||||
{detailModal.report.status === 'pending' && (
|
{detailModal.report.status === 'pending' && (
|
||||||
<DialogFooter className="mt-4 sm:justify-start">
|
<DialogFooter className="mt-4 sm:justify-start">
|
||||||
<Button
|
<Button
|
||||||
|
data-testid="btn-report-reviewed"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
updateReport.mutate(
|
updateReport.mutate(
|
||||||
{ id: detailModal.report!.id, data: { status: 'reviewed' } },
|
{ id: detailModal.report!.id, data: { status: 'reviewed' } },
|
||||||
@@ -353,6 +355,7 @@ const ReportListPage: React.FC = () => {
|
|||||||
{t('common.reviewed')}
|
{t('common.reviewed')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
data-testid="btn-report-dismissed"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
updateReport.mutate(
|
updateReport.mutate(
|
||||||
|
|||||||
@@ -367,6 +367,7 @@ const ReviewListPage: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ExploreListShell
|
<ExploreListShell
|
||||||
|
data-testid="page-reviews"
|
||||||
title={t('reviews.title')}
|
title={t('reviews.title')}
|
||||||
stats={exploreStats}
|
stats={exploreStats}
|
||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
@@ -418,6 +419,7 @@ const ReviewListPage: React.FC = () => {
|
|||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button
|
<Button
|
||||||
|
data-testid="btn-reviews-bulk-hidden"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => openBulkStatusModal('hidden')}
|
onClick={() => openBulkStatusModal('hidden')}
|
||||||
disabled={selectedRowKeys.length === 0}
|
disabled={selectedRowKeys.length === 0}
|
||||||
@@ -425,6 +427,7 @@ const ReviewListPage: React.FC = () => {
|
|||||||
{t('reviews.hideSelected')}
|
{t('reviews.hideSelected')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
data-testid="btn-reviews-bulk-visible"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => openBulkStatusModal('visible')}
|
onClick={() => openBulkStatusModal('visible')}
|
||||||
disabled={selectedRowKeys.length === 0}
|
disabled={selectedRowKeys.length === 0}
|
||||||
@@ -432,6 +435,7 @@ const ReviewListPage: React.FC = () => {
|
|||||||
{t('reviews.showSelected')}
|
{t('reviews.showSelected')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
data-testid="btn-reviews-bulk-deleted"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={() => openBulkStatusModal('deleted')}
|
onClick={() => openBulkStatusModal('deleted')}
|
||||||
disabled={selectedRowKeys.length === 0}
|
disabled={selectedRowKeys.length === 0}
|
||||||
|
|||||||
Reference in New Issue
Block a user