From a532bd6117782cce845c08c8bd14088c642efbf2 Mon Sep 17 00:00:00 2001 From: Kseniia Velychko Date: Thu, 14 May 2026 15:03:34 +0300 Subject: [PATCH 01/20] Add ChevronItem --- .../frame/components/items/ChevronItem.jsx | 19 ++++++++++++ .../frame/components/items/items.module.css | 11 ++++++- .../favorite/structure/FavoriteGroup.jsx | 7 ++--- map/src/login/garmin/GarminConnectedView.jsx | 31 ++++++------------- 4 files changed, 41 insertions(+), 27 deletions(-) create mode 100644 map/src/frame/components/items/ChevronItem.jsx diff --git a/map/src/frame/components/items/ChevronItem.jsx b/map/src/frame/components/items/ChevronItem.jsx new file mode 100644 index 000000000..d22bda109 --- /dev/null +++ b/map/src/frame/components/items/ChevronItem.jsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { ListItemIcon, ListItemText, MenuItem, Typography } from '@mui/material'; +import { ReactComponent as ChevronIcon } from '../../../assets/icons/ic_action_arrow_up.svg'; +import styles from './items.module.css'; + +export default function ChevronItem({ id, icon = null, title, value, onClick, disabled = false }) { + return ( + + {icon && {icon}} + + {title} + +
+ {value !== undefined && {value}} + +
+
+ ); +} diff --git a/map/src/frame/components/items/items.module.css b/map/src/frame/components/items/items.module.css index 3cf51af55..8dda586a2 100644 --- a/map/src/frame/components/items/items.module.css +++ b/map/src/frame/components/items/items.module.css @@ -4,6 +4,15 @@ gap: 24px !important; min-height: 48px !important; } +.itemChevron { + padding-right: 12px !important; +} +.itemChevronRight { + display: flex !important; + align-items: center !important; + gap: 6px !important; + flex-shrink: 0 !important; +} .mainText { color: var(--text-primary) !important; font-size: 16px !important; @@ -136,7 +145,7 @@ .sectionRow { width: 360px !important; min-height: 48px !important; - padding: 12px 20px !important; + padding: 12px 12px 12px 16px !important; display: flex !important; align-items: center !important; justify-content: space-between !important; diff --git a/map/src/infoblock/components/favorite/structure/FavoriteGroup.jsx b/map/src/infoblock/components/favorite/structure/FavoriteGroup.jsx index 58d7f20bb..77c83ee98 100644 --- a/map/src/infoblock/components/favorite/structure/FavoriteGroup.jsx +++ b/map/src/infoblock/components/favorite/structure/FavoriteGroup.jsx @@ -1,8 +1,7 @@ import React, { useState } from 'react'; -import ChevronRightIcon from '@mui/icons-material/ChevronRight'; import { useTranslation } from 'react-i18next'; import FavoritesManager from '../../../../manager/FavoritesManager'; -import SelectItemWithoutOptions from '../../../../frame/components/items/SelectItemWithoutOptions'; +import ChevronItem from '../../../../frame/components/items/ChevronItem'; import FolderSelectionPanel from './FolderSelectionPanel'; export default function FavoriteGroup({ favoriteGroup, setFavoriteGroup, defaultGroup, isTrackWpt }) { @@ -14,12 +13,10 @@ export default function FavoriteGroup({ favoriteGroup, setFavoriteGroup, default return ( <> - } onClick={() => setPanelOpen((o) => !o)} /> {panelOpen && ( diff --git a/map/src/login/garmin/GarminConnectedView.jsx b/map/src/login/garmin/GarminConnectedView.jsx index 7e3c88a86..87988ccba 100644 --- a/map/src/login/garmin/GarminConnectedView.jsx +++ b/map/src/login/garmin/GarminConnectedView.jsx @@ -1,15 +1,14 @@ import React, { useContext } from 'react'; -import { Box, Typography } from '@mui/material'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { ReactComponent as FolderIcon } from '../../assets/icons/ic_action_folder.svg'; import { ReactComponent as SyncIcon } from '../../assets/icons/ic_action_update.svg'; import { ReactComponent as LogoutIcon } from '../../assets/icons/ic_action_logout.svg'; import { ReactComponent as ExternalLinkIcon } from '../../assets/icons/ic_action_external_link.svg'; -import { ReactComponent as ChevronIcon } from '../../assets/icons/ic_action_arrow_up.svg'; import { ReactComponent as FilterIcon } from '../../assets/icons/ic_action_filter.svg'; import ThickDivider from '../../frame/components/dividers/ThickDivider'; import SubTitleMenu from '../../frame/components/titles/SubTitleMenu'; +import ChevronItem from '../../frame/components/items/ChevronItem'; import DefaultItem from '../../frame/components/items/DefaultItem'; import DividerWithMargin from '../../frame/components/dividers/DividerWithMargin'; import { MAIN_URL_WITH_SLASH, TRACKS_URL } from '../../manager/GlobalManager'; @@ -19,7 +18,6 @@ import AppContext from '../../context/AppContext'; import { GARMIN_FOLDER_NAME } from './garminApi'; import { GARMIN_ACTIVITY_GROUPS } from './GarminActivitiesToSync'; import { useRecentDataSaver } from '../../util/hooks/menu/useRecentDataSaver'; -import styles from '../../frame/components/items/items.module.css'; export default function GarminConnectedView({ syncTimeMs, @@ -76,18 +74,18 @@ export default function GarminConnectedView({ return ( <> - } - name={t('web:garmin_activities')} + title={t('web:garmin_activities')} + value={String(activitiesCount)} onClick={handleActivitiesClick} - rightSlot={} /> - } - name={t('web:garmin_last_sync')} + title={t('web:garmin_last_sync')} + value={syncTimeMs ? formatTimeAgo(syncTimeMs, t) : '—'} onClick={handleLastActivityClick} - rightSlot={} /> - } - name={t('web:garmin_activities_to_sync')} + title={t('web:garmin_activities_to_sync')} + value={activityTypesLabel} onClick={onActivitiesToSyncClick} - rightSlot={} /> - {text} - - - ); -} - function handleViewOnGarminClick() { globalThis.open('https://connect.garmin.com/app/activities', '_blank', 'noopener,noreferrer'); } From 64fc99312ce2b7a97e623226255575485fbb5e24 Mon Sep 17 00:00:00 2001 From: Kseniia Velychko Date: Fri, 15 May 2026 11:23:06 +0300 Subject: [PATCH 02/20] Feat guard WptEditPanel against unsaved changes exit --- map/src/context/AppContext.js | 7 ++ map/src/dialogs/dialog.module.css | 9 +++ .../favorites/ExitWithoutSavingDialog.jsx | 27 ++++++++ .../infoblock/components/InformationBlock.jsx | 18 +++-- .../components/favorite/WptEditPanel.jsx | 41 +++++++++++- .../structure/ColorSelectionPanel.jsx | 3 +- .../favorite/structure/FavoriteAddress.jsx | 7 +- .../favorite/structure/FavoriteName.jsx | 10 +-- map/src/map/components/ContextMenu.jsx | 4 +- map/src/menu/MainMenu.js | 11 +++- map/src/menu/actions/FavoriteItemActions.jsx | 8 ++- .../translations/en/web-translation.json | 6 +- .../util/hooks/map/useSelectMarkerOnMap.js | 4 ++ map/src/util/hooks/useExitGuard.js | 65 +++++++++++++++++++ .../80-fav-import-conflict-cancel-move.mjs | 2 + 15 files changed, 203 insertions(+), 19 deletions(-) create mode 100644 map/src/dialogs/favorites/ExitWithoutSavingDialog.jsx create mode 100644 map/src/util/hooks/useExitGuard.js diff --git a/map/src/context/AppContext.js b/map/src/context/AppContext.js index c82bdb237..15f8ae2de 100644 --- a/map/src/context/AppContext.js +++ b/map/src/context/AppContext.js @@ -207,6 +207,11 @@ export const AppContextProvider = (props) => { add: false, location: null, }); + + // Registry of exit guards: { key: guardFn }. Components register via useExitGuard({ register }). + // Callers: const guard = ctx.exitGuards.wptEdit; guard ? guard(action) : action(); + const [exitGuards, setExitGuards] = useState({}); + const [processingGroups, setProcessingGroups] = useState(false); const [favLoading, setFavLoading] = useState(false); const [removeFavGroup, setRemoveFavGroup] = useState(null); @@ -555,6 +560,8 @@ export const AppContextProvider = (props) => { setFavorites, addFavorite, setAddFavorite, + exitGuards, + setExitGuards, localTracks, setLocalTracks, currentObjectType, diff --git a/map/src/dialogs/dialog.module.css b/map/src/dialogs/dialog.module.css index 3533cbd79..f247ddce8 100644 --- a/map/src/dialogs/dialog.module.css +++ b/map/src/dialogs/dialog.module.css @@ -37,6 +37,15 @@ letter-spacing: 0.28px !important; text-transform: uppercase !important; } +.contentText { + font-size: 14px !important; + font-weight: 400 !important; + line-height: 20px !important; + letter-spacing: 0.25px !important; + color: var(--text-secondary) !important; + align-self: stretch !important; + padding: 12px 0 !important; +} /* 280px (.title width) + 24px * 2 (left/right padding from .title) */ .dialog :global(.MuiPaper-root) { max-width: 328px !important; diff --git a/map/src/dialogs/favorites/ExitWithoutSavingDialog.jsx b/map/src/dialogs/favorites/ExitWithoutSavingDialog.jsx new file mode 100644 index 000000000..84cddeac4 --- /dev/null +++ b/map/src/dialogs/favorites/ExitWithoutSavingDialog.jsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import dialogStyles from '../dialog.module.css'; + +export default function ExitWithoutSavingDialog({ open, onKeepEditing, onExit }) { + const { t } = useTranslation(); + + return ( + + {t('web:exit_without_saving')} + + + {t('web:all_changes_will_be_lost')} + + + + + + + + ); +} diff --git a/map/src/infoblock/components/InformationBlock.jsx b/map/src/infoblock/components/InformationBlock.jsx index cfc43c09c..693e609c7 100644 --- a/map/src/infoblock/components/InformationBlock.jsx +++ b/map/src/infoblock/components/InformationBlock.jsx @@ -101,10 +101,20 @@ export default function InformationBlock({ // Close WptEditPanel when the user navigates to another object or switches context useEffect(() => { - ctx.setAddFavorite((prev) => { - if (!prev?.location && !prev?.editWpt) return prev; - return { ...prev, add: false, location: null, editTrack: false, editWpt: null, previewAppearance: null }; - }); + const close = () => + ctx.setAddFavorite((prev) => { + if (!prev?.location && !prev?.editWpt) return prev; + return { + ...prev, + add: false, + location: null, + editTrack: false, + editWpt: null, + previewAppearance: null, + }; + }); + const guard = ctx.exitGuards.wptEdit; + guard ? guard(close) : close(); }, [ctx.selectedWpt, ctx.currentObjectType]); useEffect(() => { diff --git a/map/src/infoblock/components/favorite/WptEditPanel.jsx b/map/src/infoblock/components/favorite/WptEditPanel.jsx index bec44ba0a..aaf7dc74d 100644 --- a/map/src/infoblock/components/favorite/WptEditPanel.jsx +++ b/map/src/infoblock/components/favorite/WptEditPanel.jsx @@ -21,6 +21,8 @@ import FavoritesManager, { } from '../../../manager/FavoritesManager'; import FavoriteHelper from './FavoriteHelper'; import DeleteWptDialog from '../../../dialogs/favorites/DeleteWptDialog'; +import ExitWithoutSavingDialog from '../../../dialogs/favorites/ExitWithoutSavingDialog'; +import useExitGuard from '../../../util/hooks/useExitGuard'; import { ADDRESS_NOT_FOUND } from '../wpt/WptDetails'; import { FINAL_POI_ICON_NAME, WEB_POI_PREFIX, WEB_PREFIX } from '../wpt/WptTagsProvider'; import TracksManager, { GPX_FILE_EXT } from '../../../manager/track/TracksManager'; @@ -60,7 +62,9 @@ export default function WptEditPanel({ setShowInfoBlock }) { const useSelected = !isEmpty(ctx.selectedGpxFile); const [favoriteName, setFavoriteName] = useState(editWpt?.name ?? ''); + const [initialName, setInitialName] = useState(editWpt?.name ?? ''); const [favoriteAddress, setFavoriteAddress] = useState(editWpt?.address ?? ctx.addFavorite?.address ?? ''); + const [initialAddress, setInitialAddress] = useState(editWpt?.address ?? ctx.addFavorite?.address ?? ''); const [favoriteDescription, setFavoriteDescription] = useState(editWpt?.desc ?? ''); const [addAddress, setAddAddress] = useState(isEditMode || isPoi || (isAddMode && !isAddTrackWpt)); const [activePanel, setActivePanel] = useState(null); // null | 'description' | 'icon' | 'color' @@ -78,6 +82,34 @@ export default function WptEditPanel({ setShowInfoBlock }) { const [latLon, setLatLon] = useState(null); const [deleteWptDialogOpen, setDeleteWptDialogOpen] = useState(false); + const hasEditChanges = + favoriteName !== (editWpt?.name ?? '') || + favoriteDescription !== (editWpt?.desc ?? '') || + favoriteAddress !== (editWpt?.address ?? '') || + (favoriteGroup !== null && favoriteGroup.id !== editWpt?.groupId) || + favoriteIcon !== (editWpt?.icon ?? MarkerOptions.DEFAULT_WPT_ICON) || + favoriteColor !== (editWpt?.color ?? MarkerOptions.DEFAULT_WPT_COLOR) || + favoriteShape !== (editWpt?.background ?? MarkerOptions.BACKGROUND_WPT_SHAPE_CIRCLE); + + const hasAddChanges = + favoriteName !== initialName || favoriteDescription !== '' || favoriteAddress !== initialAddress; + + const hasChanges = isEditMode ? hasEditChanges : hasAddChanges; + + const { guardAction, dialog } = useExitGuard({ + hasChanges, + renderDialog: ({ onKeepEditing, onExit }) => ( + + ), + register: (fn) => + ctx.setExitGuards((prev) => { + if (fn) return { ...prev, wptEdit: fn }; + const next = { ...prev }; + delete next.wptEdit; + return next; + }), + }); + useEffect(() => { getIconCategories().then(); if (!isTrackWpt) { @@ -499,6 +531,10 @@ export default function WptEditPanel({ setShowInfoBlock }) { setActivePanel((prev) => (prev === panel ? null : panel)); } + function handleClose() { + guardAction(closePanel); + } + const groups = isTrackWpt ? ctx.selectedGpxFile?.pointsGroups : ctx.favorites.groups; const defaultGroup = isAddTrackWpt ? DEFAULT_GROUP_NAME_POINTS_GROUPS @@ -515,6 +551,7 @@ export default function WptEditPanel({ setShowInfoBlock }) { return ( <> + {dialog} {activePanel === 'description' && ( } diff --git a/map/src/infoblock/components/favorite/structure/ColorSelectionPanel.jsx b/map/src/infoblock/components/favorite/structure/ColorSelectionPanel.jsx index df38505c3..3f4d86bbf 100644 --- a/map/src/infoblock/components/favorite/structure/ColorSelectionPanel.jsx +++ b/map/src/infoblock/components/favorite/structure/ColorSelectionPanel.jsx @@ -79,7 +79,8 @@ export default function ColorSelectionPanel({ selectedColor, setSelectedColor, f const ok = await saveColorPalette(updated, ctx.setNotification); if (ok) { setColors(updated); - if (removed.value === selectedColor) { + const colorStillAvailable = updated.some((c) => c.value === removed.value); + if (removed.value === selectedColor && !colorStillAvailable) { setSelectedColor(updated[0]?.value ?? MarkerOptions.DEFAULT_WPT_COLOR); } } diff --git a/map/src/infoblock/components/favorite/structure/FavoriteAddress.jsx b/map/src/infoblock/components/favorite/structure/FavoriteAddress.jsx index be13d0ce7..63f75c3a0 100644 --- a/map/src/infoblock/components/favorite/structure/FavoriteAddress.jsx +++ b/map/src/infoblock/components/favorite/structure/FavoriteAddress.jsx @@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next'; import { getAddressByLatLon } from '../../wpt/WptDetails'; import styles from '../wptEditPanel.module.css'; -export default function FavoriteAddress({ favoriteAddress, setFavoriteAddress, widthDialog, latLon }) { +export default function FavoriteAddress({ favoriteAddress, setFavoriteAddress, onAutoFill, widthDialog, latLon }) { const { t } = useTranslation(); const [searching, setSearching] = useState(false); @@ -19,8 +19,9 @@ export default function FavoriteAddress({ favoriteAddress, setFavoriteAddress, w function searchAddress() { if (!latLon?.lat || !latLon?.lon) return; setSearching(true); - getAddressByLatLon(latLon.lat, latLon.lon).then((address) => { - setFavoriteAddress(address ?? ''); + getAddressByLatLon(latLon.lat, latLon.lon).then((resolved = '') => { + setFavoriteAddress(resolved); + onAutoFill?.(resolved); setSearching(false); }); } diff --git a/map/src/infoblock/components/favorite/structure/FavoriteName.jsx b/map/src/infoblock/components/favorite/structure/FavoriteName.jsx index 6658555ad..b42c0bc69 100644 --- a/map/src/infoblock/components/favorite/structure/FavoriteName.jsx +++ b/map/src/infoblock/components/favorite/structure/FavoriteName.jsx @@ -8,6 +8,7 @@ import styles from '../wptEditPanel.module.css'; export default function FavoriteName({ favoriteName, setFavoriteName, + onAutoFill, favoriteGroup, favorite, setErrorName, @@ -75,13 +76,12 @@ export default function FavoriteName({ const objOptions = ctx.selectedWpt.poi?.options ?? ctx.selectedWpt.poi?.properties; const { name } = getPropsFromSearchResultItem(objOptions, t); setFavoriteName(name); + onAutoFill?.(name); } else if (ctx.selectedWpt?.stop) { const name = ctx.selectedWpt?.stop.options.name; - if (name && name.trim() !== '') { - setFavoriteName(name); - } else { - setFavoriteName(t('web:transport_stop')); - } + const resolved = name && name.trim() !== '' ? name : t('web:transport_stop'); + setFavoriteName(resolved); + onAutoFill?.(resolved); } }, [ctx.selectedWpt]); diff --git a/map/src/map/components/ContextMenu.jsx b/map/src/map/components/ContextMenu.jsx index 8f9d5e4eb..18b59d11e 100644 --- a/map/src/map/components/ContextMenu.jsx +++ b/map/src/map/components/ContextMenu.jsx @@ -66,8 +66,10 @@ export default function ContextMenu({ setGeocodingData, setRegionData }) { }; const handleMenuItemClick = (callback) => { - callback(clickLatLng); + const latlng = clickLatLng; handleClose(); + const guard = ctx.exitGuards.wptEdit; + guard ? guard(() => callback(latlng)) : callback(latlng); }; const openLogin = () => { diff --git a/map/src/menu/MainMenu.js b/map/src/menu/MainMenu.js index 5475485c2..f0cd72d77 100644 --- a/map/src/menu/MainMenu.js +++ b/map/src/menu/MainMenu.js @@ -742,7 +742,16 @@ export default function MainMenu({ return res.join(' '); } - function selectMenu({ item }) { + function selectMenu({ item, openFromUrl = false }) { + if (!openFromUrl) { + const guard = ctx.exitGuards.wptEdit; + guard ? guard(() => doSelectMenu({ item })) : doSelectMenu({ item }); + return; + } + doSelectMenu({ item }); + } + + function doSelectMenu({ item }) { closeSubPages({ ctx, ltx }); let currentType; if (menuInfo) { diff --git a/map/src/menu/actions/FavoriteItemActions.jsx b/map/src/menu/actions/FavoriteItemActions.jsx index 0f0faf235..d2fa7b580 100644 --- a/map/src/menu/actions/FavoriteItemActions.jsx +++ b/map/src/menu/actions/FavoriteItemActions.jsx @@ -50,8 +50,12 @@ const FavoriteItemActions = forwardRef(({ marker, group, setOpenActions }, ref) id={'se-edit-fav-item'} className={styles.action} onClick={() => { - ctx.setAddFavorite({ editWpt: favorite, openKey: Date.now() }); - setOpenActions(false); + const guard = ctx.exitGuards.wptEdit; + const action = () => { + ctx.setAddFavorite({ editWpt: favorite, openKey: Date.now() }); + setOpenActions(false); + }; + guard ? guard(action) : action(); }} > diff --git a/map/src/resources/translations/en/web-translation.json b/map/src/resources/translations/en/web-translation.json index 45bec1ead..912264378 100644 --- a/map/src/resources/translations/en/web-translation.json +++ b/map/src/resources/translations/en/web-translation.json @@ -399,5 +399,9 @@ "folder_already_exists": "A folder with this name already exists.", "shared_string_advanced": "Advanced", "focus_on": "Focus: hide other items", - "focus_off": "Focus: dim other items" + "focus_off": "Focus: dim other items", + "exit_without_saving": "Exit without saving?", + "all_changes_will_be_lost": "All changes will be lost.", + "keep_editing": "Keep Editing", + "shared_string_exit": "Exit" } diff --git a/map/src/util/hooks/map/useSelectMarkerOnMap.js b/map/src/util/hooks/map/useSelectMarkerOnMap.js index d12291f6f..871999ae4 100644 --- a/map/src/util/hooks/map/useSelectMarkerOnMap.js +++ b/map/src/util/hooks/map/useSelectMarkerOnMap.js @@ -83,6 +83,10 @@ export function useSelectMarkerOnMap({ ctx, getLayers, layers: layersProp, type, useEffect(() => { if (!map) return; + if (ctx.addFavorite?.editWpt && ctx.exitGuards?.wptEdit) { + return; + } + if (!selectedObjId) { if (isAddFavoritePreviewActive(ctx)) { return; diff --git a/map/src/util/hooks/useExitGuard.js b/map/src/util/hooks/useExitGuard.js new file mode 100644 index 000000000..0111741ef --- /dev/null +++ b/map/src/util/hooks/useExitGuard.js @@ -0,0 +1,65 @@ +import { useEffect, useRef, useState } from 'react'; + +/** + * Guards actions with an "Exit without saving?" dialog. + * The dialog is fully defined in the calling component — text, style, callbacks. + * This hook only manages the pending-action state. + * + * Usage: + * const { guardAction, dialog } = useExitGuard({ + * hasChanges, + * renderDialog: ({ onKeepEditing, onExit }) => ( + * + * ), + * onExitConfirmed: () => { ... cleanup ... }, + * // Optional: expose guardAction to other components via ctx.exitGuards. + * // register(fn) is called with a stable guard on mount, register(null) on unmount. + * register: (fn) => ctx.setExitGuards((prev) => fn ? { ...prev, myKey: fn } : omit(prev, 'myKey')), + * }); + * + * // Wrap any user action: + * guardAction(() => doSomething()); + * + * // Place once in JSX: + * {dialog} + * + * @param {boolean} hasChanges - whether there are unsaved changes + * @param {Function} renderDialog - ({ onKeepEditing, onExit }) => ReactElement + * @param {Function} [onExitConfirmed] - called before the pending action when user confirms exit + * @param {Function} [register] - called with stable guardAction on mount, null on unmount + */ +export default function useExitGuard({ hasChanges, renderDialog, onExitConfirmed, register }) { + const [pendingAction, setPendingAction] = useState(null); + + function guardAction(action) { + if (hasChanges) { + setPendingAction(() => action); + return; + } + action(); + } + + const guardRef = useRef(guardAction); + guardRef.current = guardAction; + + useEffect(() => { + if (!register) return; + const stableGuard = (action) => guardRef.current(action); + register(stableGuard); + return () => register(null); + }, []); + + const dialog = + pendingAction !== null + ? renderDialog({ + onKeepEditing: () => setPendingAction(null), + onExit: () => { + setPendingAction(null); + onExitConfirmed?.(); + pendingAction(); + }, + }) + : null; + + return { guardAction, dialog }; +} diff --git a/tests/selenium/src/tests/favorites/80-fav-import-conflict-cancel-move.mjs b/tests/selenium/src/tests/favorites/80-fav-import-conflict-cancel-move.mjs index b4cdd84ba..25a0c6825 100644 --- a/tests/selenium/src/tests/favorites/80-fav-import-conflict-cancel-move.mjs +++ b/tests/selenium/src/tests/favorites/80-fav-import-conflict-cancel-move.mjs @@ -59,6 +59,8 @@ export default async function test() { await waitBy(By.id('se-add-fav-dialog')); await sendKeysBy(By.id('se-fav-name-input'), 'ShouldNotExist'); await clickBy(By.id('se-close-add-wpt-panel')); + await waitBy(By.id('se-exit-dialog-exit')); + await clickBy(By.id('se-exit-dialog-exit')); await waitByRemoved(By.id('se-add-fav-dialog')); // verify no default 'favorites' group was created (favorite was not saved) const notSaved = await waitBy(By.id('se-menu-fav-favorites'), { optional: true, idle: true }); From 369a52bf7003bc1c8d103a1b080d242862a2a3ee Mon Sep 17 00:00:00 2001 From: Kseniia Velychko Date: Fri, 15 May 2026 14:16:29 +0300 Subject: [PATCH 03/20] Add test 87 --- .../favorite/structure/FavoriteAddress.jsx | 6 +- map/src/map/util/TrackLayerProvider.js | 10 +- .../favorites/87-exit-guard-wpt-edit.mjs | 116 ++++++++++++++++++ 3 files changed, 128 insertions(+), 4 deletions(-) create mode 100644 tests/selenium/src/tests/favorites/87-exit-guard-wpt-edit.mjs diff --git a/map/src/infoblock/components/favorite/structure/FavoriteAddress.jsx b/map/src/infoblock/components/favorite/structure/FavoriteAddress.jsx index 63f75c3a0..a6b4804a0 100644 --- a/map/src/infoblock/components/favorite/structure/FavoriteAddress.jsx +++ b/map/src/infoblock/components/favorite/structure/FavoriteAddress.jsx @@ -19,9 +19,9 @@ export default function FavoriteAddress({ favoriteAddress, setFavoriteAddress, o function searchAddress() { if (!latLon?.lat || !latLon?.lon) return; setSearching(true); - getAddressByLatLon(latLon.lat, latLon.lon).then((resolved = '') => { - setFavoriteAddress(resolved); - onAutoFill?.(resolved); + getAddressByLatLon(latLon.lat, latLon.lon).then((addr = '') => { + setFavoriteAddress(addr); + onAutoFill?.(addr); setSearching(false); }); } diff --git a/map/src/map/util/TrackLayerProvider.js b/map/src/map/util/TrackLayerProvider.js index 17a294812..076bc73a2 100644 --- a/map/src/map/util/TrackLayerProvider.js +++ b/map/src/map/util/TrackLayerProvider.js @@ -8,7 +8,7 @@ import TracksManager, { isProtectedSegment, isWptGroupShown, } from '../../manager/track/TracksManager'; -import { getFavoriteId, resolveWptAppearance } from '../../manager/FavoritesManager'; +import { FAVORITE_FILE_TYPE, getFavoriteId, resolveWptAppearance } from '../../manager/FavoritesManager'; import EditablePolyline from './creator/EditablePolyline'; import { clusterMarkers, addMarkerTooltip, removeTooltip } from './Clusterizer'; import Utils from '../../util/Utils'; @@ -527,6 +527,14 @@ function parseWpt({ return; } marker.options.idObj = getFavoriteId(marker); + if (type === FAVORITE_FILE_TYPE && point.name) { + marker.on('add', () => { + const el = marker.getElement(); + if (el) { + el.id = `se-fav-map-marker-${point.name}`; + } + }); + } bindWptVisibilityOnAdd(marker, resolvedPointsGroups); if (ctx && map && data) { if (type === GPX_FILE_TYPE) { diff --git a/tests/selenium/src/tests/favorites/87-exit-guard-wpt-edit.mjs b/tests/selenium/src/tests/favorites/87-exit-guard-wpt-edit.mjs new file mode 100644 index 000000000..eed7df7d1 --- /dev/null +++ b/tests/selenium/src/tests/favorites/87-exit-guard-wpt-edit.mjs @@ -0,0 +1,116 @@ +import actionOpenMap from '../../actions/map/actionOpenMap.mjs'; +import actionLogIn from '../../actions/login/actionLogIn.mjs'; +import actionFinish from '../../actions/actionFinish.mjs'; +import actionOpenContextMenu from '../../actions/map/actionOpenContextMenu.mjs'; +import { assert, clickBy, sendKeysBy, waitBy, waitByRemoved } from '../../lib.mjs'; +import { By } from 'selenium-webdriver'; +import { getFiles } from '../../util.mjs'; +import actionOpenFavorites from '../../actions/favorites/actionOpenFavorites.mjs'; +import actionDeleteAllFavorites from '../../actions/favorites/actionDeleteAllFavorites.mjs'; +import actionDeleteFavGroup from '../../actions/favorites/actionDeleteFavGroup.mjs'; +import actionsUploadFavorites from '../../actions/favorites/actionsUploadFavorites.mjs'; + +export default async function test() { + await actionOpenMap(); + await actionLogIn(); + + const favorites = getFiles({ folder: 'favorites' }); + const shopGroupName = 'shops'; + const wptName = 'Test wpt'; + const wptName2 = 'Spuistraat (Oude Binnenstad) 96'; + + const { path: shopsPath } = favorites.find((t) => t.name === 'favorites-shops'); + + // prepare + await actionOpenFavorites(); + await actionDeleteAllFavorites(favorites); + await clickBy(By.id('se-import-fav-group')); + await actionsUploadFavorites({ files: shopsPath }); + await waitBy(By.id(`se-menu-fav-${shopGroupName}`)); + + // --- Test 1: Edit → change name → tracks menu → keep editing → back button → exit → edit closes --- + await clickBy(By.id(`se-menu-fav-${shopGroupName}`)); + await waitByRemoved(By.id(`se-menu-fav-${shopGroupName}`)); + await waitBy(By.id(`se-opened-fav-group-${shopGroupName}`)); + await waitBy(By.id(`se-actions-${wptName}`), { idle: true }); + await clickBy(By.id(`se-actions-${wptName}`)); + await waitBy(By.id('se-fav-item-actions')); + await clickBy(By.id('se-edit-fav-item')); + await waitBy(By.id('se-edit-fav-dialog')); + await sendKeysBy(By.id('se-fav-name-input'), ' edited'); + // click tracks menu → navigates to tracks URL → guard intercepts → dialog appears + await clickBy(By.id('se-show-menu-tracks')); + await waitBy(By.id('se-exit-dialog-keep-editing')); + await clickBy(By.id('se-exit-dialog-keep-editing')); + // edit panel is still mounted (changes not saved) + await waitBy(By.id('se-edit-fav-dialog')); + // click back button in the edit panel → guard fires → exit → panel closes + await clickBy(By.id('se-back-edit-wpt-panel')); + await waitBy(By.id('se-exit-dialog-exit')); + await clickBy(By.id('se-exit-dialog-exit')); + await waitByRemoved(By.id('se-edit-fav-dialog')); + // navigate back to favorites root + await actionOpenFavorites(); + await waitBy(By.id(`se-menu-fav-${shopGroupName}`)); + // --- Test 2: Add from map → type name → open context menu again → add favorite → guard fires → dialog --- + await actionOpenContextMenu(); + await clickBy(By.id('se-add-favorite-action')); + await waitBy(By.id('se-add-fav-dialog')); + await sendKeysBy(By.id('se-fav-name-input'), 'My New Favorite'); + await actionOpenContextMenu(); + await clickBy(By.id('se-add-favorite-action')); + await waitBy(By.id('se-exit-dialog-exit')); + await clickBy(By.id('se-exit-dialog-exit')); + await clickBy(By.id('se-close-add-wpt-panel')); + await actionOpenFavorites(); + await clickBy(By.id('se-back-folder-button-favorites')); + await waitBy(By.id(`se-menu-fav-${shopGroupName}`)); + // --- Test 3: Edit → no changes → add favorite from context menu → NO dialog --- + await clickBy(By.id(`se-menu-fav-${shopGroupName}`)); + await waitByRemoved(By.id(`se-menu-fav-${shopGroupName}`)); + await waitBy(By.id(`se-opened-fav-group-${shopGroupName}`)); + await clickBy(By.id(`se-fav-item-name-${wptName}`)); + await waitBy(By.id(`se-fav-item-info-${wptName}`)); + await clickBy(By.id('se-edit-fav-item')); + await waitBy(By.id('se-edit-fav-dialog')); + await actionOpenContextMenu(); + await clickBy(By.id('se-add-favorite-action')); + const unexpectedDialog = await waitBy(By.id('se-exit-dialog-exit'), { optional: true, idle: true }); + await assert(!unexpectedDialog, 'No exit dialog should appear when there are no changes'); + await waitByRemoved(By.id('se-edit-fav-dialog')); + await waitBy(By.id('se-add-fav-dialog')); + // close the add panel and go back to favorites root + await clickBy(By.id('se-close-add-wpt-panel')); + await clickBy(By.id('se-back-wpt-details')); + // --- Test 4: Click map marker → edit → change name → click another map marker → guard → exit → wptName2 opens --- + await waitBy(By.id(`se-opened-fav-group-${shopGroupName}`)); + // pan map to favorites: click wptName in list → map scrolls to it → go back to list + await clickBy(By.id(`se-fav-item-name-${wptName}`)); + await waitBy(By.id(`se-fav-item-info-${wptName}`)); + await clickBy(By.id('se-back-wpt-details')); + await waitByRemoved(By.id(`se-fav-item-info-${wptName}`)); + await waitBy(By.id(`se-opened-fav-group-${shopGroupName}`)); + // now markers are in viewport — click wptName map marker → WptDetails in right panel, list stays on left + await waitBy(By.id(`se-fav-map-marker-${wptName}`), { idle: true }); + await clickBy(By.id(`se-fav-map-marker-${wptName}`)); + await waitBy(By.id(`se-fav-item-info-${wptName}`)); + await clickBy(By.id('se-edit-fav-item')); + await waitBy(By.id('se-edit-fav-dialog')); + await sendKeysBy(By.id('se-fav-name-input'), ' changed'); + // click wptName2 map marker → guard fires, edit panel stays open + await clickBy(By.id(`se-fav-map-marker-${wptName2}`)); + await waitBy(By.id('se-exit-dialog-exit')); + await waitBy(By.id('se-edit-fav-dialog')); + await clickBy(By.id('se-exit-dialog-exit')); + await waitByRemoved(By.id('se-edit-fav-dialog')); + // after exit wptName2 details open + await waitBy(By.id(`se-fav-item-info-${wptName2}`)); + // clean up + await clickBy(By.id('se-close-wpt-details')); + await clickBy(By.id('se-back-folder-button-favorites')); + await waitBy(By.id(`se-menu-fav-${shopGroupName}`), { idle: true }); + await actionDeleteFavGroup(shopGroupName); + await waitBy(By.id('se-empty-page')); + + await actionFinish(); +} From ee2cc89df025eb0017c22a997d8e17fc4359f045 Mon Sep 17 00:00:00 2001 From: Kseniia Velychko Date: Mon, 18 May 2026 11:15:07 +0300 Subject: [PATCH 04/20] Add exit guard and blocker for WptEditPanel --- map/src/App.js | 161 +++++++++++------- map/src/context/AppContext.js | 2 - .../infoblock/components/InformationBlock.jsx | 4 +- .../components/favorite/WptEditPanel.jsx | 20 ++- .../favorite/structure/FavoriteAddress.jsx | 2 +- .../infoblock/components/wpt/WptDetails.jsx | 5 +- map/src/map/components/ContextMenu.jsx | 7 +- map/src/menu/MainMenu.js | 16 +- map/src/menu/actions/FavoriteItemActions.jsx | 7 +- .../util/hooks/map/useSelectMarkerOnMap.js | 2 +- map/src/util/hooks/useExitGuard.js | 55 +++--- tests/selenium/favorites/favorites-shops.gpx | 11 -- .../favorites/87-exit-guard-wpt-edit.mjs | 5 +- 13 files changed, 167 insertions(+), 130 deletions(-) diff --git a/map/src/App.js b/map/src/App.js index 367a1eee1..1e5e2c516 100644 --- a/map/src/App.js +++ b/map/src/App.js @@ -1,5 +1,5 @@ -import { createContext, React, useCallback, useContext, useState } from 'react'; -import { BrowserRouter, Route, Routes, useNavigate } from 'react-router-dom'; +import { createContext, React, useCallback, useContext, useMemo, useState } from 'react'; +import { createBrowserRouter, Outlet, RouterProvider, useNavigate } from 'react-router-dom'; import { createTheme, ThemeProvider } from '@mui/material/styles'; import GlobalFrame from './frame/GlobalFrame'; import { AppContextProvider } from './context/AppContext'; @@ -106,71 +106,110 @@ const App = () => { } } + const router = useMemo( + () => + createBrowserRouter( + [ + { + path: '/', + element: ( + <> + + + + + ), + children: [ + { + path: MAIN_URL, + element: , + children: [ + { + path: LOGIN_URL, + element: , + children: [ + { + path: PURCHASES_URL, + element: , + children: [{ path: ':key', element: }], + }, + { path: GARMIN_URL, element: }, + ], + }, + { + path: DELETE_ACCOUNT_URL, + element: , + }, + { + path: SEARCH_URL, + element: , + children: [ + { path: EXPLORE_URL, element: }, + { path: POI_CATEGORIES_URL, element: }, + { path: SEARCH_RESULT_URL, element: }, + ], + }, + { path: CONFIGURE_URL, element: }, + { + path: WEATHER_URL, + element: , + children: [{ path: WEATHER_FORECAST_URL, element: }], + }, + { + path: TRACKS_URL, + element: , + children: [ + { + path: INFO_MENU_URL + ':filename', + element: , + children: [{ path: SHARE_MENU_URL, element: }], + }, + ], + }, + { path: VISIBLE_TRACKS_URL, element: }, + { + path: FAVORITES_URL, + element: , + children: [ + { + path: INFO_MENU_URL + ':favgroup/:favname', + element: , + }, + { + path: INFO_MENU_URL + ':filename' + '/' + SHARE_MENU_URL, + element: , + }, + ], + }, + { path: NAVIGATE_URL, element: }, + { path: PLANROUTE_URL, element: }, + { path: TRAVEL_URL, element: }, + { path: SETTINGS_URL, element: }, + { path: SHARE_FILE_URL, element: }, + { path: TRACK_ANALYZER_URL, element: }, + { path: POI_URL, element: }, + { path: STOP_URL, element: }, + ], + }, + { path: PRICING_URL, element: }, + ], + }, + ], + { + // Enable React Router v7 features: concurrent transitions and correct relative splat path resolution + future: { v7_startTransition: true, v7_relativeSplatPath: true }, + } + ), + [resetKey] + ); + return ( - - - - - }> - }> - }> - }> - - } /> - - } - /> - }> - }> - }> - }> - - }> - }> - } - > - - }> - }> - } /> - - - }> - }> - } - /> - } - /> - - }> - }> - }> - }> - }> - }> - }> - }> - - }> - - + diff --git a/map/src/context/AppContext.js b/map/src/context/AppContext.js index 15f8ae2de..ac9835834 100644 --- a/map/src/context/AppContext.js +++ b/map/src/context/AppContext.js @@ -208,8 +208,6 @@ export const AppContextProvider = (props) => { location: null, }); - // Registry of exit guards: { key: guardFn }. Components register via useExitGuard({ register }). - // Callers: const guard = ctx.exitGuards.wptEdit; guard ? guard(action) : action(); const [exitGuards, setExitGuards] = useState({}); const [processingGroups, setProcessingGroups] = useState(false); diff --git a/map/src/infoblock/components/InformationBlock.jsx b/map/src/infoblock/components/InformationBlock.jsx index 693e609c7..8a60ea9ad 100644 --- a/map/src/infoblock/components/InformationBlock.jsx +++ b/map/src/infoblock/components/InformationBlock.jsx @@ -113,8 +113,8 @@ export default function InformationBlock({ previewAppearance: null, }; }); - const guard = ctx.exitGuards.wptEdit; - guard ? guard(close) : close(); + if (ctx.exitGuards.wptEdit?.hasChanges) return; + close(); }, [ctx.selectedWpt, ctx.currentObjectType]); useEffect(() => { diff --git a/map/src/infoblock/components/favorite/WptEditPanel.jsx b/map/src/infoblock/components/favorite/WptEditPanel.jsx index aaf7dc74d..8142e6de8 100644 --- a/map/src/infoblock/components/favorite/WptEditPanel.jsx +++ b/map/src/infoblock/components/favorite/WptEditPanel.jsx @@ -23,6 +23,7 @@ import FavoriteHelper from './FavoriteHelper'; import DeleteWptDialog from '../../../dialogs/favorites/DeleteWptDialog'; import ExitWithoutSavingDialog from '../../../dialogs/favorites/ExitWithoutSavingDialog'; import useExitGuard from '../../../util/hooks/useExitGuard'; +import { useBlocker } from 'react-router-dom'; import { ADDRESS_NOT_FOUND } from '../wpt/WptDetails'; import { FINAL_POI_ICON_NAME, WEB_POI_PREFIX, WEB_PREFIX } from '../wpt/WptTagsProvider'; import TracksManager, { GPX_FILE_EXT } from '../../../manager/track/TracksManager'; @@ -101,14 +102,9 @@ export default function WptEditPanel({ setShowInfoBlock }) { renderDialog: ({ onKeepEditing, onExit }) => ( ), - register: (fn) => - ctx.setExitGuards((prev) => { - if (fn) return { ...prev, wptEdit: fn }; - const next = { ...prev }; - delete next.wptEdit; - return next; - }), + register: (guard) => ctx.setExitGuards((prev) => ({ ...prev, wptEdit: guard ?? undefined })), }); + const blocker = useBlocker(hasChanges); useEffect(() => { getIconCategories().then(); @@ -552,6 +548,16 @@ export default function WptEditPanel({ setShowInfoBlock }) { return ( <> {dialog} + {blocker.state === 'blocked' && ( + blocker.reset()} + onExit={() => { + closePanel(); + blocker.proceed(); + }} + /> + )} {activePanel === 'description' && ( setFavoriteAddress(e.target.value)} - value={searching ? t('web:fav_address_searching') : favoriteAddress} + value={searching ? t('web:fav_address_searching') : (favoriteAddress ?? '')} inputProps={{ className: styles.fieldInput }} InputProps={{ endAdornment: ( diff --git a/map/src/infoblock/components/wpt/WptDetails.jsx b/map/src/infoblock/components/wpt/WptDetails.jsx index ebabd5afe..d14ab066b 100644 --- a/map/src/infoblock/components/wpt/WptDetails.jsx +++ b/map/src/infoblock/components/wpt/WptDetails.jsx @@ -601,10 +601,11 @@ export default function WptDetails({ setOpenWptTab, setShowInfoBlock }) { } else if (type.isFav) { if (!wpt.mapObj) { ctx.setSelectedFavoriteObj(null); - closeOnlyFavDetails(); } else { - closeObjectFromMap(); + // remove the selected pin from the map + ctx.setCloseMapObj(true); } + closeOnlyFavDetails(); } else if (type.isShareFav) { setShowInfoBlock(false); ctx.setSelectedGpxFile((prev) => ({ ...prev, markerCurrent: null, favItem: false, name: null })); diff --git a/map/src/map/components/ContextMenu.jsx b/map/src/map/components/ContextMenu.jsx index 18b59d11e..ace5da635 100644 --- a/map/src/map/components/ContextMenu.jsx +++ b/map/src/map/components/ContextMenu.jsx @@ -68,8 +68,11 @@ export default function ContextMenu({ setGeocodingData, setRegionData }) { const handleMenuItemClick = (callback) => { const latlng = clickLatLng; handleClose(); - const guard = ctx.exitGuards.wptEdit; - guard ? guard(() => callback(latlng)) : callback(latlng); + if (ctx.exitGuards.wptEdit) { + ctx.exitGuards.wptEdit.guard(() => callback(latlng)); + } else { + callback(latlng); + } }; const openLogin = () => { diff --git a/map/src/menu/MainMenu.js b/map/src/menu/MainMenu.js index f0cd72d77..e8068bbd0 100644 --- a/map/src/menu/MainMenu.js +++ b/map/src/menu/MainMenu.js @@ -743,20 +743,21 @@ export default function MainMenu({ } function selectMenu({ item, openFromUrl = false }) { - if (!openFromUrl) { - const guard = ctx.exitGuards.wptEdit; - guard ? guard(() => doSelectMenu({ item })) : doSelectMenu({ item }); - return; - } doSelectMenu({ item }); } function doSelectMenu({ item }) { - closeSubPages({ ctx, ltx }); + const editInProgress = !!ctx.exitGuards.wptEdit?.hasChanges; + if (!editInProgress) { + closeSubPages({ ctx, ltx }); + } let currentType; if (menuInfo) { // update menu - setShowInfoBlock(false); + if (!editInProgress) { + setShowInfoBlock(false); + ctx.setCurrentObjectType(null); + } ctx.setOpenNavigationSettings(false); ctx.setSearchSettings({ ...ctx.searchSettings, showExploreMarkers: false }); closeCloudSettings(openCloudSettings, setOpenCloudSettings, ctx); @@ -767,7 +768,6 @@ export default function MainMenu({ } setMenuInfo(menu?.component); currentType = menu?.type; - ctx.setCurrentObjectType(null); } else { // select first menu setMenuInfo(item.component); diff --git a/map/src/menu/actions/FavoriteItemActions.jsx b/map/src/menu/actions/FavoriteItemActions.jsx index d2fa7b580..dc0ef8d04 100644 --- a/map/src/menu/actions/FavoriteItemActions.jsx +++ b/map/src/menu/actions/FavoriteItemActions.jsx @@ -50,12 +50,15 @@ const FavoriteItemActions = forwardRef(({ marker, group, setOpenActions }, ref) id={'se-edit-fav-item'} className={styles.action} onClick={() => { - const guard = ctx.exitGuards.wptEdit; const action = () => { ctx.setAddFavorite({ editWpt: favorite, openKey: Date.now() }); setOpenActions(false); }; - guard ? guard(action) : action(); + if (ctx.exitGuards.wptEdit) { + ctx.exitGuards.wptEdit.guard(action); + } else { + action(); + } }} > diff --git a/map/src/util/hooks/map/useSelectMarkerOnMap.js b/map/src/util/hooks/map/useSelectMarkerOnMap.js index 871999ae4..a84db56a6 100644 --- a/map/src/util/hooks/map/useSelectMarkerOnMap.js +++ b/map/src/util/hooks/map/useSelectMarkerOnMap.js @@ -83,7 +83,7 @@ export function useSelectMarkerOnMap({ ctx, getLayers, layers: layersProp, type, useEffect(() => { if (!map) return; - if (ctx.addFavorite?.editWpt && ctx.exitGuards?.wptEdit) { + if (ctx.addFavorite?.editWpt && ctx.exitGuards.wptEdit?.hasChanges) { return; } diff --git a/map/src/util/hooks/useExitGuard.js b/map/src/util/hooks/useExitGuard.js index 0111741ef..1b1962bf0 100644 --- a/map/src/util/hooks/useExitGuard.js +++ b/map/src/util/hooks/useExitGuard.js @@ -1,9 +1,15 @@ -import { useEffect, useRef, useState } from 'react'; +import { useLayoutEffect, useState } from 'react'; /** * Guards actions with an "Exit without saving?" dialog. - * The dialog is fully defined in the calling component — text, style, callbacks. - * This hook only manages the pending-action state. + * + * Handles two kinds of triggers: + * 1. Local (back button, close icon): call guardAction(fn) directly. + * 2. External (map marker click, context menu, etc.): pass a `register` + * callback — the hook registers itself in context so any caller can do + * ctx.exitGuards.wptEdit.guard(fn). Re-registers automatically when hasChanges + * changes so callers always see the current state. + * For URL navigations use React Router's useBlocker. * * Usage: * const { guardAction, dialog } = useExitGuard({ @@ -11,24 +17,19 @@ import { useEffect, useRef, useState } from 'react'; * renderDialog: ({ onKeepEditing, onExit }) => ( * * ), - * onExitConfirmed: () => { ... cleanup ... }, - * // Optional: expose guardAction to other components via ctx.exitGuards. - * // register(fn) is called with a stable guard on mount, register(null) on unmount. - * register: (fn) => ctx.setExitGuards((prev) => fn ? { ...prev, myKey: fn } : omit(prev, 'myKey')), + * register: (g) => ctx.setExitGuards((prev) => ({ ...prev, wptEdit: g ?? undefined })), // optional * }); * - * // Wrap any user action: - * guardAction(() => doSomething()); - * - * // Place once in JSX: - * {dialog} + * guardAction(() => doSomething()); // wrap any local action + * {dialog} // place once in JSX * - * @param {boolean} hasChanges - whether there are unsaved changes - * @param {Function} renderDialog - ({ onKeepEditing, onExit }) => ReactElement - * @param {Function} [onExitConfirmed] - called before the pending action when user confirms exit - * @param {Function} [register] - called with stable guardAction on mount, null on unmount + * @param {boolean} hasChanges - whether there are unsaved changes + * @param {Function} renderDialog - ({ onKeepEditing, onExit }) => ReactElement + * @param {Function} [register] - (guard | null) => void — called with + * { hasChanges, guard: fn } on mount/update + * and with null on unmount */ -export default function useExitGuard({ hasChanges, renderDialog, onExitConfirmed, register }) { +export default function useExitGuard({ hasChanges, renderDialog, register }) { const [pendingAction, setPendingAction] = useState(null); function guardAction(action) { @@ -39,24 +40,22 @@ export default function useExitGuard({ hasChanges, renderDialog, onExitConfirmed action(); } - const guardRef = useRef(guardAction); - guardRef.current = guardAction; - - useEffect(() => { - if (!register) return; - const stableGuard = (action) => guardRef.current(action); - register(stableGuard); - return () => register(null); - }, []); + // Register in context using useLayoutEffect (fires synchronously after DOM + // update, before paint) so callers never see a stale or missing guard. + // Re-runs whenever hasChanges flips to keep the registered snapshot current. + useLayoutEffect(() => { + register?.({ hasChanges, guard: guardAction }); + return () => register?.(null); + }, [hasChanges]); const dialog = pendingAction !== null ? renderDialog({ onKeepEditing: () => setPendingAction(null), onExit: () => { + const action = pendingAction; setPendingAction(null); - onExitConfirmed?.(); - pendingAction(); + action(); }, }) : null; diff --git a/tests/selenium/favorites/favorites-shops.gpx b/tests/selenium/favorites/favorites-shops.gpx index 4e091595c..d9b3ea997 100644 --- a/tests/selenium/favorites/favorites-shops.gpx +++ b/tests/selenium/favorites/favorites-shops.gpx @@ -150,17 +150,6 @@ #43a047ff - - - Spuistraat (Oude Binnenstad) 96 - shops - - Vlissingen - amenity_fire_station - circle - #43a047ff - - Waterlooplein (Amsterdam) 1 diff --git a/tests/selenium/src/tests/favorites/87-exit-guard-wpt-edit.mjs b/tests/selenium/src/tests/favorites/87-exit-guard-wpt-edit.mjs index eed7df7d1..d70c3baa3 100644 --- a/tests/selenium/src/tests/favorites/87-exit-guard-wpt-edit.mjs +++ b/tests/selenium/src/tests/favorites/87-exit-guard-wpt-edit.mjs @@ -17,7 +17,7 @@ export default async function test() { const favorites = getFiles({ folder: 'favorites' }); const shopGroupName = 'shops'; const wptName = 'Test wpt'; - const wptName2 = 'Spuistraat (Oude Binnenstad) 96'; + const wptName2 = 'Dapperstraat 40-1'; const { path: shopsPath } = favorites.find((t) => t.name === 'favorites-shops'); @@ -51,6 +51,7 @@ export default async function test() { await waitByRemoved(By.id('se-edit-fav-dialog')); // navigate back to favorites root await actionOpenFavorites(); + await clickBy(By.id('se-back-folder-button-favorites')); await waitBy(By.id(`se-menu-fav-${shopGroupName}`)); // --- Test 2: Add from map → type name → open context menu again → add favorite → guard fires → dialog --- await actionOpenContextMenu(); @@ -62,8 +63,6 @@ export default async function test() { await waitBy(By.id('se-exit-dialog-exit')); await clickBy(By.id('se-exit-dialog-exit')); await clickBy(By.id('se-close-add-wpt-panel')); - await actionOpenFavorites(); - await clickBy(By.id('se-back-folder-button-favorites')); await waitBy(By.id(`se-menu-fav-${shopGroupName}`)); // --- Test 3: Edit → no changes → add favorite from context menu → NO dialog --- await clickBy(By.id(`se-menu-fav-${shopGroupName}`)); From 2bd89b839828fff6571c52df4810e6924265735a Mon Sep 17 00:00:00 2001 From: Kseniia Velychko Date: Mon, 18 May 2026 12:09:23 +0300 Subject: [PATCH 05/20] Fix hasAddChanges description comparison --- map/src/infoblock/components/favorite/WptEditPanel.jsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/map/src/infoblock/components/favorite/WptEditPanel.jsx b/map/src/infoblock/components/favorite/WptEditPanel.jsx index 8142e6de8..31b91272e 100644 --- a/map/src/infoblock/components/favorite/WptEditPanel.jsx +++ b/map/src/infoblock/components/favorite/WptEditPanel.jsx @@ -67,6 +67,7 @@ export default function WptEditPanel({ setShowInfoBlock }) { const [favoriteAddress, setFavoriteAddress] = useState(editWpt?.address ?? ctx.addFavorite?.address ?? ''); const [initialAddress, setInitialAddress] = useState(editWpt?.address ?? ctx.addFavorite?.address ?? ''); const [favoriteDescription, setFavoriteDescription] = useState(editWpt?.desc ?? ''); + const [initialDescription] = useState(editWpt?.desc ?? ''); const [addAddress, setAddAddress] = useState(isEditMode || isPoi || (isAddMode && !isAddTrackWpt)); const [activePanel, setActivePanel] = useState(null); // null | 'description' | 'icon' | 'color' const [favoriteGroup, setFavoriteGroup] = useState(null); @@ -93,7 +94,9 @@ export default function WptEditPanel({ setShowInfoBlock }) { favoriteShape !== (editWpt?.background ?? MarkerOptions.BACKGROUND_WPT_SHAPE_CIRCLE); const hasAddChanges = - favoriteName !== initialName || favoriteDescription !== '' || favoriteAddress !== initialAddress; + favoriteName !== initialName || + favoriteDescription !== initialDescription || + favoriteAddress !== initialAddress; const hasChanges = isEditMode ? hasEditChanges : hasAddChanges; From 4a702ac93cd9c34be957ad7e2ac05a51a20419c3 Mon Sep 17 00:00:00 2001 From: Kseniia Velychko Date: Mon, 18 May 2026 12:38:45 +0300 Subject: [PATCH 06/20] Fix helper text alignment in FavoriteName --- map/src/infoblock/components/favorite/WptEditPanel.jsx | 4 ++++ .../components/favorite/structure/FavoriteName.jsx | 10 ++++++---- .../components/favorite/wptEditPanel.module.css | 3 ++- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/map/src/infoblock/components/favorite/WptEditPanel.jsx b/map/src/infoblock/components/favorite/WptEditPanel.jsx index 31b91272e..5b7a9adfa 100644 --- a/map/src/infoblock/components/favorite/WptEditPanel.jsx +++ b/map/src/infoblock/components/favorite/WptEditPanel.jsx @@ -80,6 +80,7 @@ export default function WptEditPanel({ setShowInfoBlock }) { editWpt?.background ?? MarkerOptions.BACKGROUND_WPT_SHAPE_CIRCLE ); const [errorName, setErrorName] = useState(false); + const [submitted, setSubmitted] = useState(false); const [process, setProcess] = useState(false); const [latLon, setLatLon] = useState(null); const [deleteWptDialogOpen, setDeleteWptDialogOpen] = useState(false); @@ -160,6 +161,8 @@ export default function WptEditPanel({ setShowInfoBlock }) { } async function save() { + setSubmitted(true); + if (!favoriteName?.trim()) return; setProcess(true); if (isEditMode) { if (isEditTrackWpt) { @@ -614,6 +617,7 @@ export default function WptEditPanel({ setShowInfoBlock }) { favorite={isEditMode ? editWpt : undefined} setErrorName={setErrorName} widthDialog={PANEL_CONTENT_WIDTH} + submitted={submitted} /> {!addAddress && ( diff --git a/map/src/infoblock/components/favorite/structure/FavoriteName.jsx b/map/src/infoblock/components/favorite/structure/FavoriteName.jsx index b42c0bc69..7654f5785 100644 --- a/map/src/infoblock/components/favorite/structure/FavoriteName.jsx +++ b/map/src/infoblock/components/favorite/structure/FavoriteName.jsx @@ -14,6 +14,7 @@ export default function FavoriteName({ setErrorName, widthDialog, isGroupName = false, + submitted = true, }) { const ctx = useContext(AppContext); @@ -44,13 +45,13 @@ export default function FavoriteName({ useEffect(() => { validateName(favoriteName, favNames); - }, [favoriteName]); + }, [favoriteName, submitted]); function validateName(name, otherNames) { const trimmedName = name?.trim(); if (!trimmedName) { - setErrorName(true); + setErrorName(submitted); setNameAlreadyExist(false); return; } @@ -62,7 +63,7 @@ export default function FavoriteName({ } function getErrorText(name) { - if (name === '') { + if (name === '' && submitted) { return t('web:fav_name_empty'); } else if (nameAlreadyExist) { return t('web:fav_name_already_exists'); @@ -95,8 +96,9 @@ export default function FavoriteName({ onChange={(e) => setFavoriteName(e.target.value)} value={favoriteName} autoFocus - error={favoriteName === '' || nameAlreadyExist} + error={(submitted && favoriteName === '') || nameAlreadyExist} helperText={getErrorText(favoriteName)} + inputProps={{ className: styles.fieldInput }} FormHelperTextProps={{ className: styles.helperText }} /> diff --git a/map/src/infoblock/components/favorite/wptEditPanel.module.css b/map/src/infoblock/components/favorite/wptEditPanel.module.css index de614e14e..358118e17 100644 --- a/map/src/infoblock/components/favorite/wptEditPanel.module.css +++ b/map/src/infoblock/components/favorite/wptEditPanel.module.css @@ -19,7 +19,8 @@ .helperText { height: 16px; line-height: 16px; - margin: 2px 0 0; + margin: 0 !important; + transform: translateY(-1px); overflow: hidden; white-space: nowrap; text-overflow: ellipsis; From c6fdd9cdbb42d212587d2ba76d421618f4cb20ac Mon Sep 17 00:00:00 2001 From: Kseniia Velychko Date: Mon, 18 May 2026 12:57:42 +0300 Subject: [PATCH 07/20] Prevent password manager autocomplete on Name and Address fields --- .../infoblock/components/favorite/structure/FavoriteAddress.jsx | 2 +- .../infoblock/components/favorite/structure/FavoriteName.jsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/map/src/infoblock/components/favorite/structure/FavoriteAddress.jsx b/map/src/infoblock/components/favorite/structure/FavoriteAddress.jsx index b724413cb..147d06a4e 100644 --- a/map/src/infoblock/components/favorite/structure/FavoriteAddress.jsx +++ b/map/src/infoblock/components/favorite/structure/FavoriteAddress.jsx @@ -36,7 +36,7 @@ export default function FavoriteAddress({ favoriteAddress, setFavoriteAddress, o disabled={searching} onChange={(e) => setFavoriteAddress(e.target.value)} value={searching ? t('web:fav_address_searching') : (favoriteAddress ?? '')} - inputProps={{ className: styles.fieldInput }} + inputProps={{ className: styles.fieldInput, autoComplete: 'off' }} InputProps={{ endAdornment: ( diff --git a/map/src/infoblock/components/favorite/structure/FavoriteName.jsx b/map/src/infoblock/components/favorite/structure/FavoriteName.jsx index 7654f5785..295832236 100644 --- a/map/src/infoblock/components/favorite/structure/FavoriteName.jsx +++ b/map/src/infoblock/components/favorite/structure/FavoriteName.jsx @@ -98,7 +98,7 @@ export default function FavoriteName({ autoFocus error={(submitted && favoriteName === '') || nameAlreadyExist} helperText={getErrorText(favoriteName)} - inputProps={{ className: styles.fieldInput }} + inputProps={{ className: styles.fieldInput, autoComplete: 'off' }} FormHelperTextProps={{ className: styles.helperText }} /> From 14d005d3596119ea77ebe8caad40a809de6f2aba Mon Sep 17 00:00:00 2001 From: Kseniia Velychko Date: Mon, 18 May 2026 13:40:04 +0300 Subject: [PATCH 08/20] Fix left padding alignment in Name and Address fields --- .../components/favorite/structure/FavoriteAddress.jsx | 1 + .../components/favorite/structure/FavoriteName.jsx | 1 + .../infoblock/components/favorite/wptEditPanel.module.css | 6 +++++- 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/map/src/infoblock/components/favorite/structure/FavoriteAddress.jsx b/map/src/infoblock/components/favorite/structure/FavoriteAddress.jsx index 147d06a4e..29744a99c 100644 --- a/map/src/infoblock/components/favorite/structure/FavoriteAddress.jsx +++ b/map/src/infoblock/components/favorite/structure/FavoriteAddress.jsx @@ -37,6 +37,7 @@ export default function FavoriteAddress({ favoriteAddress, setFavoriteAddress, o onChange={(e) => setFavoriteAddress(e.target.value)} value={searching ? t('web:fav_address_searching') : (favoriteAddress ?? '')} inputProps={{ className: styles.fieldInput, autoComplete: 'off' }} + InputLabelProps={{ className: styles.fieldLabel }} InputProps={{ endAdornment: ( diff --git a/map/src/infoblock/components/favorite/structure/FavoriteName.jsx b/map/src/infoblock/components/favorite/structure/FavoriteName.jsx index 295832236..a1de130bb 100644 --- a/map/src/infoblock/components/favorite/structure/FavoriteName.jsx +++ b/map/src/infoblock/components/favorite/structure/FavoriteName.jsx @@ -99,6 +99,7 @@ export default function FavoriteName({ error={(submitted && favoriteName === '') || nameAlreadyExist} helperText={getErrorText(favoriteName)} inputProps={{ className: styles.fieldInput, autoComplete: 'off' }} + InputLabelProps={{ className: styles.fieldLabel }} FormHelperTextProps={{ className: styles.helperText }} /> diff --git a/map/src/infoblock/components/favorite/wptEditPanel.module.css b/map/src/infoblock/components/favorite/wptEditPanel.module.css index 358118e17..89680d0df 100644 --- a/map/src/infoblock/components/favorite/wptEditPanel.module.css +++ b/map/src/infoblock/components/favorite/wptEditPanel.module.css @@ -27,7 +27,11 @@ } .fieldInput { - padding-left: 8px !important; + padding-left: 16px !important; +} + +.fieldLabel { + left: 4px !important; } .actions { From 107caa2f6ad12b7bfa81c1ac2b7679005ecda0ef Mon Sep 17 00:00:00 2001 From: Kseniia Velychko Date: Mon, 18 May 2026 14:03:26 +0300 Subject: [PATCH 09/20] Fix WptEditPanel input padding, label alignment and save button spacing --- map/src/frame/components/btns/buttons.module.css | 2 +- map/src/infoblock/components/favorite/WptEditPanel.jsx | 2 -- map/src/infoblock/components/favorite/wptEditPanel.module.css | 2 +- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/map/src/frame/components/btns/buttons.module.css b/map/src/frame/components/btns/buttons.module.css index c6130d5d7..cb02c4372 100644 --- a/map/src/frame/components/btns/buttons.module.css +++ b/map/src/frame/components/btns/buttons.module.css @@ -91,7 +91,7 @@ } .primaryButton { display: flex !important; - padding: 6px 0px !important; + padding: 6px 12px !important; flex-direction: column !important; justify-content: center !important; align-items: center !important; diff --git a/map/src/infoblock/components/favorite/WptEditPanel.jsx b/map/src/infoblock/components/favorite/WptEditPanel.jsx index 5b7a9adfa..5699f36b6 100644 --- a/map/src/infoblock/components/favorite/WptEditPanel.jsx +++ b/map/src/infoblock/components/favorite/WptEditPanel.jsx @@ -616,7 +616,6 @@ export default function WptEditPanel({ setShowInfoBlock }) { favoriteGroup={favoriteGroup} favorite={isEditMode ? editWpt : undefined} setErrorName={setErrorName} - widthDialog={PANEL_CONTENT_WIDTH} submitted={submitted} /> {!addAddress && ( @@ -636,7 +635,6 @@ export default function WptEditPanel({ setShowInfoBlock }) { favoriteAddress={favoriteAddress} setFavoriteAddress={setFavoriteAddress} onAutoFill={setInitialAddress} - widthDialog={PANEL_CONTENT_WIDTH} latLon={isAddMode ? latLon : null} /> )} diff --git a/map/src/infoblock/components/favorite/wptEditPanel.module.css b/map/src/infoblock/components/favorite/wptEditPanel.module.css index 89680d0df..65f39ef9f 100644 --- a/map/src/infoblock/components/favorite/wptEditPanel.module.css +++ b/map/src/infoblock/components/favorite/wptEditPanel.module.css @@ -42,7 +42,7 @@ .saveAction { display: flex; - padding: 0 12px; + padding: 0 8px 0 0; justify-content: flex-end; align-items: center; } From 85883f11b1ed8660419f66b69aa01a2820682550 Mon Sep 17 00:00:00 2001 From: Kseniia Velychko Date: Mon, 18 May 2026 18:43:24 +0300 Subject: [PATCH 10/20] Center map on pin when WptEditPanel and secondary drawer open --- .../components/favorite/WptEditPanel.jsx | 19 ++++++++--- map/src/map/layers/FavoriteLayer.js | 17 +++++++++- map/src/map/layers/MapStateLayer.js | 34 +++++++++++++------ 3 files changed, 54 insertions(+), 16 deletions(-) diff --git a/map/src/infoblock/components/favorite/WptEditPanel.jsx b/map/src/infoblock/components/favorite/WptEditPanel.jsx index 5699f36b6..77d97e999 100644 --- a/map/src/infoblock/components/favorite/WptEditPanel.jsx +++ b/map/src/infoblock/components/favorite/WptEditPanel.jsx @@ -111,6 +111,10 @@ export default function WptEditPanel({ setShowInfoBlock }) { const blocker = useBlocker(hasChanges); useEffect(() => { + ctx.setAddFavorite((prev) => ({ + ...prev, + panRequest: { key: Date.now(), infoBlockWidthPx: MENU_INFO_OPEN_SIZE }, + })); getIconCategories().then(); if (!isTrackWpt) { const categoryName = isEditMode @@ -529,8 +533,15 @@ export default function WptEditPanel({ setShowInfoBlock }) { ); } + function closeActivePanel() { + togglePanel(activePanel); + } + function togglePanel(panel) { - setActivePanel((prev) => (prev === panel ? null : panel)); + const next = activePanel === panel ? null : panel; + setActivePanel(next); + const infoBlockWidthPx = next !== null ? MENU_INFO_OPEN_SIZE * 2 : MENU_INFO_OPEN_SIZE; + ctx.setAddFavorite((prev) => ({ ...prev, panRequest: { key: Date.now(), infoBlockWidthPx } })); } function handleClose() { @@ -568,7 +579,7 @@ export default function WptEditPanel({ setShowInfoBlock }) { setActivePanel(null)} + onClose={closeActivePanel} /> )} {activePanel === 'icon' && ( @@ -578,7 +589,7 @@ export default function WptEditPanel({ setShowInfoBlock }) { favoriteIconCategories={favoriteIconCategories} selectedGpxFile={ctx.selectedGpxFile} add={!isEditMode} - onClose={() => setActivePanel(null)} + onClose={closeActivePanel} /> )} {activePanel === 'color' && ( @@ -586,7 +597,7 @@ export default function WptEditPanel({ setShowInfoBlock }) { selectedColor={favoriteColor} setSelectedColor={setFavoriteColor} favoriteShape={favoriteShape} - onClose={() => setActivePanel(null)} + onClose={closeActivePanel} /> )} diff --git a/map/src/map/layers/FavoriteLayer.js b/map/src/map/layers/FavoriteLayer.js index 838eefe67..e2dff7267 100644 --- a/map/src/map/layers/FavoriteLayer.js +++ b/map/src/map/layers/FavoriteLayer.js @@ -19,6 +19,7 @@ import { resetSelectedPin, } from '../util/MarkerSelectionService'; import { panToIfNeeded } from '../util/MapManager'; +import { panToVisibleCenter } from './MapStateLayer'; import { useSelectMarkerOnMap } from '../../util/hooks/map/useSelectMarkerOnMap'; import MarkerOptions, { createPoiIcon, @@ -37,6 +38,13 @@ import LoginContext from '../../context/LoginContext'; import { MARKER_Z_INDEX_MAIN, MENU_INFO_OPEN_SIZE, NAVIGATE_URL } from '../../manager/GlobalManager'; import { NAVIGATION_OBJECT_TYPE_FAVORITE } from '../../manager/NavigationManager'; +function getAddFavoritePinLatLng(addFavorite) { + const { location, editWpt } = addFavorite; + if (location) return { lat: location.lat, lng: location.lng }; + if (editWpt) return { lat: editWpt.latlon?.lat ?? editWpt.lat, lng: editWpt.latlon?.lon ?? editWpt.lon }; + return null; +} + export function filterPointsInBounds(points, map) { if (!map) return []; if (!Array.isArray(points) || points.length === 0) return []; @@ -130,6 +138,13 @@ const FavoriteLayer = () => { return () => resetSelectedPin({ ctx, map, force: true }); }, [map, ctx.addFavorite?.location, ctx.addFavorite?.editWpt]); + // Pan map to keep the preview pin in the visible center: on open, secondary drawer open/close. + useEffect(() => { + const req = ctx.addFavorite?.panRequest; + if (!req || !map) return; + panToVisibleCenter(map, getAddFavoritePinLatLng(ctx.addFavorite), req.infoBlockWidthPx); + }, [ctx.addFavorite?.panRequest?.key]); + // Updates the selected pin icon in real-time when user changes appearance (color, icon, shape). // Works for both add mode (preview pin) and edit mode (selected existing pin). useEffect(() => { @@ -520,6 +535,6 @@ const FavoriteLayer = () => { } return null; -}; +};; export default FavoriteLayer; diff --git a/map/src/map/layers/MapStateLayer.js b/map/src/map/layers/MapStateLayer.js index f24d6937f..abe5a9308 100644 --- a/map/src/map/layers/MapStateLayer.js +++ b/map/src/map/layers/MapStateLayer.js @@ -28,21 +28,25 @@ const MAP_SPIN_COLOR = '#1976d2'; const TOP_PADDING = HEADER_SIZE; const BOTTOM_PADDING = 0; -function centerPercentsForInfoBlockPx(map, infoBlockWidthPx) { - if (!map?.getSize) { - return { left: '50%', top: '50%' }; - } +function calcVisibleCenterPx(map, infoBlockWidthPx) { const containerSize = map.getSize(); - if (!containerSize?.x || !containerSize?.y) { - return { left: '50%', top: '50%' }; - } + if (!containerSize?.x || !containerSize?.y) return null; const infoColumnWidthPx = Number.isFinite(infoBlockWidthPx) ? infoBlockWidthPx : 0; const leftChromeWidthPx = infoColumnWidthPx + MAIN_MENU_MIN_SIZE; - const centerX = leftChromeWidthPx + (containerSize.x - leftChromeWidthPx) / 2; - const centerY = TOP_PADDING + (containerSize.y - TOP_PADDING - BOTTOM_PADDING) / 2; return { - left: `${(centerX / containerSize.x) * 100}%`, - top: `${(centerY / containerSize.y) * 100}%`, + x: leftChromeWidthPx + (containerSize.x - leftChromeWidthPx) / 2, + y: TOP_PADDING + (containerSize.y - TOP_PADDING - BOTTOM_PADDING) / 2, + containerSize, + }; +} + +function centerPercentsForInfoBlockPx(map, infoBlockWidthPx) { + if (!map?.getSize) return { left: '50%', top: '50%' }; + const center = calcVisibleCenterPx(map, infoBlockWidthPx); + if (!center) return { left: '50%', top: '50%' }; + return { + left: `${(center.x / center.containerSize.x) * 100}%`, + top: `${(center.y / center.containerSize.y) * 100}%`, }; } @@ -54,6 +58,14 @@ export function getVisibleBboxCenterPercents(map, ctx) { return centerPercentsForInfoBlockPx(map, infoBlockWidthPx); } +export function panToVisibleCenter(map, latlng, infoBlockWidthPx) { + if (!map || !latlng) return; + const center = calcVisibleCenterPx(map, infoBlockWidthPx); + if (!center) return; + const pinPoint = map.latLngToContainerPoint(L.latLng(latlng.lat, latlng.lng ?? latlng.lon)); + map.panBy([pinPoint.x - center.x, pinPoint.y - center.y], { animate: true }); +} + export function mapSpinOptionsForVisibleBbox(map, ctx, options = {}) { const { isInfoBlockOpen, ...rest } = options; const positionPercents = isInfoBlockOpen From 1ab5dd9bf41c7315c0d22d07d3fc752ac08b2a44 Mon Sep 17 00:00:00 2001 From: Kseniia Velychko Date: Mon, 18 May 2026 19:00:36 +0300 Subject: [PATCH 11/20] Fix Folder title in WptEditPanel --- map/src/frame/components/items/ChevronItem.jsx | 6 ++++-- map/src/infoblock/components/favorite/WptEditPanel.jsx | 1 + .../components/favorite/structure/FavoriteGroup.jsx | 2 ++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/map/src/frame/components/items/ChevronItem.jsx b/map/src/frame/components/items/ChevronItem.jsx index d22bda109..b8d0e2f6e 100644 --- a/map/src/frame/components/items/ChevronItem.jsx +++ b/map/src/frame/components/items/ChevronItem.jsx @@ -3,12 +3,14 @@ import { ListItemIcon, ListItemText, MenuItem, Typography } from '@mui/material' import { ReactComponent as ChevronIcon } from '../../../assets/icons/ic_action_arrow_up.svg'; import styles from './items.module.css'; -export default function ChevronItem({ id, icon = null, title, value, onClick, disabled = false }) { +export default function ChevronItem({ id, icon = null, title, titleProps, value, onClick, disabled = false }) { return ( {icon && {icon}} - {title} + + {title} +
{value !== undefined && {value}} diff --git a/map/src/infoblock/components/favorite/WptEditPanel.jsx b/map/src/infoblock/components/favorite/WptEditPanel.jsx index 77d97e999..5dd00cb22 100644 --- a/map/src/infoblock/components/favorite/WptEditPanel.jsx +++ b/map/src/infoblock/components/favorite/WptEditPanel.jsx @@ -662,6 +662,7 @@ export default function WptEditPanel({ setShowInfoBlock }) { defaultGroup={defaultGroup} isTrackWpt={isTrackWpt} /> + setPanelOpen((o) => !o)} /> From a9c56d72d5a73d6137cfd5842376fcdd6cc7669e Mon Sep 17 00:00:00 2001 From: Kseniia Velychko Date: Tue, 19 May 2026 08:58:58 +0300 Subject: [PATCH 12/20] Refactor extract AppRadio --- map/src/frame/components/items/AppRadio.jsx | 14 ++++++++++++++ map/src/frame/components/items/SelectItemRadio.jsx | 5 +++-- .../components/favorite/AddFolderDialog.jsx | 9 ++++++--- .../components/favorite/addFolderDialog.module.css | 4 ++++ .../favorite/structure/FolderSelectionPanel.jsx | 6 +++--- 5 files changed, 30 insertions(+), 8 deletions(-) create mode 100644 map/src/frame/components/items/AppRadio.jsx diff --git a/map/src/frame/components/items/AppRadio.jsx b/map/src/frame/components/items/AppRadio.jsx new file mode 100644 index 000000000..7b8c27ee8 --- /dev/null +++ b/map/src/frame/components/items/AppRadio.jsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { Radio } from '@mui/material'; + +export default function AppRadio({ checked, onChange, onClick, className }) { + return ( + + ); +} diff --git a/map/src/frame/components/items/SelectItemRadio.jsx b/map/src/frame/components/items/SelectItemRadio.jsx index e79296272..6e46c19d1 100644 --- a/map/src/frame/components/items/SelectItemRadio.jsx +++ b/map/src/frame/components/items/SelectItemRadio.jsx @@ -1,5 +1,6 @@ import React from 'react'; -import { Box, ListItemText, MenuItem, Radio, Typography } from '@mui/material'; +import { Box, ListItemText, MenuItem, Typography } from '@mui/material'; +import AppRadio from './AppRadio'; import styles from './items.module.css'; import DividerWithMargin from '../dividers/DividerWithMargin'; @@ -34,7 +35,7 @@ export default function SelectItemRadio({ {title} - + diff --git a/map/src/infoblock/components/favorite/AddFolderDialog.jsx b/map/src/infoblock/components/favorite/AddFolderDialog.jsx index cf41df9d7..e82122c84 100644 --- a/map/src/infoblock/components/favorite/AddFolderDialog.jsx +++ b/map/src/infoblock/components/favorite/AddFolderDialog.jsx @@ -12,7 +12,6 @@ import { ListItemButton, ListItemText, Popover, - Radio, TextField, } from '@mui/material'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; @@ -21,6 +20,7 @@ import AppContext from '../../../context/AppContext'; import MarkerOptions from '../../../map/markers/MarkerOptions'; import FavoritesManager, { decodeGroupNameFromFile, saveFavoriteGroup } from '../../../manager/FavoritesManager'; import { sanitizedFileName } from '../../../util/Utils'; +import AppRadio from '../../../frame/components/items/AppRadio'; import dialogStyles from '../../../dialogs/dialog.module.css'; import itemStyles from '../../../frame/components/items/items.module.css'; import styles from './addFolderDialog.module.css'; @@ -194,10 +194,13 @@ export default function AddFolderDialog({ dialogOpen, setDialogOpen, parentGroup > - { setSelectedParent(effectiveGroup); diff --git a/map/src/infoblock/components/favorite/addFolderDialog.module.css b/map/src/infoblock/components/favorite/addFolderDialog.module.css index 08f3d4c0e..f6a8f2b46 100644 --- a/map/src/infoblock/components/favorite/addFolderDialog.module.css +++ b/map/src/infoblock/components/favorite/addFolderDialog.module.css @@ -25,3 +25,7 @@ padding: 3px 6px 3px 16px !important; min-height: 52px !important; } + +.dropdownItemText { + font-size: 16px !important; +} diff --git a/map/src/infoblock/components/favorite/structure/FolderSelectionPanel.jsx b/map/src/infoblock/components/favorite/structure/FolderSelectionPanel.jsx index 513a1f122..d3ac9bc7c 100644 --- a/map/src/infoblock/components/favorite/structure/FolderSelectionPanel.jsx +++ b/map/src/infoblock/components/favorite/structure/FolderSelectionPanel.jsx @@ -1,5 +1,5 @@ import React, { useState, useMemo, useContext } from 'react'; -import { Box, Divider, IconButton, List, ListItemButton, ListItemIcon, Radio, Typography } from '@mui/material'; +import { Box, Divider, IconButton, List, ListItemButton, ListItemIcon, Typography } from '@mui/material'; import { useTranslation } from 'react-i18next'; import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; import KeyboardArrowRightIcon from '@mui/icons-material/KeyboardArrowRight'; @@ -15,6 +15,7 @@ import ThickDivider from '../../../../frame/components/dividers/ThickDivider'; import AddFolderDialog from '../AddFolderDialog'; import isEmpty from 'lodash-es/isEmpty'; import values from 'lodash-es/values'; +import AppRadio from '../../../../frame/components/items/AppRadio'; import styles from './folderSelectionPanel.module.css'; import menuStyles from '../../../../menu/trackfavmenu.module.css'; @@ -130,11 +131,10 @@ export default function FolderSelectionPanel({ selectedGroup, defaultGroup, isTr folder.size != null && {folder.size} )} {!isVirtual ? ( - onSelect(folder.group ?? { name: folder.name })} onClick={(e) => e.stopPropagation()} - size="small" className={styles.radio} /> ) : ( From 79f51100add16231bcbc4b82355d9aa7e5257dae Mon Sep 17 00:00:00 2001 From: Kseniia Velychko Date: Tue, 19 May 2026 09:05:57 +0300 Subject: [PATCH 13/20] Fix folder item height --- .../favorite/structure/folderSelectionPanel.module.css | 2 ++ 1 file changed, 2 insertions(+) diff --git a/map/src/infoblock/components/favorite/structure/folderSelectionPanel.module.css b/map/src/infoblock/components/favorite/structure/folderSelectionPanel.module.css index 088a7b660..5909b32ed 100644 --- a/map/src/infoblock/components/favorite/structure/folderSelectionPanel.module.css +++ b/map/src/infoblock/components/favorite/structure/folderSelectionPanel.module.css @@ -13,6 +13,8 @@ .folderItem { min-height: 48px !important; + padding-top: 0 !important; + padding-bottom: 0 !important; padding-right: 4px !important; } From 90ba937750c9edd6362971bf4f31812f53e2d1f8 Mon Sep 17 00:00:00 2001 From: Kseniia Velychko Date: Tue, 19 May 2026 09:23:02 +0300 Subject: [PATCH 14/20] Add top level item and medium text for root folders in AddFolderDialog --- .../components/favorite/AddFolderDialog.jsx | 29 ++++++++++++------- .../favorite/addFolderDialog.module.css | 5 ++++ .../translations/en/web-translation.json | 1 + 3 files changed, 24 insertions(+), 11 deletions(-) diff --git a/map/src/infoblock/components/favorite/AddFolderDialog.jsx b/map/src/infoblock/components/favorite/AddFolderDialog.jsx index e82122c84..0044b3431 100644 --- a/map/src/infoblock/components/favorite/AddFolderDialog.jsx +++ b/map/src/infoblock/components/favorite/AddFolderDialog.jsx @@ -32,16 +32,18 @@ export default function AddFolderDialog({ dialogOpen, setDialogOpen, parentGroup const { t } = useTranslation(); const groups = ctx.favorites.groups; - const defaultGroup = groups?.find((g) => g.name === FavoritesManager.DEFAULT_GROUP_NAME) ?? null; const [folderName, setFolderName] = useState(''); const [folderNameError, setFolderNameError] = useState(''); - const [selectedParent, setSelectedParent] = useState(parentGroup ?? defaultGroup); + const [selectedParent, setSelectedParent] = useState(parentGroup ?? null); const [locationAnchorEl, setLocationAnchorEl] = useState(null); const [process, setProcess] = useState(false); const [advancedOpen, setAdvancedOpen] = useState(false); - const flatGroups = useMemo(() => groupTreeToList(groupTree ?? []), [groupTree]); + const flatGroups = useMemo(() => { + const topLevelItem = { group: null, displayName: t('web:fav_top_level'), level: 0, fullName: null }; + return [topLevelItem, ...groupTreeToList(groupTree ?? [])]; + }, [groupTree, t]); const locationOpen = Boolean(locationAnchorEl); const parentDisplayName = getDisplayName(selectedParent, t); @@ -174,15 +176,16 @@ export default function AddFolderDialog({ dialogOpen, setDialogOpen, parentGroup > {flatGroups.map((item, idx) => { - const effectiveGroup = item.group ?? { name: item.fullName }; - const isSelected = - selectedParent?.name === item.fullName || - (!selectedParent && item.fullName === FavoritesManager.DEFAULT_GROUP_NAME); + const isTopLevel = item.fullName === null; + const effectiveGroup = isTopLevel ? null : (item.group ?? { name: item.fullName }); + const isSelected = isTopLevel + ? selectedParent === null + : selectedParent?.name === item.fullName; const showDivider = idx > 0 && item.level === 0; const prefix = item.level >= 1 ? '↳ ' : ''; return ( - + {showDivider && } @@ -250,14 +256,15 @@ export default function AddFolderDialog({ dialogOpen, setDialogOpen, parentGroup } function buildFullPath(parentGroup, name) { - if (!parentGroup || parentGroup.name === FavoritesManager.DEFAULT_GROUP_NAME) { + if (!parentGroup) { return name; } return `${parentGroup.name}/${name}`; } function getDisplayName(group, t) { - if (!group || group.name === FavoritesManager.DEFAULT_GROUP_NAME) return t('shared_string_my_favorites'); + if (!group) return t('web:fav_top_level'); + if (group.name === FavoritesManager.DEFAULT_GROUP_NAME) return t('shared_string_my_favorites'); const parts = group.name.split('/'); return parts[parts.length - 1]; } diff --git a/map/src/infoblock/components/favorite/addFolderDialog.module.css b/map/src/infoblock/components/favorite/addFolderDialog.module.css index f6a8f2b46..624caa9f3 100644 --- a/map/src/infoblock/components/favorite/addFolderDialog.module.css +++ b/map/src/infoblock/components/favorite/addFolderDialog.module.css @@ -29,3 +29,8 @@ .dropdownItemText { font-size: 16px !important; } + +.dropdownItemTextMedium { + font-size: 16px !important; + font-weight: 500 !important; +} diff --git a/map/src/resources/translations/en/web-translation.json b/map/src/resources/translations/en/web-translation.json index 912264378..3a8e760c4 100644 --- a/map/src/resources/translations/en/web-translation.json +++ b/map/src/resources/translations/en/web-translation.json @@ -396,6 +396,7 @@ "color_palette_save_failed": "Could not save color palette to cloud", "fav_folder_location": "Location", "fav_folder_name": "Folder name", + "fav_top_level": "– Top level –", "folder_already_exists": "A folder with this name already exists.", "shared_string_advanced": "Advanced", "focus_on": "Focus: hide other items", From 627e1a3e4e4053cda18001e511bec642fc36859c Mon Sep 17 00:00:00 2001 From: Kseniia Velychko Date: Tue, 19 May 2026 10:12:03 +0300 Subject: [PATCH 15/20] Fix folder dropdown positioning --- .../components/favorite/AddFolderDialog.jsx | 192 +++++++++--------- .../favorite/addFolderDialog.module.css | 17 +- 2 files changed, 114 insertions(+), 95 deletions(-) diff --git a/map/src/infoblock/components/favorite/AddFolderDialog.jsx b/map/src/infoblock/components/favorite/AddFolderDialog.jsx index 0044b3431..446c16876 100644 --- a/map/src/infoblock/components/favorite/AddFolderDialog.jsx +++ b/map/src/infoblock/components/favorite/AddFolderDialog.jsx @@ -1,4 +1,5 @@ -import React, { useContext, useState, useMemo } from 'react'; +import React, { useContext, useState, useMemo, useRef } from 'react'; +import ReactDOM from 'react-dom'; import { Box, Button, @@ -11,7 +12,7 @@ import { List, ListItemButton, ListItemText, - Popover, + Paper, TextField, } from '@mui/material'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; @@ -36,7 +37,8 @@ export default function AddFolderDialog({ dialogOpen, setDialogOpen, parentGroup const [folderName, setFolderName] = useState(''); const [folderNameError, setFolderNameError] = useState(''); const [selectedParent, setSelectedParent] = useState(parentGroup ?? null); - const [locationAnchorEl, setLocationAnchorEl] = useState(null); + const [foldersDropdownOpen, setFoldersDropdownOpen] = useState(false); + const foldersDropdownRef = useRef(null); const [process, setProcess] = useState(false); const [advancedOpen, setAdvancedOpen] = useState(false); @@ -45,7 +47,6 @@ export default function AddFolderDialog({ dialogOpen, setDialogOpen, parentGroup return [topLevelItem, ...groupTreeToList(groupTree ?? [])]; }, [groupTree, t]); - const locationOpen = Boolean(locationAnchorEl); const parentDisplayName = getDisplayName(selectedParent, t); const canSave = folderName.trim() !== '' && !process; @@ -132,95 +133,41 @@ export default function AddFolderDialog({ dialogOpen, setDialogOpen, parentGroup error={folderNameError !== ''} helperText={folderNameError || undefined} /> - setLocationAnchorEl(locationAnchorEl ? null : e.currentTarget)} - inputProps={{ readOnly: true, style: { cursor: 'pointer' } }} - InputLabelProps={{ shrink: true }} - InputProps={{ - endAdornment: ( - - ), - }} - className={styles.locationField} - /> - setLocationAnchorEl(null)} - anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }} - transformOrigin={{ vertical: 'top', horizontal: 'left' }} - disableAutoFocus - disableEnforceFocus - disablePortal - transitionDuration={0} - slotProps={{ - paper: { - sx: { - width: locationAnchorEl?.offsetWidth, - maxHeight: 380, - mt: '10px', - }, - }, - }} - > - - {flatGroups.map((item, idx) => { - const isTopLevel = item.fullName === null; - const effectiveGroup = isTopLevel ? null : (item.group ?? { name: item.fullName }); - const isSelected = isTopLevel - ? selectedParent === null - : selectedParent?.name === item.fullName; - const showDivider = idx > 0 && item.level === 0; - const prefix = item.level >= 1 ? '↳ ' : ''; - - return ( - - {showDivider && } - { - setSelectedParent(effectiveGroup); - setLocationAnchorEl(null); - }} - > - - - { - setSelectedParent(effectiveGroup); - setLocationAnchorEl(null); - }} - onClick={(e) => e.stopPropagation()} - /> - - - - ); - })} - - + + setFoldersDropdownOpen((prev) => !prev)} + inputProps={{ readOnly: true, style: { cursor: 'pointer' } }} + InputLabelProps={{ shrink: true }} + InputProps={{ + endAdornment: ( + + ), + }} + /> + {foldersDropdownOpen && ( + { + setSelectedParent(group); + setFoldersDropdownOpen(false); + }} + onClose={() => setFoldersDropdownOpen(false)} + /> + )} +