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:
riyadhafraa
2026-04-29 20:04:21 +00:00
parent 4e9cb38ab1
commit e0c586c0e2
4 changed files with 511 additions and 25 deletions
+11 -1
View File
@@ -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": "الملاحظات",
+11 -1
View File
@@ -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",
+217 -23
View File
@@ -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<Set<number>>(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<number>(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<Set<number>>(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<number, string>();
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 (
<div className="fixed inset-0 bg-slate-900/30 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="glass-panel rounded-3xl p-6 w-full max-w-md space-y-3 max-h-[85vh] overflow-y-auto">
@@ -2847,29 +2962,108 @@ function UserGroupsEditor({
<Button variant="outline" className="flex-1" onClick={onClose}>{t("common.cancel")}</Button>
<Button
className="flex-1"
onClick={() =>
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") }); onSaved(); },
onError: () => toast({ title: t("common.error"), variant: "destructive" }),
},
)
}
disabled={!hasChanges}
title={hasChanges ? undefined : t("admin.users.review.noChanges")}
onClick={() => setReviewOpen(true)}
data-testid="edit-user-save"
>
{t("common.save")}
</Button>
</div>
</div>
{reviewOpen && (
<div
className="fixed inset-0 bg-slate-900/50 backdrop-blur-sm z-[60] flex items-center justify-center p-4"
data-testid="edit-user-review-dialog"
role="dialog"
aria-modal="true"
>
<div className="glass-panel rounded-3xl p-6 w-full max-w-md space-y-4 max-h-[85vh] overflow-y-auto">
<div className="space-y-1">
<h3 className="font-semibold text-foreground">{t("admin.users.review.title")}</h3>
<p className="text-xs text-muted-foreground">
{t("admin.users.review.subtitle", { username: user.username })}
</p>
</div>
<ul className="space-y-2 text-sm">
{fieldChanges.map((c) => (
<li
key={c.key}
className="flex flex-wrap items-baseline gap-x-2 gap-y-1"
data-testid={`edit-user-review-change-${c.key}`}
>
<span className="text-muted-foreground">{fieldLabel(c.key)}:</span>
<span className="line-through text-muted-foreground/70">
{renderValue(c.key, c.before)}
</span>
<span className="text-muted-foreground"></span>
<span className="font-medium text-foreground">
{renderValue(c.key, c.after)}
</span>
</li>
))}
</ul>
{groupDelta.added.length > 0 && (
<div data-testid="edit-user-review-group-added" className="space-y-1">
<h4 className="text-xs font-medium text-foreground">
{t("admin.users.review.groupsAdded")}
</h4>
<div className="flex flex-wrap gap-1">
{groupDelta.added.map((name) => (
<span
key={`add-${name}`}
className="text-[11px] px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-800"
>
{name}
</span>
))}
</div>
</div>
)}
{groupDelta.removed.length > 0 && (
<div data-testid="edit-user-review-group-removed" className="space-y-1">
<h4 className="text-xs font-medium text-foreground">
{t("admin.users.review.groupsRemoved")}
</h4>
<div className="flex flex-wrap gap-1">
{groupDelta.removed.map((name) => (
<span
key={`rem-${name}`}
className="text-[11px] px-2 py-0.5 rounded-full bg-rose-100 text-rose-800 line-through"
>
{name}
</span>
))}
</div>
</div>
)}
<div className="flex gap-2 pt-1">
<Button
variant="outline"
className="flex-1"
disabled={updateUser.isPending}
onClick={() => setReviewOpen(false)}
data-testid="edit-user-review-back"
>
{t("admin.users.review.back")}
</Button>
<Button
className="flex-1"
disabled={updateUser.isPending}
onClick={handleConfirmSave}
data-testid="edit-user-review-confirm"
>
{updateUser.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
t("admin.users.review.confirm")
)}
</Button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -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);
});
});
}