Compare commits

...

9 Commits

Author SHA1 Message Date
dependabot[bot]
1fae45b46a
build(deps): bump the tiptap group with 10 updates
Bumps the tiptap group with 10 updates:

| Package | From | To |
| --- | --- | --- |
| [@tiptap/core](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/core) | `3.13.0` | `3.19.0` |
| [@tiptap/extension-bubble-menu](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-bubble-menu) | `3.13.0` | `3.19.0` |
| [@tiptap/extension-color](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-color) | `3.13.0` | `3.19.0` |
| [@tiptap/extension-image](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-image) | `3.13.0` | `3.19.0` |
| [@tiptap/extension-link](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-link) | `3.13.0` | `3.19.0` |
| [@tiptap/extension-placeholder](https://github.com/ueberdosis/tiptap/tree/HEAD/packages-deprecated/extension-placeholder) | `3.13.0` | `3.19.0` |
| [@tiptap/extension-youtube](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-youtube) | `3.13.0` | `3.19.0` |
| [@tiptap/pm](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/pm) | `3.13.0` | `3.19.0` |
| [@tiptap/react](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/react) | `3.13.0` | `3.19.0` |
| [@tiptap/starter-kit](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/starter-kit) | `3.13.0` | `3.19.0` |


Updates `@tiptap/core` from 3.13.0 to 3.19.0
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/v3.19.0/packages/core/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.19.0/packages/core)

Updates `@tiptap/extension-bubble-menu` from 3.13.0 to 3.19.0
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/v3.19.0/packages/extension-bubble-menu/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.19.0/packages/extension-bubble-menu)

Updates `@tiptap/extension-color` from 3.13.0 to 3.19.0
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/v3.19.0/packages/extension-color/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.19.0/packages/extension-color)

Updates `@tiptap/extension-image` from 3.13.0 to 3.19.0
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/v3.19.0/packages/extension-image/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.19.0/packages/extension-image)

Updates `@tiptap/extension-link` from 3.13.0 to 3.19.0
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/v3.19.0/packages/extension-link/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.19.0/packages/extension-link)

Updates `@tiptap/extension-placeholder` from 3.13.0 to 3.19.0
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/v3.19.0/packages-deprecated/extension-placeholder/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.19.0/packages-deprecated/extension-placeholder)

Updates `@tiptap/extension-youtube` from 3.13.0 to 3.19.0
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/v3.19.0/packages/extension-youtube/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.19.0/packages/extension-youtube)

Updates `@tiptap/pm` from 3.13.0 to 3.19.0
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/v3.19.0/packages/pm/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.19.0/packages/pm)

Updates `@tiptap/react` from 3.13.0 to 3.19.0
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/v3.19.0/packages/react/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.19.0/packages/react)

Updates `@tiptap/starter-kit` from 3.13.0 to 3.19.0
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/v3.19.0/packages/starter-kit/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.19.0/packages/starter-kit)

---
updated-dependencies:
- dependency-name: "@tiptap/core"
  dependency-version: 3.19.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: tiptap
- dependency-name: "@tiptap/extension-bubble-menu"
  dependency-version: 3.19.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: tiptap
- dependency-name: "@tiptap/extension-color"
  dependency-version: 3.19.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: tiptap
- dependency-name: "@tiptap/extension-image"
  dependency-version: 3.19.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: tiptap
- dependency-name: "@tiptap/extension-link"
  dependency-version: 3.19.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: tiptap
- dependency-name: "@tiptap/extension-placeholder"
  dependency-version: 3.19.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: tiptap
- dependency-name: "@tiptap/extension-youtube"
  dependency-version: 3.19.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: tiptap
- dependency-name: "@tiptap/pm"
  dependency-version: 3.19.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: tiptap
- dependency-name: "@tiptap/react"
  dependency-version: 3.19.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: tiptap
- dependency-name: "@tiptap/starter-kit"
  dependency-version: 3.19.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: tiptap
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-11 02:04:38 +00:00
Anton Tranelis
298560c02e
fix(lib): infinite scroll in items index page (#438)
Co-authored-by: mahula <lenzmath@posteo.de>
Co-authored-by: Ulf Gebhardt <ulf.gebhardt@webcraft-media.de>
2026-02-06 20:37:28 +01:00
ab515c20ad
feat(backend): database scripts for tasks required regularly (#694) 2026-02-06 16:51:53 +01:00
dependabot[bot]
529ee8cc81
build(deps-dev): bump eslint-plugin-react-refresh from 0.4.26 to 0.5.0 (#702)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-04 09:03:59 +00:00
dependabot[bot]
ab2380ec55
build(deps-dev): bump cypress from 15.9.0 to 15.10.0 (#698)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-04 09:53:46 +01:00
dependabot[bot]
afb280da14
build(deps-dev): bump happy-dom from 20.4.0 to 20.5.0 (#701)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-04 08:36:37 +00:00
dependabot[bot]
0f93c393a3
build(deps-dev): bump daisyui from 5.5.14 to 5.5.17 (#696)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-04 08:28:51 +00:00
dependabot[bot]
088516e3b7
build(deps): bump react-dropzone from 14.3.8 to 14.4.0 (#700)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-04 08:16:21 +00:00
dependabot[bot]
f3d282b29a
build(deps-dev): bump rollup from 4.57.0 to 4.57.1 (#697)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-04 09:10:07 +01:00
10 changed files with 762 additions and 400 deletions

View File

@ -32,7 +32,7 @@
"@types/react": "^18.2.79",
"@types/react-dom": "^18.2.25",
"@vitejs/plugin-react": "^4.0.0",
"daisyui": "^5.5.14",
"daisyui": "^5.5.17",
"eslint": "^9.39.2",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-import-x": "^4.16.1",
@ -42,7 +42,7 @@
"eslint-plugin-promise": "^7.2.1",
"eslint-plugin-react": "^7.31.8",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.26",
"eslint-plugin-react-refresh": "^0.5.0",
"eslint-plugin-security": "^3.0.1",
"globals": "^17.3.0",
"postcss": "^8.4.30",

View File

@ -54,48 +54,9 @@ docker exec -t utopia-map-database-1 pg_dumpall -c -U directus > dump.sql
Assuming you run docker-compose with the default postgress credentials and have the dump in cwd as ./dump.sql, execute:
Find current schema name:
Drop database:
```
echo "SELECT CURRENT_SCHEMA, CURRENT_SCHEMA();" | docker exec -i utopia-map-database-1 /bin/bash -c "PGPASSWORD=directus psql --username directus"
```
> current_schema | current_schema
> ----------------+----------------
> public | public
> (1 row)
Drop schemata (loses all data):
```
echo "DROP SCHEMA public CASCADE;" | docker exec -i utopia-map-database-1 /bin/bash -c "PGPASSWORD=directus psql --username directus"
echo "DROP SCHEMA tiger CASCADE;" | docker exec -i utopia-map-database-1 /bin/bash -c "PGPASSWORD=directus psql --username directus"
echo "DROP SCHEMA tiger_data CASCADE;" | docker exec -i utopia-map-database-1 /bin/bash -c "PGPASSWORD=directus psql --username directus"
echo "DROP SCHEMA topology CASCADE;" | docker exec -i utopia-map-database-1 /bin/bash -c "PGPASSWORD=directus psql --username directus"
```
> drop cascades to table ...
> ...
> DROP SCHEMA
Create the public schema again:
```
echo "CREATE SCHEMA public;" | docker exec -i utopia-map-database-1 /bin/bash -c "PGPASSWORD=directus psql --username directus"
```
Verify schemata:
```
echo "select schema_name from information_schema.schemata;" | docker exec -i utopia-map-database-1 /bin/bash -c "PGPASSWORD=directus psql --username directus"
```
Verify database is empty:
```
echo "\dt" | docker exec -i utopia-map-database-1 /bin/bash -c "PGPASSWORD=directus psql --username directus directus"
```
> Did not find any relations.
Create admin role & grant it:
```
echo "CREATE ROLE admin;" | docker exec -i utopia-map-database-1 /bin/bash -c "PGPASSWORD=directus psql --username directus directus"
docker exec -i utopia-map-database-1 /bin/bash -c "PGPASSWORD=directus psql -v ON_ERROR_STOP=1 --username directus directus" < ./backend/scripts/drop-database.sql
```
Apply dump:

View File

@ -0,0 +1,8 @@
-- CAUTION: THIS SCRIPT DROPS ALL DATA IN YOUR DATABASE!
DROP SCHEMA public CASCADE;
DROP SCHEMA tiger CASCADE;
DROP SCHEMA tiger_data CASCADE;
DROP SCHEMA topology CASCADE;
CREATE SCHEMA public;
CREATE ROLE admin;

View File

@ -0,0 +1,3 @@
-- Selects passwords and emails and creates a script to update user passwords in a database.
-- This is used to port users between instances as directus cannot import user passwords
SELECT CONCAT('UPDATE public.directus_users SET password=''', password, ''' WHERE email=''', email, ''';') FROM public.directus_users;

View File

@ -59,8 +59,8 @@
"@types/react-dom": "^18.0.5",
"@vitejs/plugin-react": "^4.3.4",
"@vitest/coverage-v8": "^4.0.18",
"cypress": "^15.9.0",
"daisyui": "^5.5.14",
"cypress": "^15.10.0",
"daisyui": "^5.5.17",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-import-resolver-typescript": "^4.4.4",
@ -71,15 +71,15 @@
"eslint-plugin-promise": "^7.2.1",
"eslint-plugin-react": "^7.31.8",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.26",
"eslint-plugin-react-refresh": "^0.5.0",
"eslint-plugin-security": "^3.0.1",
"globals": "^17.3.0",
"happy-dom": "^20.4.0",
"happy-dom": "^20.5.0",
"postcss": "^8.4.21",
"prettier": "^3.8.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"rollup": "^4.57.0",
"rollup": "^4.57.1",
"rollup-plugin-dts": "^6.3.0",
"rollup-plugin-postcss": "^4.0.2",
"rollup-plugin-svg": "^2.0.0",
@ -101,16 +101,16 @@
"dependencies": {
"@heroicons/react": "^2.0.17",
"@maplibre/maplibre-gl-leaflet": "^0.1.3",
"@tiptap/core": "^3.13.0",
"@tiptap/extension-bubble-menu": "^3.13.0",
"@tiptap/extension-color": "^3.13.0",
"@tiptap/extension-image": "^3.13.0",
"@tiptap/extension-link": "^3.13.0",
"@tiptap/extension-placeholder": "^3.13.0",
"@tiptap/extension-youtube": "^3.13.0",
"@tiptap/core": "^3.19.0",
"@tiptap/extension-bubble-menu": "^3.19.0",
"@tiptap/extension-color": "^3.19.0",
"@tiptap/extension-image": "^3.19.0",
"@tiptap/extension-link": "^3.19.0",
"@tiptap/extension-placeholder": "^3.19.0",
"@tiptap/extension-youtube": "^3.19.0",
"@tiptap/pm": "^3.6.5",
"@tiptap/react": "^3.13.0",
"@tiptap/starter-kit": "^3.13.0",
"@tiptap/react": "^3.19.0",
"@tiptap/starter-kit": "^3.19.0",
"axios": "^1.13.4",
"browser-image-compression": "^2.0.2",
"classnames": "^2.5.1",
@ -119,7 +119,7 @@
"maplibre-gl": "^5.17.0",
"radash": "^12.1.0",
"react-colorful": "^5.6.1",
"react-dropzone": "^14.3.8",
"react-dropzone": "^14.4.0",
"react-icons": "^5.5.0",
"react-image-crop": "^11.0.10",
"react-inlinesvg": "^4.2.0",

View File

@ -0,0 +1,171 @@
/* eslint-disable camelcase */
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unsafe-argument */
import { render, screen, act, waitFor } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { OverlayItemsIndexPage } from './OverlayItemsIndexPage'
import type { Item } from '#types/Item'
vi.mock('./ItemCard', () => ({
ItemCard: ({ i }: { i: Item }) => <div data-testid={`item-${i.id}`}>{i.name}</div>,
}))
vi.mock('./MapOverlayPage', () => ({
MapOverlayPage: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
vi.mock('#components/Map/Subcomponents/Controls/Control', () => ({
Control: () => null,
}))
vi.mock('#components/Map/Subcomponents/Controls/SearchControl', () => ({
SearchControl: () => null,
}))
vi.mock('#components/Map/Subcomponents/Controls/TagsControl', () => ({
TagsControl: () => null,
}))
vi.mock('#components/Profile/Subcomponents/PlusButton', () => ({
PlusButton: () => null,
}))
vi.mock('#components/Map/Subcomponents/ItemPopupComponents', () => ({
PopupStartEndInput: () => null,
}))
vi.mock('#components/Input', () => ({
TextInput: () => null,
}))
vi.mock('react-toastify', () => ({
toast: { success: vi.fn(), error: vi.fn() },
}))
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual('react-router-dom')
return { ...actual, useNavigate: () => vi.fn() }
})
vi.mock('#components/Auth/useAuth')
vi.mock('#components/Map/hooks/useFilter')
vi.mock('#components/Map/hooks/useItems')
vi.mock('#components/Map/hooks/useLayers')
vi.mock('#components/Map/hooks/useTags')
let observerCallback: IntersectionObserverCallback
const mockObserve = vi.fn()
const mockDisconnect = vi.fn()
class MockIntersectionObserver {
// eslint-disable-next-line promise/prefer-await-to-callbacks
constructor(cb: IntersectionObserverCallback) {
observerCallback = cb
}
observe = mockObserve
disconnect = mockDisconnect
unobserve = vi.fn()
}
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver)
function makeItems(count: number): Item[] {
return Array.from({ length: count }, (_, i) => ({
id: `item-${String(i)}`,
name: `Item ${String(i)}`,
date_created: new Date(2025, 0, count - i).toISOString(),
layer: { name: 'places' } as Item['layer'],
})) as Item[]
}
const mockLayer = {
name: 'places',
menuText: 'Places',
itemType: { show_start_end_input: false },
userProfileLayer: false,
api: { createItem: vi.fn(), deleteItem: vi.fn() },
}
function triggerIntersection() {
observerCallback(
[{ isIntersecting: true } as IntersectionObserverEntry],
{} as IntersectionObserver,
)
}
describe('OverlayItemsIndexPage infinite scroll', () => {
beforeEach(async () => {
vi.clearAllMocks()
const { useAuth } = await import('#components/Auth/useAuth')
const { useFilterTags } = await import('#components/Map/hooks/useFilter')
const { useItems, useAddItem, useRemoveItem } = await import('#components/Map/hooks/useItems')
const { useLayers } = await import('#components/Map/hooks/useLayers')
const { useTags, useAddTag, useGetItemTags } = await import('#components/Map/hooks/useTags')
vi.mocked(useAuth).mockReturnValue({ user: null } as any)
vi.mocked(useFilterTags).mockReturnValue([])
vi.mocked(useItems).mockReturnValue(makeItems(50))
vi.mocked(useAddItem).mockReturnValue(vi.fn())
vi.mocked(useRemoveItem).mockReturnValue(vi.fn())
vi.mocked(useLayers).mockReturnValue([mockLayer] as any)
vi.mocked(useTags).mockReturnValue([])
vi.mocked(useAddTag).mockReturnValue(vi.fn())
vi.mocked(useGetItemTags).mockReturnValue((() => []) as any)
})
function renderPage() {
return render(
<MemoryRouter>
<OverlayItemsIndexPage url='/places' layerName='places' />
</MemoryRouter>,
)
}
it('renders the first batch of 30 items with a sentinel', () => {
renderPage()
expect(screen.getAllByTestId(/^item-/)).toHaveLength(30)
expect(screen.getByTestId('scroll-sentinel')).toBeInTheDocument()
})
it('loads more items when the sentinel becomes visible', async () => {
renderPage()
expect(mockObserve).toHaveBeenCalled()
expect(screen.getAllByTestId(/^item-/)).toHaveLength(30)
act(() => {
triggerIntersection()
})
await waitFor(() => {
expect(screen.getAllByTestId(/^item-/)).toHaveLength(50)
})
})
it('removes the sentinel after all items are loaded', async () => {
renderPage()
expect(mockObserve).toHaveBeenCalled()
act(() => {
triggerIntersection()
})
await waitFor(() => {
expect(screen.queryByTestId('scroll-sentinel')).not.toBeInTheDocument()
})
})
it('only loads one batch when triggered twice before re-render', async () => {
const { useItems } = await import('#components/Map/hooks/useItems')
vi.mocked(useItems).mockReturnValue(makeItems(80))
renderPage()
expect(mockObserve).toHaveBeenCalled()
expect(screen.getAllByTestId(/^item-/)).toHaveLength(30)
act(() => {
triggerIntersection()
triggerIntersection()
})
await waitFor(() => {
expect(screen.getAllByTestId(/^item-/)).toHaveLength(54)
})
})
})

View File

@ -22,6 +22,7 @@ import { PlusButton } from '#components/Profile/Subcomponents/PlusButton'
import { hashTagRegex } from '#utils/HashTagRegex'
import { randomColor } from '#utils/RandomColor'
import { filterSortAndPaginate } from './filterSortAndPaginate'
import { ItemCard } from './ItemCard'
import { MapOverlayPage } from './MapOverlayPage'
@ -40,8 +41,11 @@ export const OverlayItemsIndexPage = ({
}) => {
const [loading, setLoading] = useState<boolean>(false)
const [addItemPopupOpen, setAddItemPopupOpen] = useState<boolean>(false)
const [itemsToShow, setItemsToShow] = useState<number>(30)
const tabRef = useRef<HTMLFormElement>(null)
const sentinelRef = useRef<HTMLDivElement>(null)
const scrollContainerRef = useRef<HTMLDivElement>(null)
const isLoadingMoreRef = useRef(false)
function scroll() {
tabRef.current?.scrollIntoView()
@ -66,6 +70,45 @@ export const OverlayItemsIndexPage = ({
const layer = layers.find((l) => l.name === layerName)
const { visibleItems, hasMore } = filterSortAndPaginate(
items,
layerName,
filterTags,
getItemTags,
itemsToShow,
)
useEffect(() => {
const sentinel = sentinelRef.current
const scrollContainer = scrollContainerRef.current
if (!sentinel || !scrollContainer) return
const observer = new IntersectionObserver(
(entries) => {
const entry = entries[0]
if (entry.isIntersecting && !isLoadingMoreRef.current && hasMore) {
isLoadingMoreRef.current = true
setItemsToShow((prev) => prev + 24)
}
},
{
root: scrollContainer,
rootMargin: '400px',
threshold: 0.1,
},
)
observer.observe(sentinel)
return () => {
observer.disconnect()
}
}, [hasMore, visibleItems.length])
useEffect(() => {
isLoadingMoreRef.current = false
}, [visibleItems.length])
const submitNewItem = async (evt: React.FormEvent<HTMLFormElement>) => {
evt.preventDefault()
const formItem: Item = {} as Item
@ -130,44 +173,18 @@ export const OverlayItemsIndexPage = ({
<TagsControl />
</Control>
</div>
<div className='tw:overflow-scroll fade tw:flex-1'>
<div ref={scrollContainerRef} className='tw:overflow-scroll fade tw:flex-1'>
<div className='tw:columns-1 tw:md:columns-2 tw:lg:columns-3 tw:2xl:columns-4 tw:gap-6 tw:pt-4'>
{items
.filter((i) => i.layer?.name === layerName)
.filter((item) =>
filterTags.length === 0
? item
: filterTags.some((tag) =>
getItemTags(item).some(
(filterTag) =>
filterTag.name.toLocaleLowerCase() === tag.name.toLocaleLowerCase(),
),
),
)
.sort((a, b) => {
// Convert date_created to milliseconds, handle undefined by converting to lowest possible date (0 milliseconds)
const dateA = a.date_updated
? new Date(a.date_updated).getTime()
: a.date_created
? new Date(a.date_created).getTime()
: 0
const dateB = b.date_updated
? new Date(b.date_updated).getTime()
: b.date_created
? new Date(b.date_created).getTime()
: 0
return dateB - dateA // Subtracts milliseconds which are numbers
})
.map((i, k) => (
<div key={k} className='tw:break-inside-avoid tw:mb-6'>
<ItemCard
i={i}
loading={loading}
url={url}
deleteCallback={() => deleteItem(i)}
/>
</div>
))}
{visibleItems.map((i) => (
<div key={i.id} className='tw:break-inside-avoid tw:mb-6'>
<ItemCard
i={i}
loading={loading}
url={url}
deleteCallback={() => deleteItem(i)}
/>
</div>
))}
{addItemPopupOpen && (
<form ref={tabRef} autoComplete='off' onSubmit={(e) => submitNewItem(e)}>
<div className='tw:cursor-pointer tw:break-inside-avoid card tw:border-[1px] tw:border-base-300 card-body tw:shadow-xl tw:bg-base-100 tw:text-base-content tw:p-6 tw:mb-10'>
@ -208,6 +225,15 @@ export const OverlayItemsIndexPage = ({
</form>
)}
</div>
{hasMore && (
<div
ref={sentinelRef}
data-testid='scroll-sentinel'
className='tw:w-full tw:py-8 tw:flex tw:justify-center'
>
<span className='loading loading-spinner loading-lg'></span>
</div>
)}
</div>
</div>
</MapOverlayPage>

View File

@ -0,0 +1,145 @@
/* eslint-disable camelcase */ // Directus database fields use snake_case
import { describe, it, expect } from 'vitest'
import { filterSortAndPaginate } from './filterSortAndPaginate'
import type { Item } from '#types/Item'
import type { Tag } from '#types/Tag'
function makeItem(overrides: Partial<Item> & { id: string; layerName?: string }): Item {
const { layerName, ...rest } = overrides
return {
...rest,
layer: layerName ? ({ name: layerName } as Item['layer']) : undefined,
} as Item
}
function getItemTags(item: Item): Tag[] {
if (!item.text) return []
const matches = item.text.match(/#([a-zA-ZÀ-ÖØ-öø-ʸ0-9_-]+)/g)
if (!matches) return []
return matches.map((m) => ({
id: m,
name: m.slice(1),
color: '#000',
}))
}
const PLACES = 'places'
const EVENTS = 'events'
const items: Item[] = [
makeItem({ id: '1', layerName: PLACES, text: '#nature', date_updated: '2025-03-01T00:00:00Z' }),
makeItem({ id: '2', layerName: PLACES, text: '#food', date_created: '2025-02-01T00:00:00Z' }),
makeItem({
id: '3',
layerName: PLACES,
text: '#nature #food',
date_updated: '2025-01-01T00:00:00Z',
}),
makeItem({ id: '4', layerName: EVENTS, text: '#nature', date_updated: '2025-04-01T00:00:00Z' }),
makeItem({ id: '5', layerName: PLACES, text: '', date_created: '2025-05-01T00:00:00Z' }),
makeItem({ id: '6', text: '#nature' }), // no layer
]
describe('filterSortAndPaginate', () => {
describe('layer filtering', () => {
it('returns only items matching the given layer name', () => {
const { visibleItems } = filterSortAndPaginate(items, PLACES, [], getItemTags, 100)
expect(visibleItems.every((i) => i.layer?.name === PLACES)).toBe(true)
expect(visibleItems).toHaveLength(4) // ids 1,2,3,5
})
it('excludes items with no layer', () => {
const { visibleItems } = filterSortAndPaginate(items, PLACES, [], getItemTags, 100)
expect(visibleItems.find((i) => i.id === '6')).toBeUndefined()
})
})
describe('tag filtering', () => {
it('returns all layer items when no filter tags are active', () => {
const { visibleItems } = filterSortAndPaginate(items, PLACES, [], getItemTags, 100)
expect(visibleItems).toHaveLength(4)
})
it('keeps only items that have at least one matching tag', () => {
const filterTags: Tag[] = [{ id: 't1', name: 'food', color: '#000' }]
const { visibleItems } = filterSortAndPaginate(items, PLACES, filterTags, getItemTags, 100)
expect(visibleItems.map((i) => i.id).sort()).toEqual(['2', '3'])
})
it('excludes items with no matching tags', () => {
const filterTags: Tag[] = [{ id: 't1', name: 'food', color: '#000' }]
const { visibleItems } = filterSortAndPaginate(items, PLACES, filterTags, getItemTags, 100)
expect(visibleItems.find((i) => i.id === '1')).toBeUndefined()
})
it('matches tags case-insensitively', () => {
const filterTags: Tag[] = [{ id: 't1', name: 'Nature', color: '#000' }]
const { visibleItems } = filterSortAndPaginate(items, PLACES, filterTags, getItemTags, 100)
// ids 1 (#nature) and 3 (#nature #food)
expect(visibleItems.map((i) => i.id)).toContain('1')
expect(visibleItems.map((i) => i.id)).toContain('3')
})
it('items with no text (no tags) are excluded when filter is active', () => {
const filterTags: Tag[] = [{ id: 't1', name: 'nature', color: '#000' }]
const { visibleItems } = filterSortAndPaginate(items, PLACES, filterTags, getItemTags, 100)
expect(visibleItems.find((i) => i.id === '5')).toBeUndefined()
})
})
describe('sorting (newest first)', () => {
it('sorts by date_updated descending', () => {
const { visibleItems } = filterSortAndPaginate(items, PLACES, [], getItemTags, 100)
const ids = visibleItems.map((i) => i.id)
expect(ids).toEqual(['5', '1', '2', '3'])
})
it('falls back to date_created when date_updated is absent', () => {
const a = makeItem({ id: 'a', layerName: PLACES, date_created: '2025-06-01T00:00:00Z' })
const b = makeItem({ id: 'b', layerName: PLACES, date_updated: '2025-05-01T00:00:00Z' })
const { visibleItems } = filterSortAndPaginate([a, b], PLACES, [], getItemTags, 100)
expect(visibleItems[0].id).toBe('a')
})
it('items with no dates sort to the end', () => {
const dated = makeItem({ id: 'd', layerName: PLACES, date_created: '2025-01-01T00:00:00Z' })
const undated = makeItem({ id: 'u', layerName: PLACES })
const { visibleItems } = filterSortAndPaginate([undated, dated], PLACES, [], getItemTags, 100)
expect(visibleItems[0].id).toBe('d')
expect(visibleItems[1].id).toBe('u')
})
})
describe('pagination', () => {
it('limits visible items to itemsToShow', () => {
const { visibleItems, hasMore } = filterSortAndPaginate(items, PLACES, [], getItemTags, 2)
expect(visibleItems).toHaveLength(2)
expect(hasMore).toBe(true)
})
it('hasMore is false when all items fit within the limit', () => {
const { hasMore } = filterSortAndPaginate(items, PLACES, [], getItemTags, 100)
expect(hasMore).toBe(false)
})
it('hasMore is false when itemsToShow equals the item count exactly', () => {
const { visibleItems, hasMore } = filterSortAndPaginate(items, PLACES, [], getItemTags, 4)
expect(visibleItems).toHaveLength(4)
expect(hasMore).toBe(false)
})
it('returns empty array when no items match', () => {
const { visibleItems, hasMore } = filterSortAndPaginate(
items,
'nonexistent',
[],
getItemTags,
30,
)
expect(visibleItems).toEqual([])
expect(hasMore).toBe(false)
})
})
})

View File

@ -0,0 +1,48 @@
import type { Item } from '#types/Item'
import type { Tag } from '#types/Tag'
/**
* Pure helper that filters items by layer and tags, sorts by date (newest first),
* and returns a paginated slice.
*
* Extracted from OverlayItemsIndexPage for testability.
*
* @category Templates
*/
export function filterSortAndPaginate(
items: Item[],
layerName: string,
filterTags: Tag[],
getItemTags: (item: Item) => Tag[],
itemsToShow: number,
): { visibleItems: Item[]; hasMore: boolean } {
const filteredAndSortedItems = items
.filter((i) => i.layer?.name === layerName)
.filter((item) =>
filterTags.length === 0
? true
: filterTags.some((tag) =>
getItemTags(item).some(
(itemTag) => itemTag.name.toLocaleLowerCase() === tag.name.toLocaleLowerCase(),
),
),
)
.sort((a, b) => {
const dateA = a.date_updated
? new Date(a.date_updated).getTime()
: a.date_created
? new Date(a.date_created).getTime()
: 0
const dateB = b.date_updated
? new Date(b.date_updated).getTime()
: b.date_created
? new Date(b.date_created).getTime()
: 0
return dateB - dateA
})
const visibleItems = filteredAndSortedItems.slice(0, itemsToShow)
const hasMore = filteredAndSortedItems.length > itemsToShow
return { visibleItems, hasMore }
}

608
package-lock.json generated

File diff suppressed because it is too large Load Diff