Add review changes dialog and improve language handling
Introduce a review changes dialog for user edits and fix language persistence by correcting the localStorage key to "tx-lang". Replit-Commit-Author: Agent Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: 1a5f18f9-0674-406a-af5d-19b28df896fc Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/wYObB6M Replit-Helium-Checkpoint-Created: true
This commit is contained in:
@@ -0,0 +1,272 @@
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user