From e0c586c0e23c394d17db6358ff4e811782dc3ac1 Mon Sep 17 00:00:00 2001 From: riyadhafraa <49757212-riyadhafraa@users.noreply.replit.com> Date: Wed, 29 Apr 2026 20:04:21 +0000 Subject: [PATCH] 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 --- artifacts/tx-os/src/locales/ar.json | 12 +- artifacts/tx-os/src/locales/en.json | 12 +- artifacts/tx-os/src/pages/admin.tsx | 240 ++++++++++++++-- .../tests/admin-user-edit-review.spec.mjs | 272 ++++++++++++++++++ 4 files changed, 511 insertions(+), 25 deletions(-) create mode 100644 artifacts/tx-os/tests/admin-user-edit-review.spec.mjs diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index b85852c9..3f139e0e 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -422,6 +422,15 @@ "createdAt": "تاريخ الإنشاء", "groups": "المجموعات", "actions": "إجراءات" + }, + "review": { + "title": "مراجعة التغييرات", + "subtitle": "أكّد ما سيتم حفظه للمستخدم {{username}}.", + "noChanges": "لا توجد تغييرات للحفظ", + "groupsAdded": "مجموعات مُضافة", + "groupsRemoved": "مجموعات مُزالة", + "back": "رجوع للتعديل", + "confirm": "تأكيد وحفظ" } }, "roles": { @@ -766,7 +775,8 @@ "language": "English", "appName": "نظام Tx", "previous": "السابق", - "next": "التالي" + "next": "التالي", + "empty": "(فارغ)" }, "notes": { "title": "الملاحظات", diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index 03ca1eca..32e03e8e 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -419,6 +419,15 @@ "createdAt": "Created", "groups": "Groups", "actions": "Actions" + }, + "review": { + "title": "Review changes", + "subtitle": "Confirm what will be saved for {{username}}.", + "noChanges": "No changes to save", + "groupsAdded": "Groups added", + "groupsRemoved": "Groups removed", + "back": "Back to edit", + "confirm": "Confirm & save" } }, "roles": { @@ -703,7 +712,8 @@ "language": "العربية", "appName": "Tx OS", "previous": "Previous", - "next": "Next" + "next": "Next", + "empty": "(empty)" }, "notes": { "title": "Notes", diff --git a/artifacts/tx-os/src/pages/admin.tsx b/artifacts/tx-os/src/pages/admin.tsx index 6b82f936..15f717f0 100644 --- a/artifacts/tx-os/src/pages/admin.tsx +++ b/artifacts/tx-os/src/pages/admin.tsx @@ -2749,6 +2749,12 @@ function UsersPanel({ ); } +type UserEditChangeKey = + | "displayNameAr" + | "displayNameEn" + | "preferredLanguage" + | "isActive"; + function UserGroupsEditor({ user, groups, @@ -2763,12 +2769,34 @@ function UserGroupsEditor({ const { t } = useTranslation(); const { toast } = useToast(); const updateUser = useUpdateUser(); - const [selected, setSelected] = useState>(new Set(user.groups?.map((g) => g.id) ?? [])); - const [displayNameAr, setDisplayNameAr] = useState(user.displayNameAr ?? ""); - const [displayNameEn, setDisplayNameEn] = useState(user.displayNameEn ?? ""); - const [preferredLanguage, setPreferredLanguage] = useState(user.preferredLanguage || "ar"); - const [isActive, setIsActive] = useState(user.isActive); + + const original = useMemo( + () => ({ + groupIds: new Set(user.groups?.map((g) => g.id) ?? []), + displayNameAr: user.displayNameAr ?? "", + displayNameEn: user.displayNameEn ?? "", + preferredLanguage: user.preferredLanguage || "ar", + isActive: user.isActive, + }), + [user.id], + ); + + const [selected, setSelected] = useState>(new Set(original.groupIds)); + const [displayNameAr, setDisplayNameAr] = useState(original.displayNameAr); + const [displayNameEn, setDisplayNameEn] = useState(original.displayNameEn); + const [preferredLanguage, setPreferredLanguage] = useState(original.preferredLanguage); + const [isActive, setIsActive] = useState(original.isActive); const [groupSearch, setGroupSearch] = useState(""); + const [reviewOpen, setReviewOpen] = useState(false); + + useEffect(() => { + if (!reviewOpen) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setReviewOpen(false); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [reviewOpen]); const toggle = (id: number) => { const next = new Set(selected); @@ -2781,6 +2809,93 @@ function UserGroupsEditor({ !groupSearch.trim() || g.name.toLowerCase().includes(groupSearch.trim().toLowerCase()), ); + const groupNameById = useMemo(() => { + const map = new Map(); + for (const g of groups) map.set(g.id, g.name); + return map; + }, [groups]); + + const fieldChanges = useMemo(() => { + const out: { key: UserEditChangeKey; before: unknown; after: unknown }[] = []; + if (displayNameAr.trim() !== original.displayNameAr.trim()) { + out.push({ key: "displayNameAr", before: original.displayNameAr, after: displayNameAr }); + } + if (displayNameEn.trim() !== original.displayNameEn.trim()) { + out.push({ key: "displayNameEn", before: original.displayNameEn, after: displayNameEn }); + } + if (preferredLanguage !== original.preferredLanguage) { + out.push({ key: "preferredLanguage", before: original.preferredLanguage, after: preferredLanguage }); + } + if (isActive !== original.isActive) { + out.push({ key: "isActive", before: original.isActive, after: isActive }); + } + return out; + }, [displayNameAr, displayNameEn, preferredLanguage, isActive, original]); + + const groupDelta = useMemo(() => { + const added: string[] = []; + const removed: string[] = []; + for (const id of selected) { + if (!original.groupIds.has(id)) added.push(groupNameById.get(id) ?? `#${id}`); + } + for (const id of original.groupIds) { + if (!selected.has(id)) removed.push(groupNameById.get(id) ?? `#${id}`); + } + return { added, removed }; + }, [selected, original.groupIds, groupNameById]); + + const hasChanges = + fieldChanges.length > 0 || groupDelta.added.length > 0 || groupDelta.removed.length > 0; + + const fieldLabel = (key: UserEditChangeKey): string => { + switch (key) { + case "displayNameAr": + return t("admin.users.col.displayNameAr"); + case "displayNameEn": + return t("admin.users.col.displayNameEn"); + case "preferredLanguage": + return t("admin.users.col.language"); + case "isActive": + return t("admin.users.statusActive"); + } + }; + + const renderValue = (key: UserEditChangeKey, value: unknown): string => { + if (key === "isActive") { + return value + ? t("admin.users.statusActive") + : t("admin.users.statusInactive"); + } + if (key === "preferredLanguage") { + return value === "ar" ? "العربية" : "English"; + } + const str = typeof value === "string" ? value.trim() : String(value ?? ""); + return str === "" ? t("common.empty") : str; + }; + + const handleConfirmSave = () => { + updateUser.mutate( + { + id: user.id, + data: { + groupIds: Array.from(selected), + displayNameAr: displayNameAr.trim() || null, + displayNameEn: displayNameEn.trim() || null, + preferredLanguage, + isActive, + }, + }, + { + onSuccess: () => { + toast({ title: t("common.success") }); + setReviewOpen(false); + onSaved(); + }, + onError: () => toast({ title: t("common.error"), variant: "destructive" }), + }, + ); + }; + return (
@@ -2847,29 +2962,108 @@ function UserGroupsEditor({
+ + {reviewOpen && ( +
+
+
+

{t("admin.users.review.title")}

+

+ {t("admin.users.review.subtitle", { username: user.username })} +

+
+
    + {fieldChanges.map((c) => ( +
  • + {fieldLabel(c.key)}: + + {renderValue(c.key, c.before)} + + + + {renderValue(c.key, c.after)} + +
  • + ))} +
+ {groupDelta.added.length > 0 && ( +
+

+ {t("admin.users.review.groupsAdded")} +

+
+ {groupDelta.added.map((name) => ( + + {name} + + ))} +
+
+ )} + {groupDelta.removed.length > 0 && ( +
+

+ {t("admin.users.review.groupsRemoved")} +

+
+ {groupDelta.removed.map((name) => ( + + {name} + + ))} +
+
+ )} +
+ + +
+
+
+ )} ); } diff --git a/artifacts/tx-os/tests/admin-user-edit-review.spec.mjs b/artifacts/tx-os/tests/admin-user-edit-review.spec.mjs new file mode 100644 index 00000000..9379a082 --- /dev/null +++ b/artifacts/tx-os/tests/admin-user-edit-review.spec.mjs @@ -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); + }); + }); +}