Task #220: Insert persons mid-list + drag-reorder attendees
Original request: Add a per-section "+ شخص هنا" inline chip in the
attendees cell so users can splice a new person right after a
subheading without opening the manage dialog, and add drag-and-drop
reordering inside the manage dialog (with up/down arrows kept as a
keyboard fallback).
Changes (artifacts/tx-os/src/pages/executive-meetings.tsx):
- Attendee gets an optional client-only `_sid` (stripped by the
manage-save projection so it never crosses the wire).
- AttendeeFlow now renders a "+ شخص هنا" chip right after every
subheading row; clicking it opens the pending-add editor anchored
to that section's tail and threads `insertAtIndex` through to
`commitAddAttendee`, which splices + renumbers contiguously.
- Manage dialog wraps its attendee list in `DndContext` +
`SortableContext` (vertical strategy). Each row is now a
`SortableAttendeeRow` driven by `useSortable`; only the new
`GripVertical` handle is wired to dnd-kit listeners so inputs,
selects, chevrons and the delete button stay interactive.
- `reorderAttendeesByDrag(activeSid, overSid)`, memoised
`sortableIds`, and pointer + keyboard sensors live on the manage
section. `openEdit` and add-row helpers stamp `_sid` so each row
has a stable React key.
- AR/EN locale keys for the chip label/aria and drag handle aria
were already in place from the prior compaction.
Tests (new): artifacts/tx-os/tests/executive-meetings-attendee-insert-reorder.spec.mjs
- 6/6 passing (3 scenarios × AR + EN):
1) "+ person here" inserts at the right slot after a subheading.
2) Drag-reorder via keyboard (Space + ArrowDown) inside the manage
dialog persists the new order.
3) Mixed person + subheading drag preserves `kind` and order
across the round-trip (architect-flagged coverage).
Verification:
- 6/6 new e2e pass (45.3s).
- 6/6 existing subheading e2e still pass (Task #207 untouched).
- tx-os typecheck clean for executive-meetings.tsx (admin.tsx errors
are pre-existing/unrelated codegen drift).
- Architect review: PASS — DnD wiring composed correctly, `_sid`
properly stripped from save payload, no security findings.
Follow-ups proposed: #221 (assert _sid never leaks to API) and #222
(quick-add chip between any two persons, not just after subheadings).
This commit is contained in:
@@ -0,0 +1,511 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import pg from "pg";
|
||||
|
||||
// E2E coverage for Task #220 — "Insert persons mid-list and drag-reorder
|
||||
// attendees" inside executive-meetings.
|
||||
//
|
||||
// What we verify:
|
||||
// 1. Per-section "+ شخص هنا" chip on the schedule grid: clicking the
|
||||
// inline chip rendered after a subheading opens a ghost row at the
|
||||
// slot right after that subheading (not at the trailing slot), and
|
||||
// committing the typed name persists the new attendee BETWEEN the
|
||||
// subheading and the next existing person.
|
||||
// 2. Drag-and-drop reordering inside the manage dialog: picking up a
|
||||
// row by its grip handle and moving it down with the keyboard
|
||||
// reorders the attendee array, and saving persists the new order.
|
||||
// Both scenarios run in BOTH locales so the RTL/LTR mirror behaves the
|
||||
// same way.
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
if (!DATABASE_URL) {
|
||||
throw new Error(
|
||||
"DATABASE_URL must be set to run the executive-meetings insert/reorder UI tests",
|
||||
);
|
||||
}
|
||||
|
||||
const pool = new pg.Pool({ connectionString: DATABASE_URL });
|
||||
const createdMeetingIds = [];
|
||||
|
||||
// Pick a far-future, randomised base so we never collide with other
|
||||
// specs that also seed in the future.
|
||||
const RANDOM_DATE_BASE_DAYS =
|
||||
2200 + Math.floor(Math.random() * 1000) + ((Date.now() / 1000) | 0) % 500;
|
||||
function uniqueFutureDate(offsetDays) {
|
||||
const d = new Date();
|
||||
d.setUTCDate(d.getUTCDate() + RANDOM_DATE_BASE_DAYS + offsetDays * 7);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
async function insertMeeting({
|
||||
meetingDate,
|
||||
dailyNumber,
|
||||
titleEn,
|
||||
titleAr,
|
||||
startTime,
|
||||
endTime,
|
||||
}) {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO executive_meetings
|
||||
(daily_number, title_ar, title_en, meeting_date, start_time, end_time, status)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'scheduled')
|
||||
RETURNING id`,
|
||||
[dailyNumber, titleAr, titleEn, meetingDate, startTime, endTime],
|
||||
);
|
||||
const id = rows[0].id;
|
||||
createdMeetingIds.push(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
async function insertAttendee({
|
||||
meetingId,
|
||||
name,
|
||||
attendanceType,
|
||||
sortOrder,
|
||||
kind,
|
||||
}) {
|
||||
await pool.query(
|
||||
`INSERT INTO executive_meeting_attendees
|
||||
(meeting_id, name, attendance_type, sort_order, kind)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
[meetingId, name, attendanceType, sortOrder, kind ?? "person"],
|
||||
);
|
||||
}
|
||||
|
||||
async function getAttendeesOrdered(meetingId) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT name, attendance_type, sort_order, kind
|
||||
FROM executive_meeting_attendees
|
||||
WHERE meeting_id = $1
|
||||
ORDER BY sort_order ASC`,
|
||||
[meetingId],
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function loginViaUi(page) {
|
||||
await page.goto("/login");
|
||||
await page.locator("#username").fill("admin");
|
||||
await page.locator("#password").fill("admin123");
|
||||
await Promise.all([
|
||||
page.waitForURL((url) => !url.pathname.endsWith("/login"), {
|
||||
timeout: 15_000,
|
||||
}),
|
||||
page.locator('form button[type="submit"]').click(),
|
||||
]);
|
||||
}
|
||||
|
||||
let originalAdminPreferredLanguage = null;
|
||||
async function setAdminPreferredLanguage(lang) {
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE users SET preferred_language = $1
|
||||
WHERE username = 'admin'
|
||||
RETURNING preferred_language`,
|
||||
[lang],
|
||||
);
|
||||
return rows[0]?.preferred_language;
|
||||
}
|
||||
|
||||
// Toggle the global edit-mode toggle so the inline "+ شخص هنا" chip and
|
||||
// inline editing affordances render. The toggle lives in the schedule
|
||||
// toolbar with data-testid="em-edit-mode-toggle".
|
||||
async function ensureEditMode(page) {
|
||||
const toggle = page.getByTestId("em-edit-mode-toggle");
|
||||
await expect(toggle).toBeVisible({ timeout: 10_000 });
|
||||
const pressed = await toggle.getAttribute("aria-pressed");
|
||||
if (pressed !== "true") {
|
||||
await toggle.click();
|
||||
// Wait for the aria-pressed state to actually flip — the localStorage
|
||||
// write + state setter is async-ish in React, and clicking the chip
|
||||
// before edit mode commits would silently miss the conditional render.
|
||||
await expect(toggle).toHaveAttribute("aria-pressed", "true", {
|
||||
timeout: 5_000,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test.beforeAll(async () => {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT preferred_language FROM users WHERE username = 'admin'`,
|
||||
);
|
||||
originalAdminPreferredLanguage = rows[0]?.preferred_language ?? "ar";
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
if (originalAdminPreferredLanguage) {
|
||||
await pool
|
||||
.query(`UPDATE users SET preferred_language = $1 WHERE username = 'admin'`, [
|
||||
originalAdminPreferredLanguage,
|
||||
])
|
||||
.catch(() => {
|
||||
/* best-effort */
|
||||
});
|
||||
}
|
||||
if (createdMeetingIds.length > 0) {
|
||||
await pool.query(
|
||||
`DELETE FROM executive_meeting_attendees WHERE meeting_id = ANY($1::int[])`,
|
||||
[createdMeetingIds],
|
||||
);
|
||||
await pool.query(
|
||||
`DELETE FROM executive_meeting_audit_logs WHERE entity_type = 'meeting' AND entity_id = ANY($1::int[])`,
|
||||
[createdMeetingIds],
|
||||
);
|
||||
await pool.query(
|
||||
`DELETE FROM executive_meetings WHERE id = ANY($1::int[])`,
|
||||
[createdMeetingIds],
|
||||
);
|
||||
}
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
const langOffsets = { en: 1, ar: 2 };
|
||||
const langOffsets2 = { en: 3, ar: 4 };
|
||||
const langOffsets3 = { en: 5, ar: 6 };
|
||||
|
||||
for (const lang of ["en", "ar"]) {
|
||||
test(`Schedule [${lang}]: "+ person here" chip after a subheading inserts at that slot`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const date = uniqueFutureDate(langOffsets[lang]);
|
||||
const meetingId = await insertMeeting({
|
||||
meetingDate: date,
|
||||
dailyNumber: 1,
|
||||
titleEn: `Insert Mid Section ${lang} ${Date.now().toString(36)}`,
|
||||
titleAr: `إدراج وسط القسم ${lang} ${Date.now().toString(36)}`,
|
||||
startTime: "09:00:00",
|
||||
endTime: "10:00:00",
|
||||
});
|
||||
// Internal section with subheading wedged between persons. After the
|
||||
// user clicks "+ person here" right after "Sec2Header" we want the
|
||||
// new person to land at sort_order 3, pushing "PersonGamma" to 4.
|
||||
await insertAttendee({
|
||||
meetingId,
|
||||
name: "PersonAlpha",
|
||||
attendanceType: "internal",
|
||||
sortOrder: 0,
|
||||
kind: "person",
|
||||
});
|
||||
await insertAttendee({
|
||||
meetingId,
|
||||
name: "PersonBeta",
|
||||
attendanceType: "internal",
|
||||
sortOrder: 1,
|
||||
kind: "person",
|
||||
});
|
||||
await insertAttendee({
|
||||
meetingId,
|
||||
name: "Sec2Header-Q7",
|
||||
attendanceType: "internal",
|
||||
sortOrder: 2,
|
||||
kind: "subheading",
|
||||
});
|
||||
await insertAttendee({
|
||||
meetingId,
|
||||
name: "PersonGamma",
|
||||
attendanceType: "internal",
|
||||
sortOrder: 3,
|
||||
kind: "person",
|
||||
});
|
||||
|
||||
await setAdminPreferredLanguage(lang);
|
||||
try {
|
||||
await loginViaUi(page);
|
||||
await page.goto("/executive-meetings");
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", lang, {
|
||||
timeout: 5_000,
|
||||
});
|
||||
await page.locator('input[type="date"]').first().fill(date);
|
||||
|
||||
const row = page.getByTestId(`em-row-${meetingId}`);
|
||||
await expect(row).toBeVisible({ timeout: 15_000 });
|
||||
await ensureEditMode(page);
|
||||
|
||||
// The "+ person here" chip renders right below each subheading
|
||||
// row, indexed by the subheading's array position (2 here).
|
||||
const chip = row.getByTestId("em-add-person-here-2");
|
||||
await expect(chip).toBeVisible({ timeout: 10_000 });
|
||||
await chip.click();
|
||||
|
||||
// The pending ghost <li> appears, with a Tiptap editor that has
|
||||
// started in edit mode. Type the new name and blur to commit.
|
||||
const pending = row.getByTestId("em-add-attendee-pending-internal");
|
||||
await expect(pending).toBeVisible({ timeout: 5_000 });
|
||||
// The EditableCell mounts a contenteditable div — find it inside
|
||||
// the pending wrapper and type into it.
|
||||
const editor = pending.locator('[contenteditable="true"]').first();
|
||||
await expect(editor).toBeVisible({ timeout: 5_000 });
|
||||
await editor.click();
|
||||
await editor.fill("InsertedDelta");
|
||||
// Blur the editor (forceSaveOnBlur fires the commit) by clicking
|
||||
// the meeting title cell, which is unrelated to the attendee
|
||||
// input and won't open another editor.
|
||||
await page.locator("body").click({ position: { x: 5, y: 5 } });
|
||||
|
||||
// Wait for the round-trip: the cell should now show 4 person
|
||||
// rows and 1 subheading.
|
||||
await expect(
|
||||
row.locator('[data-testid^="em-attendee-row-"]'),
|
||||
).toHaveCount(4, { timeout: 10_000 });
|
||||
await expect(
|
||||
row.locator('[data-testid^="em-attendee-subheading-"]'),
|
||||
).toHaveCount(1);
|
||||
|
||||
// Verify the persisted order via DB — the new person must land
|
||||
// BETWEEN the subheading and the previously-trailing person.
|
||||
const persisted = await getAttendeesOrdered(meetingId);
|
||||
// The Tiptap editor stores names as rich HTML (e.g. "<p>X</p>"),
|
||||
// while DB-seeded rows are plain strings. Strip tags before
|
||||
// comparing so the assertion stays focused on order, not on the
|
||||
// wrapper element the editor happens to emit.
|
||||
const stripTags = (s) => s.replace(/<[^>]+>/g, "").trim();
|
||||
const orderedNames = persisted.map((r) => stripTags(r.name));
|
||||
expect(orderedNames).toEqual([
|
||||
"PersonAlpha",
|
||||
"PersonBeta",
|
||||
"Sec2Header-Q7",
|
||||
"InsertedDelta",
|
||||
"PersonGamma",
|
||||
]);
|
||||
// sort_order must be a contiguous 0..N-1 sequence after the
|
||||
// splice + renumber path in commitAddAttendee.
|
||||
expect(persisted.map((r) => r.sort_order)).toEqual([0, 1, 2, 3, 4]);
|
||||
|
||||
// Numbering: section 1 = [Alpha, Beta] → 1-, 2-; section 2 =
|
||||
// [InsertedDelta, Gamma] → 1-, 2- (cell has a subheading so all
|
||||
// persons are numbered).
|
||||
const indexTexts = await row
|
||||
.locator('[data-testid^="em-attendee-index-"]')
|
||||
.allInnerTexts();
|
||||
const trimmed = indexTexts.map((s) => s.trim().replace(/\s+/g, ""));
|
||||
expect(trimmed).toEqual(["1-", "2-", "1-", "2-"]);
|
||||
} finally {
|
||||
if (originalAdminPreferredLanguage) {
|
||||
await setAdminPreferredLanguage(originalAdminPreferredLanguage).catch(
|
||||
() => {
|
||||
/* best-effort; afterAll also restores */
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test(`Manage [${lang}]: drag handle reorders attendees inside the dialog`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const date = uniqueFutureDate(langOffsets2[lang]);
|
||||
const meetingId = await insertMeeting({
|
||||
meetingDate: date,
|
||||
dailyNumber: 1,
|
||||
titleEn: `Drag Reorder ${lang} ${Date.now().toString(36)}`,
|
||||
titleAr: `سحب وإعادة ترتيب ${lang} ${Date.now().toString(36)}`,
|
||||
startTime: "11:00:00",
|
||||
endTime: "12:00:00",
|
||||
});
|
||||
await insertAttendee({
|
||||
meetingId,
|
||||
name: "Drag-A",
|
||||
attendanceType: "internal",
|
||||
sortOrder: 0,
|
||||
kind: "person",
|
||||
});
|
||||
await insertAttendee({
|
||||
meetingId,
|
||||
name: "Drag-B",
|
||||
attendanceType: "internal",
|
||||
sortOrder: 1,
|
||||
kind: "person",
|
||||
});
|
||||
await insertAttendee({
|
||||
meetingId,
|
||||
name: "Drag-C",
|
||||
attendanceType: "internal",
|
||||
sortOrder: 2,
|
||||
kind: "person",
|
||||
});
|
||||
|
||||
await setAdminPreferredLanguage(lang);
|
||||
try {
|
||||
await loginViaUi(page);
|
||||
await page.goto("/executive-meetings");
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", lang, {
|
||||
timeout: 5_000,
|
||||
});
|
||||
|
||||
// Switch to the Manage section via the page-level nav. The
|
||||
// manage dialog is only mounted from inside the ManageSection.
|
||||
const manageNav = page.getByTestId("em-nav-manage");
|
||||
await expect(manageNav).toBeVisible({ timeout: 10_000 });
|
||||
await manageNav.click();
|
||||
await page.locator('input[type="date"]').first().fill(date);
|
||||
|
||||
// Open the edit dialog for our meeting (the pencil button on the
|
||||
// row uses the meeting id as its testid suffix).
|
||||
const editBtn = page.getByTestId(`em-edit-${meetingId}`);
|
||||
await expect(editBtn).toBeVisible({ timeout: 10_000 });
|
||||
await editBtn.click();
|
||||
|
||||
// The dialog renders three sortable rows — verify drag handles
|
||||
// are present and aria-labelled.
|
||||
const handle0 = page.getByTestId("em-attendee-drag-0");
|
||||
await expect(handle0).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Use dnd-kit's KeyboardSensor flow to move row 0 down two
|
||||
// slots. This is more reliable across Playwright browser engines
|
||||
// than synthesised pointer drags. dnd-kit picks up Space and
|
||||
// moves with arrow keys, then drops with Space again.
|
||||
await handle0.focus();
|
||||
await page.keyboard.press("Space");
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await page.keyboard.press("Space");
|
||||
|
||||
// Verify the visible name inputs reordered to [B, C, A] before we
|
||||
// even save — this catches DnD wiring without relying on a save
|
||||
// round-trip.
|
||||
const nameInputs = page.locator(
|
||||
'[data-testid^="em-attendee-row-"] input[placeholder]',
|
||||
);
|
||||
// Filter to the first input of each row (the name field).
|
||||
const firstNames = await page
|
||||
.locator('[data-testid^="em-attendee-row-"]')
|
||||
.evaluateAll((rows) =>
|
||||
rows.map((r) => {
|
||||
const inp = r.querySelector("input");
|
||||
return inp ? inp.value : "";
|
||||
}),
|
||||
);
|
||||
expect(firstNames).toEqual(["Drag-B", "Drag-C", "Drag-A"]);
|
||||
void nameInputs;
|
||||
|
||||
// Save and verify persistence.
|
||||
const saveBtn = page.getByTestId("em-form-save");
|
||||
await saveBtn.click();
|
||||
// Wait for the dialog to close.
|
||||
await expect(saveBtn).toBeHidden({ timeout: 10_000 });
|
||||
|
||||
const persisted = await getAttendeesOrdered(meetingId);
|
||||
expect(persisted.map((r) => r.name)).toEqual([
|
||||
"Drag-B",
|
||||
"Drag-C",
|
||||
"Drag-A",
|
||||
]);
|
||||
expect(persisted.map((r) => r.sort_order)).toEqual([0, 1, 2]);
|
||||
} finally {
|
||||
if (originalAdminPreferredLanguage) {
|
||||
await setAdminPreferredLanguage(originalAdminPreferredLanguage).catch(
|
||||
() => {
|
||||
/* best-effort; afterAll also restores */
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test(`Manage [${lang}]: drag-reorder works across mixed person + subheading rows`, async ({
|
||||
page,
|
||||
}) => {
|
||||
// Architect-flagged coverage: a meeting that contains BOTH person
|
||||
// rows and a subheading row, then dragged so the subheading lands
|
||||
// in a new section position. We verify (a) the kind survives the
|
||||
// round-trip, (b) the new order persists, (c) numbering on the
|
||||
// schedule view recomputes correctly after the kinds shift.
|
||||
const date = uniqueFutureDate(langOffsets3[lang]);
|
||||
const meetingId = await insertMeeting({
|
||||
meetingDate: date,
|
||||
dailyNumber: 1,
|
||||
titleEn: `Mixed Drag ${lang} ${Date.now().toString(36)}`,
|
||||
titleAr: `سحب مختلط ${lang} ${Date.now().toString(36)}`,
|
||||
startTime: "13:00:00",
|
||||
endTime: "14:00:00",
|
||||
});
|
||||
// Seed: [PersonM, SubX, PersonN, PersonO]. We will pick up
|
||||
// SubX (index 1) and move it down past PersonN (one ArrowDown).
|
||||
// Expected after drag: [PersonM, PersonN, SubX, PersonO].
|
||||
await insertAttendee({
|
||||
meetingId,
|
||||
name: "PersonM",
|
||||
attendanceType: "internal",
|
||||
sortOrder: 0,
|
||||
kind: "person",
|
||||
});
|
||||
await insertAttendee({
|
||||
meetingId,
|
||||
name: "MixSubX-K3",
|
||||
attendanceType: "internal",
|
||||
sortOrder: 1,
|
||||
kind: "subheading",
|
||||
});
|
||||
await insertAttendee({
|
||||
meetingId,
|
||||
name: "PersonN",
|
||||
attendanceType: "internal",
|
||||
sortOrder: 2,
|
||||
kind: "person",
|
||||
});
|
||||
await insertAttendee({
|
||||
meetingId,
|
||||
name: "PersonO",
|
||||
attendanceType: "internal",
|
||||
sortOrder: 3,
|
||||
kind: "person",
|
||||
});
|
||||
|
||||
await setAdminPreferredLanguage(lang);
|
||||
try {
|
||||
await loginViaUi(page);
|
||||
await page.goto("/executive-meetings");
|
||||
|
||||
const manageNav = page.getByTestId("em-nav-manage");
|
||||
await expect(manageNav).toBeVisible({ timeout: 10_000 });
|
||||
await manageNav.click();
|
||||
await page.locator('input[type="date"]').first().fill(date);
|
||||
|
||||
const editBtn = page.getByTestId(`em-edit-${meetingId}`);
|
||||
await expect(editBtn).toBeVisible({ timeout: 10_000 });
|
||||
await editBtn.click();
|
||||
|
||||
// Pick up the subheading row (index 1) and move it down once.
|
||||
const handle1 = page.getByTestId("em-attendee-drag-1");
|
||||
await expect(handle1).toBeVisible({ timeout: 5_000 });
|
||||
await handle1.focus();
|
||||
await page.keyboard.press("Space");
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await page.keyboard.press("Space");
|
||||
|
||||
// Pre-save check: the row at index 1 is now PersonN (was the
|
||||
// subheading), and the row at index 2 is the subheading.
|
||||
const kinds = await page
|
||||
.locator('[data-testid^="em-attendee-row-"]')
|
||||
.evaluateAll((rows) =>
|
||||
rows.map((r) => r.getAttribute("data-attendee-kind")),
|
||||
);
|
||||
expect(kinds).toEqual(["person", "person", "subheading", "person"]);
|
||||
|
||||
const saveBtn = page.getByTestId("em-form-save");
|
||||
await saveBtn.click();
|
||||
await expect(saveBtn).toBeHidden({ timeout: 10_000 });
|
||||
|
||||
const persisted = await getAttendeesOrdered(meetingId);
|
||||
expect(persisted.map((r) => r.name)).toEqual([
|
||||
"PersonM",
|
||||
"PersonN",
|
||||
"MixSubX-K3",
|
||||
"PersonO",
|
||||
]);
|
||||
expect(persisted.map((r) => r.kind)).toEqual([
|
||||
"person",
|
||||
"person",
|
||||
"subheading",
|
||||
"person",
|
||||
]);
|
||||
expect(persisted.map((r) => r.sort_order)).toEqual([0, 1, 2, 3]);
|
||||
} finally {
|
||||
if (originalAdminPreferredLanguage) {
|
||||
await setAdminPreferredLanguage(originalAdminPreferredLanguage).catch(
|
||||
() => {
|
||||
/* best-effort; afterAll also restores */
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user