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:
@@ -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);
|
||||
|
||||
@@ -828,7 +828,11 @@
|
||||
"editAttendeeAria": "تعديل اسم الحاضر",
|
||||
"deleteRow": "حذف الاجتماع",
|
||||
"deleteRowConfirm": "حذف هذا الاجتماع؟\n\n\"{{title}}\"\n\nلا يمكن التراجع عن هذا الإجراء.",
|
||||
"deleted": "تم حذف الاجتماع"
|
||||
"deleted": "تم حذف الاجتماع",
|
||||
"editToggle": "تحرير",
|
||||
"editToggleAria": "تبديل وضع التحرير",
|
||||
"editToggleOn": "وضع التحرير مفعّل",
|
||||
"editToggleOff": "وضع التحرير معطّل"
|
||||
},
|
||||
"rowColor": {
|
||||
"label": "لون الصف"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 |
Reference in New Issue
Block a user