From 225356cc4f49a8a8178f9d7dffc3283bc641b3d0 Mon Sep 17 00:00:00 2001 From: Meier Lukas Date: Sun, 24 Nov 2024 13:43:50 +0100 Subject: [PATCH] fix: add beforeunload logic for board in edit mode (#1531) --- .../boards/(content)/_header-actions.tsx | 68 ++++++++++++++++++- .../src/components/user-avatar-menu.tsx | 3 +- packages/translation/src/lang/en.json | 4 ++ 3 files changed, 72 insertions(+), 3 deletions(-) diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx index 0782c1f94..e951a4318 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx @@ -1,6 +1,8 @@ "use client"; -import { useCallback } from "react"; +import type { MouseEvent } from "react"; +import { useCallback, useEffect } from "react"; +import { useRouter } from "next/navigation"; import { Group, Menu } from "@mantine/core"; import { useHotkeys } from "@mantine/hooks"; import { @@ -16,7 +18,7 @@ import { import { clientApi } from "@homarr/api/client"; import { revalidatePathActionAsync } from "@homarr/common/client"; -import { useModalAction } from "@homarr/modals"; +import { useConfirmModal, useModalAction } from "@homarr/modals"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useI18n, useScopedI18n } from "@homarr/translation/client"; @@ -26,6 +28,7 @@ import { useCategoryActions } from "~/components/board/sections/category/categor import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal"; import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions"; import { HeaderButton } from "~/components/layout/header/button"; +import { env } from "~/env.mjs"; import { useEditMode, useRequiredBoard } from "./_context"; export const BoardContentHeaderActions = () => { @@ -139,6 +142,7 @@ const EditModeMenu = () => { }, [board, isEditMode, saveBoard, setEditMode]); useHotkeys([["mod+e", toggle]]); + usePreventLeaveWithDirty(isEditMode); return ( @@ -146,3 +150,63 @@ const EditModeMenu = () => { ); }; + +const usePreventLeaveWithDirty = (isDirty: boolean) => { + const t = useI18n(); + const { openConfirmModal } = useConfirmModal(); + const router = useRouter(); + + useEffect(() => { + const handleClick = (event: MouseEvent) => { + const target = (event.target as HTMLElement).closest("a"); + + if (!target) return; + if (!isDirty) return; + + event.preventDefault(); + + openConfirmModal({ + title: t("board.action.edit.confirmLeave.title"), + children: t("board.action.edit.confirmLeave.message"), + onConfirm() { + router.push(target.href); + }, + confirmProps: { + children: t("common.action.discard"), + }, + }); + }; + + const handlePopState = (event: Event) => { + if (isDirty) { + window.history.pushState(null, document.title, window.location.href); + event.preventDefault(); + } else { + window.history.back(); + } + }; + + const handleBeforeUnload = (event: BeforeUnloadEvent) => { + if (!isDirty) return; + if (env.NODE_ENV === "development") return; // Allow to reload in development + + event.preventDefault(); + event.returnValue = true; + }; + + document.querySelectorAll("a").forEach((link) => { + link.addEventListener("click", handleClick as never); + }); + window.addEventListener("popstate", handlePopState); + window.addEventListener("beforeunload", handleBeforeUnload); + + return () => { + document.querySelectorAll("a").forEach((link) => { + link.removeEventListener("click", handleClick as never); + window.removeEventListener("popstate", handlePopState); + }); + window.removeEventListener("beforeunload", handleBeforeUnload); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isDirty]); +}; diff --git a/apps/nextjs/src/components/user-avatar-menu.tsx b/apps/nextjs/src/components/user-avatar-menu.tsx index 63b089883..503642482 100644 --- a/apps/nextjs/src/components/user-avatar-menu.tsx +++ b/apps/nextjs/src/components/user-avatar-menu.tsx @@ -61,7 +61,8 @@ export const UserAvatarMenu = ({ children }: UserAvatarMenuProps) => { }, [logoutUrl, openModal, router]); return ( - + // We use keepMounted so we can add event listeners to prevent navigating away without saving the board + }> {colorSchemeText} diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index d09867d96..0ec22920c 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -1563,6 +1563,10 @@ "title": "Unable to apply changes", "message": "The board could not be saved" } + }, + "confirmLeave": { + "title": "Unsaved changes", + "message": "You have unsaved changes, are you sure you want to leave?" } }, "oldImport": {