699cfafcb4
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
351 lines
12 KiB
JavaScript
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();
|
|
});
|
|
});
|
|
}
|