Files
TX/artifacts/tx-os/tests/admin-user-edit-review.spec.mjs
T
riyadhafraa 699cfafcb4 Migrate admin user editor + Review pop-up to in-house Dialog (a11y)
Original task: Make admin pop-ups close with Escape and trap keyboard
focus (#185).

Changes
- artifacts/tx-os/src/pages/admin.tsx
  - UserGroupsEditor and its inner "Review changes" pop-up no longer
    use the hand-rolled fixed-inset overlay. Both now use the in-house
    Radix-based Dialog (Dialog/DialogContent/DialogHeader/DialogTitle/
    DialogDescription/DialogFooter) so Escape closes only the topmost
    dialog, Tab/Shift+Tab is trapped, and the close button + aria-modal
    semantics come for free.
  - Removed the manual `keydown` Escape useEffect that was previously
    needed for the Review pop-up.
  - Preserved all existing data-testids (edit-user-save,
    edit-user-review-dialog, edit-user-review-back, etc.) plus added
    a new edit-user-dialog testid for the editor shell.
  - Kept the original glass-panel look by passing border-0 / shadow-none
    and rounded-3xl through DialogContent's className.
  - Focus return: Radix's automatic restoration is unreliable for
    nested dialogs. Added two explicit hops:
      - The Review dialog uses onCloseAutoFocus to refocus the Save
        button via a saveBtnRef.
      - The editor dialog captures whatever was focused when it first
        rendered (the pencil icon) into triggerElementRef, and
        onCloseAutoFocus refocuses it. Verified the pencil button is
        what the test sees.

Tests
- artifacts/tx-os/tests/admin-user-edit-review.spec.mjs
  - New test "Escape closes topmost dialog; Tab keeps focus inside;
    focus returns to opener" runs in both en and ar:
      - Esc closes only the Review (editor stays open) and focus
        returns to the Save button.
      - Tab x15 and Shift+Tab x5 keep document.activeElement inside
        the editor dialog.
      - Esc on the editor closes it and returns focus to the pencil.
  - All 4 specs in this file pass (~58s).

No deviations from the task; scope intentionally limited to the user
editor + Review pop-up. The same pattern in the Groups/Roles/Apps
editors was filed as follow-up #266.

Replit-Task-Id: fc613d10-06d0-460f-b1a4-0a65ff021d4a
2026-05-01 09:57:47 +00:00

351 lines
12 KiB
JavaScript

// 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();
});
});
}