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:
@@ -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": "الملاحظات",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user