Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 3 additions & 33 deletions lib/src/Components/Map/UtopiaMapInner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { toast } from 'react-toastify'

import { useSetAppState } from '#components/AppShell/hooks/useAppState'
import { useTheme } from '#components/AppShell/hooks/useTheme'
import { useSyncFilterTagsWithUrl } from '#components/Map/hooks/useSyncFilterTagsWithUrl'
import { containsUUID } from '#utils/ContainsUUID'
import {
removeItemFromUrl,
Expand All @@ -21,13 +22,7 @@ import {
} from '#utils/UrlHelper'

import { useClusterRef, useSetClusterRef } from './hooks/useClusterRef'
import {
useAddFilterTag,
useAddVisibleLayer,
useFilterTags,
useResetFilterTags,
useToggleVisibleLayer,
} from './hooks/useFilter'
import { useAddVisibleLayer, useToggleVisibleLayer } from './hooks/useFilter'
import { useLayers } from './hooks/useLayers'
import { useLeafletRefs } from './hooks/useLeafletRefs'
import { usePopupForm } from './hooks/usePopupForm'
Expand Down Expand Up @@ -241,33 +236,8 @@ export function UtopiaMapInner({
}
}

const addFilterTag = useAddFilterTag()
const resetFilterTags = useResetFilterTags()
const tags = useTags()
const filterTags = useFilterTags()

useEffect(() => {
const params = new URLSearchParams(location.search)
const urlTags = params.get('tags')
const decodedTags = urlTags ? decodeURIComponent(urlTags) : ''
const decodedTagsArray = decodedTags.split(';').filter(Boolean)

const urlDiffersFromState =
decodedTagsArray.some(
(ut) => !filterTags.find((ft) => ut.toLowerCase() === ft.name.toLowerCase()),
) ||
filterTags.some(
(ft) => !decodedTagsArray.find((ut) => ut.toLowerCase() === ft.name.toLowerCase()),
)

if (urlDiffersFromState) {
resetFilterTags()
decodedTagsArray.forEach((urlTag) => {
const match = tags.find((t) => t.name.toLowerCase() === urlTag.toLowerCase())
if (match) addFilterTag(match)
})
}
}, [location, tags, filterTags, addFilterTag, resetFilterTags])
useSyncFilterTagsWithUrl(tags)

const toggleVisibleLayer = useToggleVisibleLayer()
const allLayers = useLayers()
Expand Down
76 changes: 76 additions & 0 deletions lib/src/Components/Map/hooks/useSyncFilterTagsWithUrl.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { render, screen, act } from '@testing-library/react'
import { BrowserRouter } from 'react-router-dom'
import { describe, it, expect, beforeEach } from 'vitest'

import { TagsControl } from '#components/Map/Subcomponents/Controls/TagsControl'

import { FilterProvider } from './useFilter'
import { useSyncFilterTagsWithUrl } from './useSyncFilterTagsWithUrl'

import type { Tag } from '#types/Tag'

const TAGS = [
{ id: '1', name: 'garten', color: '#2E7D32' },
{ id: '2', name: 'baum', color: '#7E57C2' },
] as unknown as Tag[]

const Bed = () => {
useSyncFilterTagsWithUrl(TAGS)
return <TagsControl />
}

const renderAt = (search: string) => {
window.history.replaceState({}, '', `/${search}`)
return render(
<BrowserRouter>
<FilterProvider initialTags={[]}>
<Bed />
</FilterProvider>
</BrowserRouter>,
)
}

const chips = () => screen.queryAllByText(/^#/).map((e) => e.textContent)
const urlTags = () => new URLSearchParams(window.location.search).get('tags')

const clickRemove = (index = 0) => {
act(() => {
screen.getAllByText('✕').at(index)?.click()
})
}

describe('useSyncFilterTagsWithUrl', () => {
beforeEach(() => {
window.history.replaceState({}, '', '/')
})

it('picks up the tags from the url', () => {
renderAt('?tags=garten')
expect(chips()).toEqual(['#Garten'])
})

it('picks up several tags from the url', () => {
renderAt('?tags=garten;baum')
expect(chips()).toEqual(['#Garten', '#Baum'])
})

it('keeps a removed tag removed', () => {
renderAt('?tags=garten')
expect(chips()).toEqual(['#Garten'])

clickRemove()

expect(chips()).toEqual([])
expect(urlTags()).toBeNull()
})

it('keeps the remaining tag when one of two is removed', () => {
renderAt('?tags=garten;baum')
expect(chips()).toEqual(['#Garten', '#Baum'])

clickRemove(0)

expect(chips()).toEqual(['#Baum'])
expect(urlTags()).toBe('baum')
})
})
46 changes: 46 additions & 0 deletions lib/src/Components/Map/hooks/useSyncFilterTagsWithUrl.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { useEffect, useRef } from 'react'
import { useLocation } from 'react-router-dom'

import { useAddFilterTag, useResetFilterTags } from './useFilter'

import type { Tag } from '#types/Tag'

const readUrlTags = (search: string): string => {
const urlTags = new URLSearchParams(search).get('tags')
return urlTags ? decodeURIComponent(urlTags) : ''
}

/**
* Keeps the active filter tags in step with the `tags` query parameter.
*
* Only a change of the URL may drive the filter state. Reacting to the filter
* state as well would undo every removal: `removeFilterTag` updates the state
* and the address bar in one go, but the router location still carries the old
* `tags` value for one render. Reconciling against that stale value puts the
* tag the user just removed straight back.
*/
export const useSyncFilterTagsWithUrl = (tags: Tag[]) => {
const location = useLocation()
const addFilterTag = useAddFilterTag()
const resetFilterTags = useResetFilterTags()
const appliedUrlTags = useRef<string | null>(null)

useEffect(() => {
// Without the available tags nothing can be matched yet. Returning without
// recording keeps a deep link working once the tags have loaded.
if (tags.length === 0) return

const urlTags = readUrlTags(location.search)
if (appliedUrlTags.current === urlTags) return
appliedUrlTags.current = urlTags

resetFilterTags()
urlTags
.split(';')
.filter(Boolean)
.forEach((urlTag) => {
const match = tags.find((t) => t.name.toLowerCase() === urlTag.toLowerCase())
if (match) addFilterTag(match)
})
}, [location.search, tags, addFilterTag, resetFilterTags])
}
Loading