feat(admin): E2E content/system mock-сьюты, row testids, IFT smoke. Refs EventHub/EventHubFrontAdmin#33 #37 #38 #39
This commit is contained in:
+12
-13
@@ -6,21 +6,20 @@
|
|||||||
|
|
||||||
| Pattern | Example |
|
| Pattern | Example |
|
||||||
|---------|---------|
|
|---------|---------|
|
||||||
| `page-{name}` | `page-login`, `page-users` |
|
| `page-{name}` | `page-login`, `page-users`, `page-inbox-reports` |
|
||||||
| `nav-{slug}` | `nav-users` (slug = path without `/`) |
|
| `nav-{slug}` | `nav-users` |
|
||||||
| `{entity}-row-{id}` | `user-row-{uuid}` |
|
| `row-{id}` | dense list / DataTable rows |
|
||||||
| `{entity}-detail` | `user-detail` |
|
| `row-activate-{id}` | dense list main click target |
|
||||||
| `{entity}-filter-{field}` | `event-filter-status` |
|
| `{entity}-row-{id}` | inbox-specific (`report-row-*`) |
|
||||||
| `btn-{action}` | `btn-login`, `btn-save`, `btn-delete-confirm` |
|
| `btn-{action}` | `btn-login`, `btn-save-profile` |
|
||||||
| `dialog-{name}` | `dialog-edit-user` |
|
| `dialog-{name}` | `dialog-edit-user` |
|
||||||
| `menu-user` | avatar dropdown |
|
| `filter-{name}` | `filter-users-search` |
|
||||||
| `palette-input` | command palette |
|
| `menu-user` / `lang-ru` | avatar dropdown |
|
||||||
|
|
||||||
Helper in app: `src/lib/testid.ts` → `testid(['user-row', id])`.
|
Helper: `src/lib/testid.ts`.
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
- kebab-case only
|
- kebab-case; dynamic id = API entity id (or banned word)
|
||||||
- Dynamic id segment = real API entity id
|
- Prefer a11y for login fields
|
||||||
- Prefer roles/labels for login email/password and primary submit when i18n fixed to `ru` in mock suite
|
- Do not decorate decorative wrappers
|
||||||
- Do not decorate every layout div — only interaction anchors
|
|
||||||
|
|||||||
+12
-19
@@ -1,5 +1,7 @@
|
|||||||
import type { Page, Route } from '@playwright/test';
|
import type { Page, Route } from '@playwright/test';
|
||||||
import { createModerationSeed, type ModerationState } from './moderationSeed';
|
import { createModerationSeed, type ModerationState } from './moderationSeed';
|
||||||
|
import { createContentSeed } from './contentSeed';
|
||||||
|
import { tryHandleContentApi } from './handleContentApi';
|
||||||
|
|
||||||
export type MockAdmin = {
|
export type MockAdmin = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -43,6 +45,7 @@ type MockOptions = {
|
|||||||
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();
|
const moderation: ModerationState | null = options.moderation === false ? null : createModerationSeed();
|
||||||
|
const content = createContentSeed();
|
||||||
|
|
||||||
await page.route('**/v1/admin/**', async (route) => {
|
await page.route('**/v1/admin/**', async (route) => {
|
||||||
const req = route.request();
|
const req = route.request();
|
||||||
@@ -111,6 +114,12 @@ export async function installAdminApiMocks(page: Page, options: MockOptions = {}
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (method === 'GET' && path.includes('/v1/admin/nodes/metrics')) {
|
if (method === 'GET' && path.includes('/v1/admin/nodes/metrics')) {
|
||||||
|
// Prefer content seed with sample node (monitoring)
|
||||||
|
if (
|
||||||
|
await tryHandleContentApi(route, method, path, url, content, json, () => req.postDataJSON())
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
await json(route, 200, { nodes: [] });
|
await json(route, 200, { nodes: [] });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -267,26 +276,10 @@ export async function installAdminApiMocks(page: Page, options: MockOptions = {}
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// soft stubs for linked entities in inbox
|
if (await tryHandleContentApi(route, method, path, url, content, json, () => req.postDataJSON())) {
|
||||||
if (method === 'GET' && /\/v1\/admin\/users\/[^/]+$/.test(path)) {
|
return;
|
||||||
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') {
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
/** Content/system seed data for mock E2E (users, calendars, events, …). */
|
||||||
|
|
||||||
|
const now = '2026-07-14T12:00:00Z';
|
||||||
|
|
||||||
|
export function createContentSeed() {
|
||||||
|
const users = [
|
||||||
|
{
|
||||||
|
id: 'user-e2e-1',
|
||||||
|
email: 'alice@eventhub.local',
|
||||||
|
nickname: 'Alice',
|
||||||
|
role: 'user',
|
||||||
|
status: 'active',
|
||||||
|
language: 'ru',
|
||||||
|
phone: null,
|
||||||
|
avatar_url: null,
|
||||||
|
last_login: now,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'user-e2e-2',
|
||||||
|
email: 'bob@eventhub.local',
|
||||||
|
nickname: 'Bob',
|
||||||
|
role: 'user',
|
||||||
|
status: 'frozen',
|
||||||
|
language: 'en',
|
||||||
|
phone: null,
|
||||||
|
avatar_url: null,
|
||||||
|
last_login: now,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const calendars = [
|
||||||
|
{
|
||||||
|
id: 'cal-e2e-1',
|
||||||
|
title: 'E2E Calendar',
|
||||||
|
description: 'for tests',
|
||||||
|
type: 'personal',
|
||||||
|
category: 'sport',
|
||||||
|
owner_id: 'user-e2e-1',
|
||||||
|
status: 'active',
|
||||||
|
reason: null,
|
||||||
|
tags: [],
|
||||||
|
color: '#3366ff',
|
||||||
|
image_url: '',
|
||||||
|
rating_avg: 4.5,
|
||||||
|
rating_count: 2,
|
||||||
|
short_name: 'e2ecal',
|
||||||
|
confirmation: 'none',
|
||||||
|
settings: null,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const events = [
|
||||||
|
{
|
||||||
|
id: 'evt-e2e-1',
|
||||||
|
calendar_id: 'cal-e2e-1',
|
||||||
|
title: 'E2E Workout',
|
||||||
|
description: 'test event',
|
||||||
|
event_type: 'single',
|
||||||
|
start_time: '2026-01-01T10:00:00Z',
|
||||||
|
duration: 60,
|
||||||
|
recurrence: null,
|
||||||
|
master_id: null,
|
||||||
|
is_instance: false,
|
||||||
|
specialist_id: null,
|
||||||
|
location: null,
|
||||||
|
tags: [],
|
||||||
|
capacity: 10,
|
||||||
|
online_link: null,
|
||||||
|
status: 'active',
|
||||||
|
reason: null,
|
||||||
|
rating_avg: 5,
|
||||||
|
rating_count: 1,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const subscriptions = [
|
||||||
|
{
|
||||||
|
id: 'sub-e2e-1',
|
||||||
|
user_id: 'user-e2e-1',
|
||||||
|
plan: 'pro',
|
||||||
|
status: 'active',
|
||||||
|
started_at: now,
|
||||||
|
expires_at: '2027-01-01T00:00:00Z',
|
||||||
|
auto_renew: true,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const bannedWords = [
|
||||||
|
{ word: 'spamword', added_by: 'admin-e2e-1', created_at: now },
|
||||||
|
{ word: 'badword', added_by: 'admin-e2e-1', created_at: now },
|
||||||
|
];
|
||||||
|
|
||||||
|
const admins = [
|
||||||
|
{
|
||||||
|
id: 'admin-e2e-1',
|
||||||
|
email: 'e2e@eventhub.local',
|
||||||
|
nickname: 'E2E Admin',
|
||||||
|
role: 'superadmin',
|
||||||
|
language: 'ru',
|
||||||
|
phone: null,
|
||||||
|
avatar_url: null,
|
||||||
|
timezone: 'Europe/Moscow',
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
last_login: now,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'admin-e2e-2',
|
||||||
|
email: 'mod@eventhub.local',
|
||||||
|
nickname: 'Mod Admin',
|
||||||
|
role: 'moderator',
|
||||||
|
language: 'ru',
|
||||||
|
phone: null,
|
||||||
|
avatar_url: null,
|
||||||
|
timezone: 'UTC',
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
last_login: now,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const audit = [
|
||||||
|
{
|
||||||
|
id: 'audit-e2e-1',
|
||||||
|
admin_id: 'admin-e2e-1',
|
||||||
|
action: 'user.update',
|
||||||
|
target_type: 'user',
|
||||||
|
target_id: 'user-e2e-1',
|
||||||
|
details: { status: 'frozen' },
|
||||||
|
ip: '127.0.0.1',
|
||||||
|
created_at: now,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return { users, calendars, events, subscriptions, bannedWords, admins, audit };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ContentState = ReturnType<typeof createContentSeed>;
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
import type { Route } from '@playwright/test';
|
||||||
|
import type { ContentState } from './contentSeed';
|
||||||
|
|
||||||
|
type JsonFn = (
|
||||||
|
route: Route,
|
||||||
|
status: number,
|
||||||
|
body: unknown,
|
||||||
|
headers?: Record<string, string>
|
||||||
|
) => Promise<void>;
|
||||||
|
|
||||||
|
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);
|
||||||
|
const q = (url.searchParams.get('q') || '').toLowerCase();
|
||||||
|
let rows = items;
|
||||||
|
if (q) {
|
||||||
|
rows = items.filter((item) => JSON.stringify(item).toLowerCase().includes(q));
|
||||||
|
}
|
||||||
|
const status = url.searchParams.get('status');
|
||||||
|
if (status && rows.length && typeof (rows[0] as { status?: string }).status === 'string') {
|
||||||
|
rows = rows.filter((item) => (item as { status?: string }).status === status);
|
||||||
|
}
|
||||||
|
const plan = url.searchParams.get('plan');
|
||||||
|
if (plan) {
|
||||||
|
rows = rows.filter((item) => (item as { plan?: string }).plan === plan);
|
||||||
|
}
|
||||||
|
const type = url.searchParams.get('type');
|
||||||
|
if (type) {
|
||||||
|
rows = rows.filter((item) => (item as { type?: string }).type === type);
|
||||||
|
}
|
||||||
|
return { rows: rows.slice(offset, offset + limit), total: rows.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns true if the request was handled. */
|
||||||
|
export async function tryHandleContentApi(
|
||||||
|
route: Route,
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
url: URL,
|
||||||
|
content: ContentState,
|
||||||
|
json: JsonFn,
|
||||||
|
postData: () => unknown
|
||||||
|
): Promise<boolean> {
|
||||||
|
// ---- users ----
|
||||||
|
if (method === 'GET' && path.endsWith('/v1/admin/users/stats')) {
|
||||||
|
await json(route, 200, {
|
||||||
|
total_users: content.users.length,
|
||||||
|
users_by_role: {},
|
||||||
|
users_by_status: {},
|
||||||
|
pending_users: 0,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === 'GET' && path.endsWith('/v1/admin/users')) {
|
||||||
|
const page = listSlice(content.users, url);
|
||||||
|
await json(route, 200, page.rows, { 'X-Total-Count': String(page.total) });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const m = path.match(/\/v1\/admin\/users\/([^/]+)$/);
|
||||||
|
if (m) {
|
||||||
|
const id = decodeURIComponent(m[1]);
|
||||||
|
const idx = content.users.findIndex((u) => u.id === id);
|
||||||
|
if (idx < 0) {
|
||||||
|
await json(route, 404, { error: 'not_found' });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === 'GET') {
|
||||||
|
await json(route, 200, content.users[idx]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === 'PUT') {
|
||||||
|
content.users[idx] = { ...content.users[idx], ...(postData() as object) };
|
||||||
|
await json(route, 200, content.users[idx]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === 'DELETE') {
|
||||||
|
content.users.splice(idx, 1);
|
||||||
|
await json(route, 200, { ok: true });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- calendars ----
|
||||||
|
if (method === 'GET' && path.endsWith('/v1/admin/calendars/stats')) {
|
||||||
|
await json(route, 200, {
|
||||||
|
total_calendars: content.calendars.length,
|
||||||
|
calendars_by_type: {},
|
||||||
|
calendars_by_status: {},
|
||||||
|
top_calendars_by_reviews: [],
|
||||||
|
top_calendars_by_positive_reviews: [],
|
||||||
|
top_calendars_by_negative_reviews: [],
|
||||||
|
top_calendars_by_rating: [],
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === 'GET' && path.endsWith('/v1/admin/calendars')) {
|
||||||
|
const page = listSlice(content.calendars, url);
|
||||||
|
await json(route, 200, page.rows, { 'X-Total-Count': String(page.total) });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const m = path.match(/\/v1\/admin\/calendars\/([^/]+)$/);
|
||||||
|
if (m) {
|
||||||
|
const id = decodeURIComponent(m[1]);
|
||||||
|
const idx = content.calendars.findIndex((c) => c.id === id);
|
||||||
|
if (idx < 0) {
|
||||||
|
await json(route, 404, { error: 'not_found' });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === 'GET') {
|
||||||
|
await json(route, 200, content.calendars[idx]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === 'PUT') {
|
||||||
|
content.calendars[idx] = { ...content.calendars[idx], ...(postData() as object) };
|
||||||
|
await json(route, 200, content.calendars[idx]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === 'DELETE') {
|
||||||
|
content.calendars.splice(idx, 1);
|
||||||
|
await json(route, 200, { ok: true });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- events ----
|
||||||
|
if (method === 'GET' && path.endsWith('/v1/admin/events/stats')) {
|
||||||
|
await json(route, 200, {
|
||||||
|
total_events: content.events.length,
|
||||||
|
events_by_type: {},
|
||||||
|
events_by_status: {},
|
||||||
|
top_events_by_rating: [],
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === 'GET' && path.endsWith('/v1/admin/events')) {
|
||||||
|
const page = listSlice(content.events, url);
|
||||||
|
await json(route, 200, page.rows, { 'X-Total-Count': String(page.total) });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const m = path.match(/\/v1\/admin\/events\/([^/]+)$/);
|
||||||
|
if (m) {
|
||||||
|
const id = decodeURIComponent(m[1]);
|
||||||
|
const idx = content.events.findIndex((e) => e.id === id);
|
||||||
|
if (idx < 0) {
|
||||||
|
await json(route, 404, { error: 'not_found' });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === 'GET') {
|
||||||
|
await json(route, 200, content.events[idx]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === 'PUT') {
|
||||||
|
content.events[idx] = { ...content.events[idx], ...(postData() as object) };
|
||||||
|
await json(route, 200, content.events[idx]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === 'DELETE') {
|
||||||
|
content.events.splice(idx, 1);
|
||||||
|
await json(route, 200, { ok: true });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- subscriptions ----
|
||||||
|
if (method === 'GET' && path.endsWith('/v1/admin/subscriptions/stats')) {
|
||||||
|
await json(route, 200, {
|
||||||
|
total_subscriptions: content.subscriptions.length,
|
||||||
|
subscriptions_by_plan: {},
|
||||||
|
subscriptions_by_status: {},
|
||||||
|
trial_subscriptions: 0,
|
||||||
|
ending_paid_subscriptions: [],
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === 'GET' && path.endsWith('/v1/admin/subscriptions')) {
|
||||||
|
const page = listSlice(content.subscriptions, url);
|
||||||
|
await json(route, 200, page.rows, { 'X-Total-Count': String(page.total) });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const m = path.match(/\/v1\/admin\/subscriptions\/([^/]+)$/);
|
||||||
|
if (m) {
|
||||||
|
const id = decodeURIComponent(m[1]);
|
||||||
|
const idx = content.subscriptions.findIndex((s) => s.id === id);
|
||||||
|
if (idx < 0) {
|
||||||
|
await json(route, 404, { error: 'not_found' });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === 'GET') {
|
||||||
|
await json(route, 200, content.subscriptions[idx]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === 'PUT') {
|
||||||
|
content.subscriptions[idx] = { ...content.subscriptions[idx], ...(postData() as object) };
|
||||||
|
await json(route, 200, content.subscriptions[idx]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === 'DELETE') {
|
||||||
|
content.subscriptions.splice(idx, 1);
|
||||||
|
await json(route, 200, { ok: true });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- banned words ----
|
||||||
|
if (method === 'GET' && path.endsWith('/v1/admin/banned-words')) {
|
||||||
|
const page = listSlice(
|
||||||
|
content.bannedWords.map((w) => ({ ...w, id: w.word })),
|
||||||
|
url
|
||||||
|
);
|
||||||
|
await json(route, 200, page.rows, { 'X-Total-Count': String(page.total) });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
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' });
|
||||||
|
await json(route, 200, { ok: true });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const m = path.match(/\/v1\/admin\/banned-words\/([^/]+)$/);
|
||||||
|
if (m && method === 'DELETE') {
|
||||||
|
const word = decodeURIComponent(m[1]);
|
||||||
|
content.bannedWords = content.bannedWords.filter((w) => w.word !== word);
|
||||||
|
await json(route, 200, { ok: true });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- admins ----
|
||||||
|
if (method === 'GET' && path.endsWith('/v1/admin/admins')) {
|
||||||
|
const page = listSlice(content.admins, url);
|
||||||
|
await json(route, 200, page.rows, { 'X-Total-Count': String(page.total) });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === 'POST' && path.endsWith('/v1/admin/admins')) {
|
||||||
|
const payload = postData() as Record<string, unknown>;
|
||||||
|
const created = {
|
||||||
|
id: `admin-new-${content.admins.length + 1}`,
|
||||||
|
email: String(payload.email || 'new@eventhub.local'),
|
||||||
|
nickname: String(payload.nickname || 'New Admin'),
|
||||||
|
role: String(payload.role || 'admin'),
|
||||||
|
language: 'ru',
|
||||||
|
phone: null,
|
||||||
|
avatar_url: null,
|
||||||
|
timezone: 'UTC',
|
||||||
|
created_at: '2026-07-14T12:00:00Z',
|
||||||
|
updated_at: '2026-07-14T12:00:00Z',
|
||||||
|
last_login: null,
|
||||||
|
};
|
||||||
|
content.admins.push(created);
|
||||||
|
await json(route, 200, created);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const m = path.match(/\/v1\/admin\/admins\/([^/]+)$/);
|
||||||
|
if (m) {
|
||||||
|
const id = decodeURIComponent(m[1]);
|
||||||
|
const idx = content.admins.findIndex((a) => a.id === id);
|
||||||
|
if (idx < 0) {
|
||||||
|
await json(route, 404, { error: 'not_found' });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === 'GET') {
|
||||||
|
await json(route, 200, content.admins[idx]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === 'PUT') {
|
||||||
|
content.admins[idx] = { ...content.admins[idx], ...(postData() as object) };
|
||||||
|
await json(route, 200, content.admins[idx]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === 'DELETE') {
|
||||||
|
content.admins.splice(idx, 1);
|
||||||
|
await json(route, 200, { ok: true });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- audit ----
|
||||||
|
if (method === 'GET' && path.endsWith('/v1/admin/audit')) {
|
||||||
|
const page = listSlice(content.audit, url);
|
||||||
|
await json(route, 200, page.rows, { 'X-Total-Count': String(page.total) });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- monitoring enrichment ----
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { seedAuthenticatedSession } from '../helpers/session';
|
||||||
|
|
||||||
|
test.describe('content explore (mock)', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await seedAuthenticatedSession(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('users: список, поиск, detail', async ({ page }) => {
|
||||||
|
await page.goto('/users');
|
||||||
|
await expect(page.getByTestId('page-users')).toBeVisible();
|
||||||
|
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 page.goto('/users/user-e2e-1');
|
||||||
|
await expect(page.getByTestId('page-user-detail')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('calendars: список и detail', async ({ page }) => {
|
||||||
|
await page.goto('/calendars');
|
||||||
|
await expect(page.getByTestId('page-calendars')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('row-cal-e2e-1')).toBeVisible();
|
||||||
|
await page.goto('/calendars/cal-e2e-1');
|
||||||
|
await expect(page.getByTestId('page-calendar-detail')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('events: список и detail', async ({ page }) => {
|
||||||
|
await page.goto('/events');
|
||||||
|
await expect(page.getByTestId('page-events')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('row-evt-e2e-1')).toBeVisible();
|
||||||
|
await page.goto('/events/evt-e2e-1');
|
||||||
|
await expect(page.getByTestId('page-event-detail')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subscriptions: список', async ({ page }) => {
|
||||||
|
await page.goto('/subscriptions');
|
||||||
|
await expect(page.getByTestId('page-subscriptions')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('row-sub-e2e-1')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('banned-words: список и add', async ({ page }) => {
|
||||||
|
await page.goto('/banned-words');
|
||||||
|
await expect(page.getByTestId('page-banned-words')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('row-spamword')).toBeVisible();
|
||||||
|
await page.getByTestId('input-banned-word').fill('newbad');
|
||||||
|
await page.getByTestId('btn-add-banned-word').click();
|
||||||
|
await expect(page.getByTestId('row-newbad')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,10 +1,20 @@
|
|||||||
import { test } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
/** IFT smoke project placeholder — filled in phase 6. */
|
/**
|
||||||
|
* IFT smoke — заполняется секретами E2E_ADMIN_EMAIL / E2E_ADMIN_PASSWORD.
|
||||||
|
* Запуск: `npm run test:e2e:ift` (без моков).
|
||||||
|
*/
|
||||||
test.describe('IFT smoke', () => {
|
test.describe('IFT smoke', () => {
|
||||||
test.skip(!process.env.E2E_ADMIN_EMAIL, 'Set E2E_ADMIN_EMAIL / E2E_ADMIN_PASSWORD for IFT');
|
test.skip(!process.env.E2E_ADMIN_EMAIL || !process.env.E2E_ADMIN_PASSWORD, 'E2E_ADMIN_* secrets required');
|
||||||
|
|
||||||
test('placeholder', async () => {
|
test('login → dashboard → logout', async ({ page }) => {
|
||||||
// Implemented in E2E phase 6
|
await page.goto('/login');
|
||||||
|
await page.getByLabel(/email/i).fill(process.env.E2E_ADMIN_EMAIL!);
|
||||||
|
await page.getByLabel(/пароль|password/i).fill(process.env.E2E_ADMIN_PASSWORD!);
|
||||||
|
await page.getByTestId('btn-login').click();
|
||||||
|
await expect(page.getByTestId('layout-shell')).toBeVisible({ timeout: 30_000 });
|
||||||
|
await page.getByTestId('menu-user').click();
|
||||||
|
await page.getByTestId('btn-logout').click();
|
||||||
|
await expect(page.getByTestId('page-login')).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { seedAuthenticatedSession } from '../helpers/session';
|
||||||
|
|
||||||
|
test.describe('system / overview (mock)', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await seedAuthenticatedSession(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admins: список, create dialog, detail', async ({ page }) => {
|
||||||
|
await page.goto('/admins');
|
||||||
|
await expect(page.getByTestId('page-admins')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('row-admin-e2e-2')).toBeVisible();
|
||||||
|
await page.getByTestId('btn-create-admin').click();
|
||||||
|
await expect(page.getByTestId('dialog-create-admin')).toBeVisible();
|
||||||
|
await page.goto('/admins/admin-e2e-2');
|
||||||
|
await expect(page.getByTestId('page-admin-detail')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('btn-edit-admin')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('audit: страница и фильтры', async ({ page }) => {
|
||||||
|
await page.goto('/audit');
|
||||||
|
await expect(page.getByTestId('page-audit')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('filter-audit')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('row-audit-e2e-1')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('monitoring: страница и range', 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();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('profile: view и edit', async ({ page }) => {
|
||||||
|
await page.goto('/profile');
|
||||||
|
await expect(page.getByTestId('page-profile')).toBeVisible();
|
||||||
|
await page.getByTestId('btn-edit-profile').click();
|
||||||
|
await expect(page.getByTestId('btn-save-profile')).toBeVisible();
|
||||||
|
await page.getByTestId('btn-cancel-profile').click();
|
||||||
|
await expect(page.getByTestId('btn-edit-profile')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dashboard остаётся доступен', async ({ page }) => {
|
||||||
|
await page.goto('/dashboard');
|
||||||
|
await expect(page.getByTestId('page-dashboard')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('i18n (mock)', () => {
|
||||||
|
test('переключение языка через меню', async ({ page }) => {
|
||||||
|
await seedAuthenticatedSession(page);
|
||||||
|
await page.goto('/dashboard');
|
||||||
|
await page.getByTestId('menu-user').click();
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -126,15 +126,19 @@ export function DataTable<TData, TParams extends ServerTableParams>({
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
) : table.getRowModel().rows.length ? (
|
) : table.getRowModel().rows.length ? (
|
||||||
table.getRowModel().rows.map((row) => (
|
table.getRowModel().rows.map((row) => {
|
||||||
<TableRow key={row.id}>
|
const original = row.original as { id?: string; word?: string };
|
||||||
|
const rowKey = original.id ?? original.word;
|
||||||
|
return (
|
||||||
|
<TableRow key={row.id} data-testid={rowKey ? `row-${rowKey}` : undefined}>
|
||||||
{row.getVisibleCells().map((cell) => (
|
{row.getVisibleCells().map((cell) => (
|
||||||
<TableCell key={cell.id}>
|
<TableCell key={cell.id}>
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
);
|
||||||
|
})
|
||||||
) : (
|
) : (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={columns.length} className="h-24 text-center text-muted-foreground">
|
<TableCell colSpan={columns.length} className="h-24 text-center text-muted-foreground">
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export function DenseRowList<TParams extends ServerTableParams>({
|
|||||||
) : (
|
) : (
|
||||||
<ul className="divide-y">
|
<ul className="divide-y">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<li key={item.id}>
|
<li key={item.id} data-testid={`row-${item.id}`}>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'group flex items-start gap-3 px-4 py-3 transition-colors',
|
'group flex items-start gap-3 px-4 py-3 transition-colors',
|
||||||
@@ -69,6 +69,7 @@ export function DenseRowList<TParams extends ServerTableParams>({
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
data-testid={`row-activate-${item.id}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
'min-w-0 flex-1 text-left',
|
'min-w-0 flex-1 text-left',
|
||||||
!item.onActivate && 'cursor-default'
|
!item.onActivate && 'cursor-default'
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ interface ExploreSearchProps {
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
|
'data-testid'?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ExploreSearch({
|
export function ExploreSearch({
|
||||||
@@ -20,10 +21,12 @@ export function ExploreSearch({
|
|||||||
placeholder,
|
placeholder,
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
|
'data-testid': dataTestId,
|
||||||
}: ExploreSearchProps) {
|
}: ExploreSearchProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
|
data-testid={dataTestId}
|
||||||
className={cn('flex flex-wrap items-center gap-2', className)}
|
className={cn('flex flex-wrap items-center gap-2', className)}
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -33,6 +36,7 @@ export function ExploreSearch({
|
|||||||
<div className="relative min-w-[200px] max-w-md flex-1">
|
<div className="relative min-w-[200px] max-w-md flex-1">
|
||||||
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
|
data-testid={dataTestId ? `${dataTestId}-input` : undefined}
|
||||||
className="pl-8"
|
className="pl-8"
|
||||||
placeholder={placeholder ?? t('common.searchPlaceholder')}
|
placeholder={placeholder ?? t('common.searchPlaceholder')}
|
||||||
value={value}
|
value={value}
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ const AdminDetailPage: React.FC = () => {
|
|||||||
const titleName = !isBadValue(admin.nickname) ? admin.nickname : admin.email || admin.id;
|
const titleName = !isBadValue(admin.nickname) ? admin.nickname : admin.email || admin.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div data-testid="page-admin-detail">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={t('admins.detailTitle', { name: titleName })}
|
title={t('admins.detailTitle', { name: titleName })}
|
||||||
breadcrumbs={[
|
breadcrumbs={[
|
||||||
@@ -127,11 +127,16 @@ const AdminDetailPage: React.FC = () => {
|
|||||||
]}
|
]}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<Button variant="outline" size="sm" onClick={() => setEditModal(true)}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
data-testid="btn-edit-admin"
|
||||||
|
onClick={() => setEditModal(true)}
|
||||||
|
>
|
||||||
<Pencil className="h-4 w-4" />
|
<Pencil className="h-4 w-4" />
|
||||||
{t('common.edit')}
|
{t('common.edit')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" onClick={() => navigate('/admins')}>
|
<Button variant="outline" data-testid="btn-back" onClick={() => navigate('/admins')}>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
{t('common.backToList')}
|
{t('common.backToList')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -301,7 +306,7 @@ const AdminDetailPage: React.FC = () => {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -236,12 +236,13 @@ const AdminListPage: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ExploreListShell
|
<ExploreListShell
|
||||||
|
data-testid="page-admins"
|
||||||
title={t('admins.title')}
|
title={t('admins.title')}
|
||||||
stats={exploreStats}
|
stats={exploreStats}
|
||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
onViewModeChange={setViewMode}
|
onViewModeChange={setViewMode}
|
||||||
actions={
|
actions={
|
||||||
<Button onClick={() => setCreateModal(true)}>
|
<Button data-testid="btn-create-admin" onClick={() => setCreateModal(true)}>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
{t('admins.add')}
|
{t('admins.add')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -261,7 +262,7 @@ const AdminListPage: React.FC = () => {
|
|||||||
</ExploreListShell>
|
</ExploreListShell>
|
||||||
|
|
||||||
<Dialog open={createModal} onOpenChange={setCreateModal}>
|
<Dialog open={createModal} onOpenChange={setCreateModal}>
|
||||||
<DialogContent>
|
<DialogContent data-testid="dialog-create-admin">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t('admins.createTitle')}</DialogTitle>
|
<DialogTitle>{t('admins.createTitle')}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|||||||
@@ -186,13 +186,14 @@ const AuditPage: React.FC = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div data-testid="page-audit" className="space-y-6">
|
||||||
<ListPageHeader title={t('audit.title')} description={t('audit.description')} />
|
<ListPageHeader title={t('audit.title')} description={t('audit.description')} />
|
||||||
|
|
||||||
<div className="flex flex-wrap items-end gap-4">
|
<div className="flex flex-wrap items-end gap-4" data-testid="filter-audit">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('audit.searchAdmin')}</Label>
|
<Label>{t('audit.searchAdmin')}</Label>
|
||||||
<Input
|
<Input
|
||||||
|
data-testid="filter-audit-admin-search"
|
||||||
placeholder={t('audit.searchAdminPlaceholder')}
|
placeholder={t('audit.searchAdminPlaceholder')}
|
||||||
value={adminSearch}
|
value={adminSearch}
|
||||||
onChange={(e) => setAdminSearch(e.target.value)}
|
onChange={(e) => setAdminSearch(e.target.value)}
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ const BannedWordsPage: React.FC = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div data-testid="page-banned-words" className="space-y-6">
|
||||||
<ListPageHeader
|
<ListPageHeader
|
||||||
title={t('bannedWords.title')}
|
title={t('bannedWords.title')}
|
||||||
description={t('bannedWords.description')}
|
description={t('bannedWords.description')}
|
||||||
@@ -126,24 +126,36 @@ const BannedWordsPage: React.FC = () => {
|
|||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Input
|
<Input
|
||||||
|
data-testid="input-banned-word"
|
||||||
placeholder={t('bannedWords.placeholder')}
|
placeholder={t('bannedWords.placeholder')}
|
||||||
value={newWord}
|
value={newWord}
|
||||||
onChange={(e) => setNewWord(e.target.value)}
|
onChange={(e) => setNewWord(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
|
onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
|
||||||
className="w-[200px]"
|
className="w-[200px]"
|
||||||
/>
|
/>
|
||||||
<Button onClick={handleAdd} disabled={addWord.isPending || !newWord.trim()}>
|
<Button
|
||||||
|
data-testid="btn-add-banned-word"
|
||||||
|
onClick={handleAdd}
|
||||||
|
disabled={addWord.isPending || !newWord.trim()}
|
||||||
|
>
|
||||||
{t('common.add')}
|
{t('common.add')}
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Input
|
<Input
|
||||||
|
data-testid="filter-banned-words-search"
|
||||||
placeholder={t('explore.searchWord')}
|
placeholder={t('explore.searchWord')}
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||||
className="w-[200px]"
|
className="w-[200px]"
|
||||||
/>
|
/>
|
||||||
<Button variant="outline" size="icon" className="h-9 w-9" onClick={handleSearch}>
|
<Button
|
||||||
|
data-testid="btn-search-banned-words"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-9 w-9"
|
||||||
|
onClick={handleSearch}
|
||||||
|
>
|
||||||
<Search className="h-4 w-4" />
|
<Search className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,158 +1,158 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { ArrowLeft } from 'lucide-react';
|
||||||
|
import { useCalendar } from '@/hooks/useCalendars';
|
||||||
import dayjs from 'dayjs';
|
import { formatDisplayValue } from '@/lib/utils';
|
||||||
|
import { PageHeader } from '@/components/PageHeader';
|
||||||
import { ArrowLeft } from 'lucide-react';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import { useCalendar } from '@/hooks/useCalendars';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { formatDisplayValue } from '@/lib/utils';
|
|
||||||
|
const isBadValue = (val: unknown) =>
|
||||||
import { PageHeader } from '@/components/PageHeader';
|
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
|
||||||
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
const DetailValue: React.FC<{ value: unknown; emptyLabel: string }> = ({ value, emptyLabel }) => {
|
||||||
|
const text = formatDisplayValue(value);
|
||||||
import { Button } from '@/components/ui/button';
|
if (text === '-') return <>{emptyLabel}</>;
|
||||||
|
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
return <pre className="overflow-auto rounded-md bg-muted p-2 text-xs">{text}</pre>;
|
||||||
|
}
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
return <>{text}</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CalendarDetailPage: React.FC = () => {
|
||||||
const isBadValue = (val: unknown) =>
|
const { t } = useTranslation();
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
|
const navigate = useNavigate();
|
||||||
|
const { data: calendar, isLoading } = useCalendar(id || '');
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
const DetailValue: React.FC<{ value: unknown; emptyLabel: string }> = ({ value, emptyLabel }) => {
|
if (isBadValue(dateStr)) return t('common.emDash');
|
||||||
|
const d = dayjs(dateStr);
|
||||||
const text = formatDisplayValue(value);
|
return d.isValid() ? d.format('DD.MM.YYYY HH:mm') : dateStr;
|
||||||
|
};
|
||||||
if (text === '-') return <>{emptyLabel}</>;
|
|
||||||
|
if (isLoading) {
|
||||||
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
return <pre className="overflow-auto rounded-md bg-muted p-2 text-xs">{text}</pre>;
|
<Skeleton className="h-8 w-64" />
|
||||||
|
<Skeleton className="h-96 w-full" />
|
||||||
}
|
</div>
|
||||||
|
);
|
||||||
return <>{text}</>;
|
}
|
||||||
|
|
||||||
};
|
if (!calendar) return <p className="text-muted-foreground">{t('calendars.notFound')}</p>;
|
||||||
|
|
||||||
|
const emDash = t('common.emDash');
|
||||||
|
const titleLabel = calendar.title || calendar.id;
|
||||||
const CalendarDetailPage: React.FC = () => {
|
|
||||||
|
return (
|
||||||
const { t } = useTranslation();
|
<div data-testid="page-calendar-detail">
|
||||||
|
<PageHeader
|
||||||
const { id } = useParams<{ id: string }>();
|
title={t('calendars.detailTitle', { name: titleLabel })}
|
||||||
|
breadcrumbs={[
|
||||||
const navigate = useNavigate();
|
{ label: t('calendars.title'), href: '/calendars' },
|
||||||
|
{ label: String(titleLabel) },
|
||||||
const { data: calendar, isLoading } = useCalendar(id || '');
|
]}
|
||||||
|
actions={
|
||||||
|
<Button variant="outline" data-testid="btn-back" onClick={() => navigate('/calendars')}>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
const formatDate = (dateStr: string) => {
|
{t('common.backToList')}
|
||||||
|
</Button>
|
||||||
if (isBadValue(dateStr)) return t('common.emDash');
|
}
|
||||||
|
/>
|
||||||
const d = dayjs(dateStr);
|
<Card>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
return d.isValid() ? d.format('DD.MM.YYYY HH:mm') : dateStr;
|
<dl className="grid gap-3 text-sm">
|
||||||
|
<div className="grid grid-cols-[140px_1fr] gap-2">
|
||||||
};
|
<dt className="text-muted-foreground">{t('common.id')}</dt>
|
||||||
|
<dd>{calendar.id}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-[140px_1fr] gap-2">
|
||||||
if (isLoading) {
|
<dt className="text-muted-foreground">{t('common.title')}</dt>
|
||||||
|
<dd>{calendar.title}</dd>
|
||||||
return (
|
</div>
|
||||||
|
<div className="grid grid-cols-[140px_1fr] gap-2">
|
||||||
<div className="space-y-4">
|
<dt className="text-muted-foreground">{t('common.shortName')}</dt>
|
||||||
|
<dd><DetailValue value={calendar.short_name} emptyLabel={emDash} /></dd>
|
||||||
<Skeleton className="h-8 w-64" />
|
</div>
|
||||||
|
<div className="grid grid-cols-[140px_1fr] gap-2">
|
||||||
<Skeleton className="h-96 w-full" />
|
<dt className="text-muted-foreground">{t('common.description')}</dt>
|
||||||
|
<dd><DetailValue value={calendar.description} emptyLabel={emDash} /></dd>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid grid-cols-[140px_1fr] gap-2">
|
||||||
);
|
<dt className="text-muted-foreground">{t('common.type')}</dt>
|
||||||
|
<dd>
|
||||||
}
|
<Badge variant={calendar.type === 'commercial' ? 'warning' : 'default'}>{calendar.type}</Badge>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-[140px_1fr] gap-2">
|
||||||
if (!calendar) return <p className="text-muted-foreground">{t('calendars.notFound')}</p>;
|
<dt className="text-muted-foreground">{t('common.status')}</dt>
|
||||||
|
<dd>
|
||||||
|
<Badge variant={calendar.status === 'active' ? 'success' : calendar.status === 'frozen' ? 'warning' : 'destructive'}>
|
||||||
|
{calendar.status}
|
||||||
const emDash = t('common.emDash');
|
</Badge>
|
||||||
|
</dd>
|
||||||
const titleLabel = calendar.title || calendar.id;
|
</div>
|
||||||
|
<div className="grid grid-cols-[140px_1fr] gap-2">
|
||||||
|
<dt className="text-muted-foreground">{t('common.reason')}</dt>
|
||||||
|
<dd><DetailValue value={calendar.reason} emptyLabel={emDash} /></dd>
|
||||||
return (
|
</div>
|
||||||
|
<div className="grid grid-cols-[140px_1fr] gap-2">
|
||||||
<>
|
<dt className="text-muted-foreground">{t('common.category')}</dt>
|
||||||
|
<dd><DetailValue value={calendar.category} emptyLabel={emDash} /></dd>
|
||||||
<PageHeader
|
</div>
|
||||||
|
<div className="grid grid-cols-[140px_1fr] gap-2">
|
||||||
title={t('calendars.detailTitle', { name: titleLabel })}
|
<dt className="text-muted-foreground">{t('common.owner')}</dt>
|
||||||
|
<dd>
|
||||||
breadcrumbs={[
|
{!isBadValue(calendar.owner_id) ? (
|
||||||
|
<Link to={`/users/${calendar.owner_id}`} className="text-primary hover:underline">
|
||||||
{ label: t('calendars.title'), href: '/calendars' },
|
{calendar.owner_id}
|
||||||
|
</Link>
|
||||||
{ label: String(titleLabel) },
|
) : emDash}
|
||||||
|
</dd>
|
||||||
]}
|
</div>
|
||||||
|
<div className="grid grid-cols-[140px_1fr] gap-2">
|
||||||
actions={
|
<dt className="text-muted-foreground">{t('common.color')}</dt>
|
||||||
|
<dd><DetailValue value={calendar.color} emptyLabel={emDash} /></dd>
|
||||||
<Button variant="outline" onClick={() => navigate('/calendars')}>
|
</div>
|
||||||
|
<div className="grid grid-cols-[140px_1fr] gap-2">
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<dt className="text-muted-foreground">{t('common.image')}</dt>
|
||||||
|
<dd><DetailValue value={calendar.image_url} emptyLabel={emDash} /></dd>
|
||||||
{t('common.backToList')}
|
</div>
|
||||||
|
<div className="grid grid-cols-[140px_1fr] gap-2">
|
||||||
</Button>
|
<dt className="text-muted-foreground">{t('common.confirmation')}</dt>
|
||||||
|
<dd><DetailValue value={calendar.confirmation} emptyLabel={emDash} /></dd>
|
||||||
}
|
</div>
|
||||||
|
<div className="grid grid-cols-[140px_1fr] gap-2">
|
||||||
/>
|
<dt className="text-muted-foreground">{t('events.tags')}</dt>
|
||||||
|
<dd><DetailValue value={calendar.tags} emptyLabel={emDash} /></dd>
|
||||||
<Card>
|
</div>
|
||||||
|
<div className="grid grid-cols-[140px_1fr] gap-2">
|
||||||
<CardContent className="space-y-6">
|
<dt className="text-muted-foreground">{t('common.rating')}</dt>
|
||||||
|
<dd>{calendar.rating_avg} ({calendar.rating_count})</dd>
|
||||||
<dl className="grid gap-3 text-sm">
|
</div>
|
||||||
|
<div className="grid grid-cols-[140px_1fr] gap-2">
|
||||||
<div className="grid grid-cols-[140px_1fr] gap-2">
|
<dt className="text-muted-foreground">{t('common.settings')}</dt>
|
||||||
|
<dd><DetailValue value={calendar.settings} emptyLabel={emDash} /></dd>
|
||||||
<dt className="text-muted-foreground">{t('common.id')}</dt>
|
</div>
|
||||||
|
<div className="grid grid-cols-[140px_1fr] gap-2">
|
||||||
<dd>{calendar.id}</dd>
|
<dt className="text-muted-foreground">{t('common.createdAt')}</dt>
|
||||||
|
<dd>{formatDate(calendar.created_at)}</dd>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid grid-cols-[140px_1fr] gap-2">
|
||||||
<div className="grid grid-cols-[140px_1fr] gap-2">
|
<dt className="text-muted-foreground">{t('common.updatedAt')}</dt>
|
||||||
|
<dd>{formatDate(calendar.updated_at)}</dd>
|
||||||
<dt className="text-muted-foreground">{t('common.title')}</dt>
|
</div>
|
||||||
|
</dl>
|
||||||
<dd>{calendar.title}</dd>
|
|
||||||
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
|
</div>
|
||||||
<div className="grid grid-cols-[140px_1fr] gap-2">
|
);
|
||||||
|
};
|
||||||
<dt className="text-muted-foreground">{t('common.shortName')}</dt>
|
|
||||||
|
export default CalendarDetailPage;
|
||||||
|
|||||||
@@ -286,12 +286,14 @@ const CalendarListPage: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ExploreListShell
|
<ExploreListShell
|
||||||
|
data-testid="page-calendars"
|
||||||
title={t('calendars.title')}
|
title={t('calendars.title')}
|
||||||
stats={exploreStats}
|
stats={exploreStats}
|
||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
onViewModeChange={setViewMode}
|
onViewModeChange={setViewMode}
|
||||||
toolbar={
|
toolbar={
|
||||||
<ExploreSearch
|
<ExploreSearch
|
||||||
|
data-testid="filter-calendars-search"
|
||||||
value={searchDraft}
|
value={searchDraft}
|
||||||
onChange={setSearchDraft}
|
onChange={setSearchDraft}
|
||||||
onSubmit={applySearch}
|
onSubmit={applySearch}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ const EventDetailPage: React.FC = () => {
|
|||||||
const titleLabel = event.title || event.id;
|
const titleLabel = event.title || event.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div data-testid="page-event-detail">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={t('events.detailTitle', { name: titleLabel })}
|
title={t('events.detailTitle', { name: titleLabel })}
|
||||||
breadcrumbs={[
|
breadcrumbs={[
|
||||||
@@ -69,7 +69,7 @@ const EventDetailPage: React.FC = () => {
|
|||||||
{ label: String(titleLabel) },
|
{ label: String(titleLabel) },
|
||||||
]}
|
]}
|
||||||
actions={
|
actions={
|
||||||
<Button variant="outline" onClick={() => navigate('/events')}>
|
<Button variant="outline" data-testid="btn-back" onClick={() => navigate('/events')}>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
{t('common.backToList')}
|
{t('common.backToList')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -190,7 +190,7 @@ const EventDetailPage: React.FC = () => {
|
|||||||
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -376,6 +376,7 @@ const EventListPage: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ExploreListShell
|
<ExploreListShell
|
||||||
|
data-testid="page-events"
|
||||||
title={t('events.title')}
|
title={t('events.title')}
|
||||||
stats={exploreStats}
|
stats={exploreStats}
|
||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
@@ -384,6 +385,7 @@ const EventListPage: React.FC = () => {
|
|||||||
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
|
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<ExploreSearch
|
<ExploreSearch
|
||||||
|
data-testid="filter-events-search"
|
||||||
value={searchDraft}
|
value={searchDraft}
|
||||||
onChange={setSearchDraft}
|
onChange={setSearchDraft}
|
||||||
onSubmit={applySearch}
|
onSubmit={applySearch}
|
||||||
|
|||||||
@@ -286,7 +286,7 @@ const MonitoringPage: React.FC = () => {
|
|||||||
const healthy = summary.online > 0 && summary.cpuPeak < 85;
|
const healthy = summary.online > 0 && summary.cpuPeak < 85;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div data-testid="page-monitoring" className="space-y-6">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
@@ -303,11 +303,12 @@ const MonitoringPage: React.FC = () => {
|
|||||||
{t('monitoring.subtitle', { minutes })}
|
{t('monitoring.subtitle', { minutes })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5" data-testid="filter-monitoring-range">
|
||||||
{TIME_RANGE_KEYS.map((r) => (
|
{TIME_RANGE_KEYS.map((r) => (
|
||||||
<Button
|
<Button
|
||||||
key={r.value}
|
key={r.value}
|
||||||
size="sm"
|
size="sm"
|
||||||
|
data-testid={`filter-monitoring-range-${r.value}`}
|
||||||
variant={minutes === r.value ? 'default' : 'outline'}
|
variant={minutes === r.value ? 'default' : 'outline'}
|
||||||
onClick={() => setMinutes(r.value)}
|
onClick={() => setMinutes(r.value)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ const ProfilePage: React.FC = () => {
|
|||||||
const emDash = t('common.emDash');
|
const emDash = t('common.emDash');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="max-w-2xl">
|
<Card data-testid="page-profile" className="max-w-2xl">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('profile.title')}</CardTitle>
|
<CardTitle>{t('profile.title')}</CardTitle>
|
||||||
<CardDescription>{t('profile.description')}</CardDescription>
|
<CardDescription>{t('profile.description')}</CardDescription>
|
||||||
@@ -232,7 +232,7 @@ const ProfilePage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
<Button onClick={startEditing}>
|
<Button data-testid="btn-edit-profile" onClick={startEditing}>
|
||||||
<Pencil className="h-4 w-4" />
|
<Pencil className="h-4 w-4" />
|
||||||
{t('profile.edit')}
|
{t('profile.edit')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -309,10 +309,10 @@ const ProfilePage: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button type="submit" disabled={updateProfile.isPending}>
|
<Button type="submit" data-testid="btn-save-profile" disabled={updateProfile.isPending}>
|
||||||
{updateProfile.isPending ? t('common.saving') : t('common.save')}
|
{updateProfile.isPending ? t('common.saving') : t('common.save')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" variant="outline" onClick={() => setEditing(false)}>
|
<Button type="button" data-testid="btn-cancel-profile" variant="outline" onClick={() => setEditing(false)}>
|
||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -277,12 +277,13 @@ const SubscriptionListPage: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ExploreListShell
|
<ExploreListShell
|
||||||
|
data-testid="page-subscriptions"
|
||||||
title={t('subscriptions.title')}
|
title={t('subscriptions.title')}
|
||||||
stats={exploreStats}
|
stats={exploreStats}
|
||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
onViewModeChange={setViewMode}
|
onViewModeChange={setViewMode}
|
||||||
toolbar={
|
toolbar={
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2" data-testid="filter-subscriptions">
|
||||||
<Select
|
<Select
|
||||||
value={params.plan ?? ALL}
|
value={params.plan ?? ALL}
|
||||||
onValueChange={(val) =>
|
onValueChange={(val) =>
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ const UserDetailPage: React.FC = () => {
|
|||||||
const emDash = t('common.emDash');
|
const emDash = t('common.emDash');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div data-testid="page-user-detail">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={String(titleName)}
|
title={String(titleName)}
|
||||||
description={`ID ${user.id}`}
|
description={`ID ${user.id}`}
|
||||||
@@ -71,7 +71,7 @@ const UserDetailPage: React.FC = () => {
|
|||||||
{ label: String(titleName) },
|
{ label: String(titleName) },
|
||||||
]}
|
]}
|
||||||
actions={
|
actions={
|
||||||
<Button variant="outline" onClick={() => navigate('/users')}>
|
<Button variant="outline" data-testid="btn-back" onClick={() => navigate('/users')}>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
{t('common.backToList')}
|
{t('common.backToList')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -329,12 +329,14 @@ const UserListPage: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ExploreListShell
|
<ExploreListShell
|
||||||
|
data-testid="page-users"
|
||||||
title={t('users.title')}
|
title={t('users.title')}
|
||||||
stats={exploreStats}
|
stats={exploreStats}
|
||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
onViewModeChange={setViewMode}
|
onViewModeChange={setViewMode}
|
||||||
toolbar={
|
toolbar={
|
||||||
<ExploreSearch
|
<ExploreSearch
|
||||||
|
data-testid="filter-users-search"
|
||||||
value={searchDraft}
|
value={searchDraft}
|
||||||
onChange={setSearchDraft}
|
onChange={setSearchDraft}
|
||||||
onSubmit={applySearch}
|
onSubmit={applySearch}
|
||||||
@@ -356,7 +358,7 @@ const UserListPage: React.FC = () => {
|
|||||||
</ExploreListShell>
|
</ExploreListShell>
|
||||||
|
|
||||||
<Dialog open={editModal.open} onOpenChange={(open) => !open && setEditModal({ open: false, userId: null })}>
|
<Dialog open={editModal.open} onOpenChange={(open) => !open && setEditModal({ open: false, userId: null })}>
|
||||||
<DialogContent>
|
<DialogContent data-testid="dialog-edit-user">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t('users.editTitle')}</DialogTitle>
|
<DialogTitle>{t('users.editTitle')}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|||||||
Reference in New Issue
Block a user