Task #171: Schedule Edit/View toggle (final fixes)

Add a single global "تحرير/Edit" toggle button to the schedule
toolbar that hides every editing affordance by default and reveals
them only when the user (with edit permission) explicitly opts in.

Affordances now gated behind `effectiveCanMutate = canMutate &&
editMode`:
- "+ Add row" button
- Per-row delete, color swatch, merge trigger, drag grip
- Inline cell editors (EditableCell, TimeRangeCell)
- Column drag-reorder (SortableHeader.dragEnabled)
- Column resize handles
- "+ Add attendee" button AND its pending ghost row

Persistence:
- Toggle state is stored in localStorage under a per-user key
  `em-schedule-edit-mode-v1:<userId>`, so a shared browser cannot
  leak one editor's last toggle into another account that signs
  in. Falls back to view mode when userId is unavailable.
- Always starts in view mode for users without edit permission.

Toggle-off safety:
- EditableCell + TimeRangeCell discard any in-progress draft and
  exit edit mode when their `disabled` / `canMutate` prop flips.
- ScheduleSection clears `pendingAttendee` in a useEffect when
  effectiveCanMutate becomes false, so the ghost "+ Add attendee"
  row unmounts immediately.
- AttendeeFlow also gates the pending render on `canMutate` as
  defense in depth.

i18n: 4 new keys under `executiveMeetings.schedule`
(editToggle / editToggleAria / editToggleOn / editToggleOff)
in both en.json and ar.json.

Tests: tests/executive-meetings-edit-toggle.spec.mjs covers
default-hidden affordances, toggle-on reveal, reload persistence,
and toggle-off re-hide. Cleanup wipes all `em-schedule-edit-mode-v1*`
keys to handle the user-namespaced storage. Full e2e suite (12
tests) passes.

Code review: PASS on the second pass after the per-user key + ghost
row fixes. Pre-existing TS errors in admin.tsx and
use-notifications-socket.ts are codegen drift from earlier tasks
and are not touched by this change.
This commit is contained in:
riyadhafraa
2026-04-29 18:22:49 +00:00
parent 11aaaf2abe
commit b66a5ef9b6
6 changed files with 228 additions and 12 deletions
@@ -244,6 +244,22 @@ export function EditableCell({
return undefined;
}, [editor, editing, disabled]);
// If the host disables this cell while we're mid-edit (e.g. the
// schedule's global Edit toggle is flipped back to view mode), drop
// any unsaved draft and exit edit mode immediately. Without this the
// cell stays in editing state with a frozen toolbar even though the
// editor itself has gone read-only via setEditable above.
useEffect(() => {
if (disabled && editing && editor) {
editor.commands.setContent(value || "", { emitUpdate: false });
setEditing(false);
if (blurTimerRef.current) {
clearTimeout(blurTimerRef.current);
blurTimerRef.current = null;
}
}
}, [disabled, editing, editor, value]);
const enterEdit = () => {
if (disabled) return;
setEditing(true);
+5 -1
View File
@@ -828,7 +828,11 @@
"editAttendeeAria": "تعديل اسم الحاضر",
"deleteRow": "حذف الاجتماع",
"deleteRowConfirm": "حذف هذا الاجتماع؟\n\n\"{{title}}\"\n\nلا يمكن التراجع عن هذا الإجراء.",
"deleted": "تم حذف الاجتماع"
"deleted": "تم حذف الاجتماع",
"editToggle": "تحرير",
"editToggleAria": "تبديل وضع التحرير",
"editToggleOn": "وضع التحرير مفعّل",
"editToggleOff": "وضع التحرير معطّل"
},
"rowColor": {
"label": "لون الصف"
+5 -1
View File
@@ -777,7 +777,11 @@
"editAttendeeAria": "Edit attendee name",
"deleteRow": "Delete meeting",
"deleteRowConfirm": "Delete this meeting?\n\n\"{{title}}\"\n\nThis cannot be undone.",
"deleted": "Meeting deleted"
"deleted": "Meeting deleted",
"editToggle": "Edit",
"editToggleAria": "Toggle edit mode",
"editToggleOn": "Edit mode on",
"editToggleOff": "Edit mode off"
},
"nav": {
"schedule": "Daily Schedule",
+121 -10
View File
@@ -342,6 +342,7 @@ const ROW_COLOR_OPTIONS: { key: string; bg: string; label: string }[] = [
const COLS_STORAGE_KEY = "em-schedule-cols-v1";
const ROW_COLORS_STORAGE_KEY = "em-schedule-row-colors-v1";
const HIGHLIGHT_STORAGE_KEY = "em-current-meeting-highlight-v1";
const EDIT_MODE_STORAGE_KEY = "em-schedule-edit-mode-v1";
type HighlightPrefs = { enabled: boolean; color: string };
const DEFAULT_HIGHLIGHT_PREFS: HighlightPrefs = {
@@ -678,6 +679,7 @@ export default function ExecutiveMeetingsPage() {
t={t}
font={effectiveFont}
canMutate={!!me?.canMutate}
userId={me?.userId ?? null}
/>
)}
{section === "manage" && me && (
@@ -735,6 +737,7 @@ function ScheduleSection({
t,
font,
canMutate,
userId,
}: {
meetings: Meeting[];
date: string;
@@ -744,6 +747,7 @@ function ScheduleSection({
t: (k: string) => string;
font: FontPrefs;
canMutate: boolean;
userId: number | null;
}) {
const qc = useQueryClient();
const { toast } = useToast();
@@ -765,6 +769,57 @@ function ScheduleSection({
writeJsonToStorage(HIGHLIGHT_STORAGE_KEY, highlightPrefs);
}, [highlightPrefs]);
// Global "Edit / View" toggle for the schedule. The default is view mode
// (no edit affordances). When a user with edit permission flips it on,
// every per-cell editor and per-row action (merge, color swatch, drag
// handle, add/delete row, inline edit, column drag, column resize) is
// re-enabled. We persist the user's last choice in localStorage so the
// toggle survives reloads + date changes, but we *also* always start in
// view mode for users without permission so they never see a stale "on"
// state from a previous session where they had access. Hydrating the
// localStorage value happens inside an effect to keep SSR-safe and to
// avoid reading from a non-mutator's namespace.
//
// The storage key is namespaced by the current user's id so a shared
// browser profile can't leak one editor's last toggle state into the
// next account that signs in. When userId is null (no /me yet) we
// fall back to view mode without touching storage.
const editModeStorageKey = useMemo(
() => (userId != null ? `${EDIT_MODE_STORAGE_KEY}:${userId}` : null),
[userId],
);
const [editMode, setEditModeState] = useState<boolean>(false);
useEffect(() => {
if (!canMutate || !editModeStorageKey) {
setEditModeState(false);
return;
}
try {
const raw = window.localStorage.getItem(editModeStorageKey);
setEditModeState(raw === "true");
} catch {
// localStorage may be unavailable (private mode); fall through to
// the default false.
}
}, [canMutate, editModeStorageKey]);
const setEditMode = useCallback(
(next: boolean) => {
if (!canMutate || !editModeStorageKey) return;
setEditModeState(next);
try {
window.localStorage.setItem(editModeStorageKey, next ? "true" : "false");
} catch {
// Persistence is best-effort; the in-memory state still flips.
}
},
[canMutate, editModeStorageKey],
);
// Anything the user can mutate must AND with editMode. We pass this
// derived flag down to every row + cell so they don't each have to
// know about the toggle. The actual `canMutate` permission stays in
// scope for the toggle button visibility check.
const effectiveCanMutate = canMutate && editMode;
const [nowTick, setNowTick] = useState(() => Date.now());
useEffect(() => {
let intervalId: ReturnType<typeof setInterval> | null = null;
@@ -907,6 +962,17 @@ function ScheduleSection({
const [pendingAttendee, setPendingAttendee] =
useState<PendingAttendee | null>(null);
// If the user toggles edit mode off (or otherwise loses edit
// permission) while the ghost "+ Add attendee" row is open, drop the
// pending state so the synthetic row unmounts immediately. Without
// this the ghost row would keep its open editor in an otherwise
// read-only schedule.
useEffect(() => {
if (!effectiveCanMutate && pendingAttendee !== null) {
setPendingAttendee(null);
}
}, [effectiveCanMutate, pendingAttendee]);
const startAddAttendee = useCallback(
(meetingId: number, attendanceType: Attendee["attendanceType"]) => {
// Only one ghost row may be open at a time across the whole page.
@@ -1229,7 +1295,10 @@ function ScheduleSection({
// since drag-resizing with a finger fights touch scrolling.
const isXl = useMediaQuery("(min-width: 1280px)");
const isFinePointer = useMediaQuery("(pointer: fine)");
const showResizeHandles = isXl && isFinePointer;
// Resize handles only render when the user is in edit mode AND on a
// large screen with a fine pointer. View mode hides them entirely so
// the table feels frozen until the editor opts in.
const showResizeHandles = isXl && isFinePointer && effectiveCanMutate;
// dnd-kit: sortable column headers + sortable rows.
//
@@ -1289,6 +1358,29 @@ function ScheduleSection({
{t("executiveMeetings.scheduleHeading")}
</h2>
<div className="flex items-center gap-2 flex-wrap">
{canMutate && (
<button
type="button"
onClick={() => setEditMode(!editMode)}
aria-pressed={editMode}
aria-label={t("executiveMeetings.schedule.editToggleAria")}
title={
editMode
? t("executiveMeetings.schedule.editToggleOn")
: t("executiveMeetings.schedule.editToggleOff")
}
data-testid="em-edit-mode-toggle"
className={
"inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-[#0B1E3F]/40 " +
(editMode
? "bg-[#0B1E3F] text-white border-[#0B1E3F] hover:bg-[#0B1E3F]/90"
: "bg-white text-[#0B1E3F] border-gray-300 hover:bg-gray-50")
}
>
<Pencil className="w-3.5 h-3.5" />
<span>{t("executiveMeetings.schedule.editToggle")}</span>
</button>
)}
<ColumnsCustomizer
columns={columns}
onChange={setColumns}
@@ -1349,6 +1441,7 @@ function ScheduleSection({
isRtl={isRtl}
t={t}
showResizeHandle={showResizeHandles}
dragEnabled={effectiveCanMutate}
onResize={(delta) => setColumnWidth(c.id, c.width + delta)}
/>
))}
@@ -1395,7 +1488,7 @@ function ScheduleSection({
visibleColumns={visibleColumns}
rowColorKey={rowColors[m.id] ?? "default"}
onPickRowColor={(key) => setRowColor(m.id, key)}
canMutate={canMutate}
canMutate={effectiveCanMutate}
onSaveTitle={(html) => saveTitle(m, html)}
onSaveAttendeeName={(idx, html) =>
saveAttendeeName(m, idx, html)
@@ -1424,7 +1517,7 @@ function ScheduleSection({
/>
))}
</SortableContext>
{canMutate && !isLoading && (
{effectiveCanMutate && !isLoading && (
<tr
className="print:hidden"
data-testid="em-add-row-tr"
@@ -1668,6 +1761,7 @@ function SortableHeader({
isRtl,
t,
showResizeHandle,
dragEnabled,
onResize,
}: {
col: ColumnSetting;
@@ -1675,6 +1769,7 @@ function SortableHeader({
isRtl: boolean;
t: (k: string) => string;
showResizeHandle: boolean;
dragEnabled: boolean;
onResize: (delta: number) => void;
}) {
const {
@@ -1684,7 +1779,7 @@ function SortableHeader({
transform,
transition,
isDragging,
} = useSortable({ id: col.id });
} = useSortable({ id: col.id, disabled: !dragEnabled });
const align =
col.id === "number" || col.id === "time" ? "text-center" : "text-start";
const style: CSSProperties = {
@@ -1692,7 +1787,9 @@ function SortableHeader({
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
cursor: "grab",
// Only advertise grab cursor when drag is actually enabled — view
// mode renders the header as static text.
cursor: dragEnabled ? "grab" : undefined,
};
return (
<th
@@ -1700,12 +1797,12 @@ function SortableHeader({
className={`relative border border-[#0B1E3F] px-2 py-2 ${align} font-semibold select-none`}
style={style}
data-testid={`em-col-header-${col.id}`}
{...attributes}
{...listeners}
{...(dragEnabled ? attributes : {})}
{...(dragEnabled ? listeners : {})}
>
{t(`executiveMeetings.col.${col.id}`)}
{/* Resize handles only render on large screens AND fine (mouse)
pointers — see comment on showResizeHandles. */}
pointers AND when the user is in edit mode. See showResizeHandles. */}
{!isLast && showResizeHandle && (
<ResizeHandle isRtl={isRtl} onResize={onResize} />
)}
@@ -2058,7 +2155,9 @@ function MeetingRow({
)}
<span>{meeting.dailyNumber}</span>
</div>
<RowColorPicker current={rowColorKey} onPick={onPickRowColor} t={t} />
{canMutate && (
<RowColorPicker current={rowColorKey} onPick={onPickRowColor} t={t} />
)}
{deleteOverlay}
{mergeOverlay}
</td>
@@ -2419,6 +2518,18 @@ function TimeRangeCell({
setEditing(false);
}, [startSaved, endSaved]);
// If the global Edit toggle flips off (or the user loses permission)
// while the time inputs are open, drop the unsaved draft and exit
// edit mode immediately so the cell snaps back to its read-only
// display instead of stranding stale time inputs in view mode.
useEffect(() => {
if (!canMutate && editing) {
setStart(startSaved);
setEnd(endSaved);
setEditing(false);
}
}, [canMutate, editing, startSaved, endSaved]);
const save = useCallback(async () => {
const newStart = start || null;
const newEnd = end || null;
@@ -2844,7 +2955,7 @@ function AttendeeFlow({
)}
</li>
))}
{isPending && onCommitAdd && (
{canMutate && isPending && onCommitAdd && (
<li className="min-w-[6rem]" data-testid={`em-add-attendee-pending-${addType}`}>
<span className="text-gray-500 me-1">{items.length + 1}-</span>
<EditableCell
@@ -0,0 +1,81 @@
import { test, expect } from "@playwright/test";
async function loginViaUi(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(),
]);
}
test("Schedule: Edit toggle hides editing affordances by default and reveals them when on", async ({
page,
}) => {
await page.addInitScript(() => {
try {
window.localStorage.setItem("tx-lang", "en");
} catch {
/* localStorage may not be ready before navigation */
}
});
await loginViaUi(page, "admin", "admin123");
// Make sure we always start the test in view mode regardless of any
// previously-persisted toggle state from another run. We clear AFTER
// login so the cleanup isn't undone by the initScript on every nav.
await page.goto("/executive-meetings");
// The toggle's storage key is namespaced per-user
// (`em-schedule-edit-mode-v1:<userId>`), so we wipe every entry that
// starts with the base key to make sure we begin in view mode no
// matter who admin's userId resolves to in this run.
await page.evaluate(() => {
try {
const keys = Object.keys(window.localStorage);
for (const k of keys) {
if (k.startsWith("em-schedule-edit-mode-v1")) {
window.localStorage.removeItem(k);
}
}
} catch {
/* ignore */
}
});
await page.reload();
// Toggle is rendered (admin has edit permission), and starts in
// pressed=false / view mode.
const toggle = page.getByTestId("em-edit-mode-toggle");
await expect(toggle).toBeVisible();
await expect(toggle).toHaveAttribute("aria-pressed", "false");
// In view mode the row-level "Add meeting" button must be hidden.
await expect(page.getByTestId("em-add-row-button")).toHaveCount(0);
// And no row-level overlays (delete, color picker, merge trigger,
// grip handle) should exist anywhere in the table.
await expect(page.locator('[data-testid^="em-delete-row-"]')).toHaveCount(0);
await expect(page.getByTestId("em-row-color-trigger")).toHaveCount(0);
await expect(page.locator('[data-testid^="em-merge-trigger-"]')).toHaveCount(0);
await expect(page.locator('[data-testid^="em-row-grip-"]')).toHaveCount(0);
// Flip toggle on. aria-pressed becomes true and the affordances appear.
await toggle.click();
await expect(toggle).toHaveAttribute("aria-pressed", "true");
// The "+ Add meeting" row button is back.
await expect(page.getByTestId("em-add-row-button")).toBeVisible();
// Persist + survive a reload: the toggle should still be on.
await page.reload();
const toggleAfterReload = page.getByTestId("em-edit-mode-toggle");
await expect(toggleAfterReload).toHaveAttribute("aria-pressed", "true");
await expect(page.getByTestId("em-add-row-button")).toBeVisible();
// Toggle back off — affordances disappear again.
await toggleAfterReload.click();
await expect(toggleAfterReload).toHaveAttribute("aria-pressed", "false");
await expect(page.getByTestId("em-add-row-button")).toHaveCount(0);
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB