From d5080e2bc9870463c21ce5d1a3af181e94b231d8 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 18 Nov 2025 18:59:55 +0000 Subject: [PATCH] fix(lib): remove UUID from URL when popup closes (#435) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: antontranelis <31516529+antontranelis@users.noreply.github.com> Co-authored-by: Anton Tranelis Co-authored-by: mahula --- .../Map/Subcomponents/ItemViewPopup.tsx | 5 +- lib/src/Components/Map/UtopiaMapInner.tsx | 79 +++++++++++-------- .../Components/Profile/Templates/TabsView.tsx | 7 +- lib/src/Components/Profile/itemFunctions.ts | 4 +- lib/src/Utils/UrlHelper.ts | 74 +++++++++++++++++ 5 files changed, 127 insertions(+), 42 deletions(-) create mode 100644 lib/src/Utils/UrlHelper.ts diff --git a/lib/src/Components/Map/Subcomponents/ItemViewPopup.tsx b/lib/src/Components/Map/Subcomponents/ItemViewPopup.tsx index fe7e4107..4f623c0c 100644 --- a/lib/src/Components/Map/Subcomponents/ItemViewPopup.tsx +++ b/lib/src/Components/Map/Subcomponents/ItemViewPopup.tsx @@ -5,7 +5,6 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ /* eslint-disable @typescript-eslint/no-non-null-asserted-optional-chain */ /* eslint-disable @typescript-eslint/prefer-optional-chain */ -/* eslint-disable @typescript-eslint/restrict-template-expressions */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ import { LatLng } from 'leaflet' import { forwardRef, useState } from 'react' @@ -17,6 +16,7 @@ import { useRemoveItem, useUpdateItem } from '#components/Map/hooks/useItems' import { usePopupForm } from '#components/Map/hooks/usePopupForm' import { useSetSelectPosition } from '#components/Map/hooks/useSelectPosition' import { timeAgo } from '#utils/TimeAgo' +import { removeItemFromUrl } from '#utils/UrlHelper' import { HeaderView } from './ItemPopupComponents/HeaderView' import { TextView } from './ItemPopupComponents/TextView' @@ -80,8 +80,7 @@ export const ItemViewPopup = forwardRef((props: ItemViewPopupProps, ref: any) => } setLoading(false) map.closePopup() - const params = new URLSearchParams(window.location.search) - window.history.pushState({}, '', '/' + `${params ? `?${params}` : ''}`) + removeItemFromUrl() navigate('/') } diff --git a/lib/src/Components/Map/UtopiaMapInner.tsx b/lib/src/Components/Map/UtopiaMapInner.tsx index aa6786cb..edde0979 100644 --- a/lib/src/Components/Map/UtopiaMapInner.tsx +++ b/lib/src/Components/Map/UtopiaMapInner.tsx @@ -1,6 +1,5 @@ /* eslint-disable @typescript-eslint/no-empty-function */ /* eslint-disable @typescript-eslint/restrict-plus-operands */ -/* eslint-disable @typescript-eslint/restrict-template-expressions */ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ @@ -15,6 +14,12 @@ import { toast } from 'react-toastify' import { useSetAppState } from '#components/AppShell/hooks/useAppState' import { useTheme } from '#components/AppShell/hooks/useTheme' import { containsUUID } from '#utils/ContainsUUID' +import { + removeItemFromUrl, + resetMetaTags as resetMetaTagsUtil, + setItemInUrl, + updateMetaTags, +} from '#utils/UrlHelper' import { useClusterRef, useSetClusterRef } from './hooks/useClusterRef' import { @@ -152,21 +157,45 @@ export function UtopiaMapInner({ return null } + // Track if we're currently switching popups to prevent URL cleanup + const popupCloseTimeoutRef = useRef(null) + useMapEvents({ popupopen: (e) => { const item = Object.entries(leafletRefs).find((r) => r[1].popup === e.popup)?.[1].item - if (window.location.pathname.split('/')[1] !== item?.id) { - const params = new URLSearchParams(window.location.search) - if (!location.pathname.includes('/item/')) { - window.history.pushState( - {}, - '', - `/${item?.id}` + `${params.toString() !== '' ? `?${params}` : ''}`, - ) + + // Cancel any pending popup close URL cleanup - we're opening a new popup + if (popupCloseTimeoutRef.current) { + clearTimeout(popupCloseTimeoutRef.current) + popupCloseTimeoutRef.current = null + } + + // Only update URL if no profile is open + if (!location.pathname.includes('/item/')) { + if (window.location.pathname.split('/')[1] !== item?.id && item?.id) { + setItemInUrl(item.id) } - let title = '' - if (item?.name) title = item.name - document.title = `${document.title.split('-')[0]} - ${title}` + if (item?.name) { + updateMetaTags(item.name, item.text) + } + } + // If profile is open, don't change URL but still update meta tags + else if (item?.name) { + updateMetaTags(item.name, item.text) + } + }, + popupclose: () => { + // Only remove UUID from URL if no profile is open + if (!location.pathname.includes('/item/')) { + // Wait briefly to see if another popup is being opened + // If so, the popupopen handler will cancel this timeout + popupCloseTimeoutRef.current = setTimeout(() => { + if (containsUUID(window.location.pathname)) { + removeItemFromUrl() + resetMetaTagsUtil() + } + popupCloseTimeoutRef.current = null + }, 50) } }, }) @@ -185,15 +214,9 @@ export function UtopiaMapInner({ clusterRef?.zoomToShowLayer(ref.marker, () => { ref.marker.openPopup() }) - let title = '' - if (ref.item.name) title = ref.item.name - document.title = `${document.title.split('-')[0]} - ${title}` - document - .querySelector('meta[property="og:title"]') - ?.setAttribute('content', ref.item.name ?? '') - document - .querySelector('meta[property="og:description"]') - ?.setAttribute('content', ref.item.text ?? '') + if (ref.item.name) { + updateMetaTags(ref.item.name, ref.item.text) + } } } } @@ -205,18 +228,10 @@ export function UtopiaMapInner({ }, [leafletRefs, location]) const resetMetaTags = () => { - const params = new URLSearchParams(window.location.search) - if (!containsUUID(window.location.pathname)) { - window.history.pushState({}, '', '/' + `${params.toString() !== '' ? `?${params}` : ''}`) + if (containsUUID(window.location.pathname)) { + removeItemFromUrl() } - document.title = document.title.split('-')[0] - document.querySelector('meta[property="og:title"]')?.setAttribute('content', document.title) - document - .querySelector('meta[property="og:description"]') - ?.setAttribute( - 'content', - `${document.querySelector('meta[name="description"]')?.getAttribute('content')}`, - ) + resetMetaTagsUtil() } const onEachFeature = (feature: Feature, layer: L.Layer) => { diff --git a/lib/src/Components/Profile/Templates/TabsView.tsx b/lib/src/Components/Profile/Templates/TabsView.tsx index 0844f8fa..94806d9d 100644 --- a/lib/src/Components/Profile/Templates/TabsView.tsx +++ b/lib/src/Components/Profile/Templates/TabsView.tsx @@ -17,6 +17,7 @@ import { ActionButton } from '#components/Profile/Subcomponents/ActionsButton' import { LinkedItemsHeaderView } from '#components/Profile/Subcomponents/LinkedItemsHeaderView' import { TagView } from '#components/Templates/TagView' import { timeAgo } from '#utils/TimeAgo' +import { setUrlParam } from '#utils/UrlHelper' import type { Item } from '#types/Item' import type { Tag } from '#types/Tag' @@ -67,11 +68,7 @@ export const TabsView = ({ const updateActiveTab = useCallback( (id: number) => { setActiveTab(id) - - const params = new URLSearchParams(window.location.search) - params.set('tab', `${id}`) - const newUrl = location.pathname + '?' + params.toString() - window.history.pushState({}, '', newUrl) + setUrlParam('tab', `${id}`) }, // eslint-disable-next-line react-hooks/exhaustive-deps [location.pathname], diff --git a/lib/src/Components/Profile/itemFunctions.ts b/lib/src/Components/Profile/itemFunctions.ts index 4c229c8b..b26c7142 100644 --- a/lib/src/Components/Profile/itemFunctions.ts +++ b/lib/src/Components/Profile/itemFunctions.ts @@ -14,6 +14,7 @@ import { toast } from 'react-toastify' import { encodeTag } from '#utils/FormatTags' import { hashTagRegex } from '#utils/HashTagRegex' import { randomColor } from '#utils/RandomColor' +import { removeItemFromUrl } from '#utils/UrlHelper' import type { FormState } from '#types/FormState' import type { Item } from '#types/Item' @@ -185,8 +186,7 @@ export const handleDelete = async ( toast.success('Item deleted') map.closePopup() - const params = new URLSearchParams(window.location.search) - window.history.pushState({}, '', '/' + `${params ? `?${params}` : ''}`) + removeItemFromUrl() navigate('/') } catch (error) { toast.error(error instanceof Error ? error.message : String(error)) diff --git a/lib/src/Utils/UrlHelper.ts b/lib/src/Utils/UrlHelper.ts new file mode 100644 index 00000000..9af81e1a --- /dev/null +++ b/lib/src/Utils/UrlHelper.ts @@ -0,0 +1,74 @@ +/** + * Utility functions for managing browser URL and history + */ + +/** + * Updates the browser URL with an item ID while preserving query parameters + * @param itemId - The item UUID to add to the URL + */ +export function setItemInUrl(itemId: string): void { + const params = new URLSearchParams(window.location.search) + const paramsString = params.toString() + const newUrl = `/${itemId}${paramsString !== '' ? `?${paramsString}` : ''}` + window.history.pushState({}, '', newUrl) +} + +/** + * Removes the item ID from the browser URL while preserving query parameters + */ +export function removeItemFromUrl(): void { + const params = new URLSearchParams(window.location.search) + const paramsString = params.toString() + const newUrl = `/${paramsString !== '' ? `?${paramsString}` : ''}` + window.history.pushState({}, '', newUrl) +} + +/** + * Updates a specific query parameter in the URL while preserving the path + * @param key - The parameter key + * @param value - The parameter value + */ +export function setUrlParam(key: string, value: string): void { + const params = new URLSearchParams(window.location.search) + params.set(key, value) + const newUrl = window.location.pathname + '?' + params.toString() + window.history.pushState({}, '', newUrl) +} + +/** + * Removes a specific query parameter from the URL + * @param key - The parameter key to remove + */ +export function removeUrlParam(key: string): void { + const params = new URLSearchParams(window.location.search) + params.delete(key) + const paramsString = params.toString() + const newUrl = window.location.pathname + (paramsString !== '' ? `?${paramsString}` : '') + window.history.pushState({}, '', newUrl) +} + +/** + * Resets page title and OpenGraph meta tags to default values + */ +export function resetMetaTags(): void { + document.title = document.title.split('-')[0].trim() + document.querySelector('meta[property="og:title"]')?.setAttribute('content', document.title) + const description = document.querySelector('meta[name="description"]')?.getAttribute('content') + if (description) { + document.querySelector('meta[property="og:description"]')?.setAttribute('content', description) + } +} + +/** + * Updates page title and OpenGraph meta tags with item information + * @param itemName - The name of the item + * @param itemText - The text/description of the item + */ +export function updateMetaTags(itemName: string, itemText?: string): void { + const baseTitle = document.title.split('-')[0].trim() + document.title = `${baseTitle} - ${itemName}` + document.querySelector('meta[property="og:title"]')?.setAttribute('content', itemName) + if (itemText) { + document.querySelector('meta[property="og:description"]')?.setAttribute('content', itemText) + } +}