// Admin user-edit Review-and-confirm dialog. // Verifies: // - The editor's Save button is disabled while the form is unchanged. // - Editing a single field opens the Review dialog with exactly one // diff row, and Confirm persists the change. // - Editing multiple fields + group memberships shows them all. // - "Back to edit" preserves the in-progress form state. // Runs in both `ar` and `en` to catch RTL layout regressions and to // confirm the new locale keys resolve in both languages. import { test, expect } from "@playwright/test"; import pg from "pg"; const DATABASE_URL = process.env.DATABASE_URL; if (!DATABASE_URL) { throw new Error("DATABASE_URL must be set to run this test"); } const TEST_PASSWORD = "TestPass123!"; const TEST_PASSWORD_HASH = "$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu"; const pool = new pg.Pool({ connectionString: DATABASE_URL }); const createdUserIds = []; const createdGroupNames = []; function uniqueSuffix() { return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`; } async function createUser(prefix, opts = {}) { const username = `${prefix}_${uniqueSuffix()}`; const { rows } = await pool.query( `INSERT INTO users (username, email, password_hash, display_name_ar, display_name_en, preferred_language, is_active) VALUES ($1, $2, $3, $4, $5, $6, true) RETURNING id`, [ username, `${username}@example.com`, TEST_PASSWORD_HASH, opts.displayNameAr ?? prefix, opts.displayNameEn ?? prefix, opts.preferredLanguage ?? "en", ], ); const id = rows[0].id; createdUserIds.push(id); const roleName = opts.admin ? "admin" : "user"; await pool.query( `INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = $2`, [id, roleName], ); return { id, username }; } async function createGroup(prefix) { const name = `${prefix}_${uniqueSuffix()}`; const { rows } = await pool.query( `INSERT INTO groups (name, description_en) VALUES ($1, 'edit-review test group') RETURNING id`, [name], ); createdGroupNames.push(name); return { id: rows[0].id, name }; } async function addUserToGroup(userId, groupId) { await pool.query( `INSERT INTO user_groups (user_id, group_id) VALUES ($1, $2)`, [userId, groupId], ); } test.afterAll(async () => { if (createdGroupNames.length > 0) { await pool.query( `DELETE FROM user_groups WHERE group_id IN (SELECT id FROM groups WHERE name = ANY($1::text[]))`, [createdGroupNames], ); await pool.query( `DELETE FROM groups WHERE name = ANY($1::text[])`, [createdGroupNames], ); } if (createdUserIds.length > 0) { await pool.query( `DELETE FROM user_roles WHERE user_id = ANY($1::int[])`, [createdUserIds], ); await pool.query( `DELETE FROM user_groups WHERE user_id = ANY($1::int[])`, [createdUserIds], ); await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [ createdUserIds, ]); } await pool.end(); }); async function login(page, username, password) { await page.goto("/login"); await page.locator("#username").fill(username); await page.locator("#password").fill(password); await Promise.all([ page.waitForURL((url) => !url.pathname.endsWith("/login"), { timeout: 15_000, }), page.locator('form button[type="submit"]').click(), ]); } async function setLanguage(page, lang) { // The app persists language via localStorage key `tx-lang` (see // artifacts/tx-os/src/i18n.ts). Set it before any page mount so the // very first render is in the chosen language. await page.addInitScript((l) => { try { window.localStorage.setItem("tx-lang", l); } catch {} }, lang); } const LABELS = { en: { title: "Review changes", back: "Back to edit", confirm: "Confirm & save", groupsAdded: "Groups added", groupsRemoved: "Groups removed", }, ar: { title: "مراجعة التغييرات", back: "رجوع للتعديل", confirm: "تأكيد وحفظ", groupsAdded: "مجموعات مُضافة", groupsRemoved: "مجموعات مُزالة", }, }; for (const lang of ["en", "ar"]) { test.describe(`Admin user edit review (${lang})`, () => { test("Save is disabled when nothing changed; review opens on edit; back preserves state; confirm saves", async ({ page, }) => { const labels = LABELS[lang]; const admin = await createUser("rev_admin", { admin: true, displayNameAr: "مراجع", displayNameEn: "Reviewer", preferredLanguage: lang, }); const target = await createUser("rev_target", { displayNameAr: "هدف قديم", displayNameEn: "Old Target", }); const groupA = await createGroup("revA"); const groupB = await createGroup("revB"); // Pre-attach groupA so the editor opens with one selected group. await addUserToGroup(target.id, groupA.id); await setLanguage(page, lang); await login(page, admin.username, TEST_PASSWORD); await page.goto("/admin#section=users"); // Find the target user's row and open the editor. const editBtn = page.locator(`[data-testid="user-edit-${target.id}"]`); await expect(editBtn).toBeVisible({ timeout: 10_000 }); await editBtn.click(); const saveBtn = page.locator('[data-testid="edit-user-save"]'); await expect(saveBtn).toBeVisible(); // 1) Pristine state → Save is disabled, no review. await expect(saveBtn).toBeDisabled(); // 2) Edit only the Arabic display name → review shows exactly one row. const arInput = page.locator('[data-testid="edit-user-display-ar"]'); await arInput.fill("هدف جديد"); await expect(saveBtn).toBeEnabled(); await saveBtn.click(); const reviewDialog = page.locator( '[data-testid="edit-user-review-dialog"]', ); await expect(reviewDialog).toBeVisible(); await expect(reviewDialog).toContainText(labels.title); await expect( reviewDialog.locator( '[data-testid="edit-user-review-change-displayNameAr"]', ), ).toBeVisible(); // Other field rows should NOT appear. await expect( reviewDialog.locator( '[data-testid="edit-user-review-change-displayNameEn"]', ), ).toHaveCount(0); // 3) Back to edit preserves form state. await page.locator('[data-testid="edit-user-review-back"]').click(); await expect(reviewDialog).toBeHidden(); await expect(arInput).toHaveValue("هدف جديد"); // 4) Make additional changes: toggle a group on, toggle the existing // one off, and change the language. Then re-open review. await page .locator('[data-testid="edit-user-group-search"]') .fill(groupB.name); await page .locator("label") .filter({ hasText: groupB.name }) .locator('input[type="checkbox"]') .check(); await page .locator('[data-testid="edit-user-group-search"]') .fill(groupA.name); await page .locator("label") .filter({ hasText: groupA.name }) .locator('input[type="checkbox"]') .uncheck(); await page .locator('[data-testid="edit-user-language"]') .selectOption("ar"); await saveBtn.click(); await expect(reviewDialog).toBeVisible(); await expect( reviewDialog.locator( '[data-testid="edit-user-review-change-displayNameAr"]', ), ).toBeVisible(); await expect( reviewDialog.locator( '[data-testid="edit-user-review-change-preferredLanguage"]', ), ).toBeVisible(); const added = reviewDialog.locator( '[data-testid="edit-user-review-group-added"]', ); const removed = reviewDialog.locator( '[data-testid="edit-user-review-group-removed"]', ); await expect(added).toBeVisible(); await expect(added).toContainText(groupB.name); await expect(added).toContainText(labels.groupsAdded); await expect(removed).toBeVisible(); await expect(removed).toContainText(groupA.name); await expect(removed).toContainText(labels.groupsRemoved); // 5) Confirm saves and closes both modals. await page.locator('[data-testid="edit-user-review-confirm"]').click(); await expect(reviewDialog).toBeHidden({ timeout: 10_000 }); await expect(saveBtn).toBeHidden(); // 6) Verify persistence in DB. const { rows: userRows } = await pool.query( `SELECT display_name_ar, preferred_language FROM users WHERE id = $1`, [target.id], ); expect(userRows[0].display_name_ar).toBe("هدف جديد"); expect(userRows[0].preferred_language).toBe("ar"); const { rows: groupRows } = await pool.query( `SELECT group_id FROM user_groups WHERE user_id = $1 ORDER BY group_id`, [target.id], ); const ids = groupRows.map((r) => r.group_id); expect(ids).toContain(groupB.id); expect(ids).not.toContain(groupA.id); }); // Keyboard accessibility: now that both dialogs use the in-house // Radix-based Dialog primitive, Escape should close just the topmost // dialog and Tab should stay trapped inside the open dialog. test("Escape closes topmost dialog; Tab keeps focus inside; focus returns to opener", async ({ page, }) => { const admin = await createUser("kbd_admin", { admin: true, displayNameAr: "لوحة المفاتيح", displayNameEn: "Keyboard", preferredLanguage: lang, }); const target = await createUser("kbd_target", { displayNameAr: "هدف لوحة", displayNameEn: "Kbd Target", }); await setLanguage(page, lang); await login(page, admin.username, TEST_PASSWORD); await page.goto("/admin#section=users"); const editBtn = page.locator(`[data-testid="user-edit-${target.id}"]`); await expect(editBtn).toBeVisible({ timeout: 10_000 }); await editBtn.click(); const editorDialog = page.locator( '[data-testid="edit-user-dialog"]', ); await expect(editorDialog).toBeVisible(); // Make a change so Save is enabled, then open the Review dialog. const arInput = page.locator('[data-testid="edit-user-display-ar"]'); await arInput.fill("هدف لوحة 2"); const saveBtn = page.locator('[data-testid="edit-user-save"]'); await expect(saveBtn).toBeEnabled(); await saveBtn.click(); const reviewDialog = page.locator( '[data-testid="edit-user-review-dialog"]', ); await expect(reviewDialog).toBeVisible(); // Escape should close ONLY the topmost (review) dialog, leaving the // editor open underneath. await page.keyboard.press("Escape"); await expect(reviewDialog).toBeHidden(); await expect(editorDialog).toBeVisible(); // After the review closes, focus returns to the Save button that // opened it. await expect(saveBtn).toBeFocused(); // Tab should stay trapped inside the editor dialog: after several // Tab presses the active element must still be a descendant of the // editor dialog (never something on the page behind it). for (let i = 0; i < 15; i++) { await page.keyboard.press("Tab"); const stillInside = await editorDialog.evaluate((el) => el.contains(document.activeElement), ); expect(stillInside).toBe(true); } // Shift+Tab also stays inside. for (let i = 0; i < 5; i++) { await page.keyboard.press("Shift+Tab"); const stillInside = await editorDialog.evaluate((el) => el.contains(document.activeElement), ); expect(stillInside).toBe(true); } // Escape on the editor closes it and returns focus to the pencil. await page.keyboard.press("Escape"); await expect(editorDialog).toBeHidden(); await expect(editBtn).toBeFocused(); }); }); }