mirror of
https://github.com/utopia-os/utopia-ui.git
synced 2026-04-06 01:25:33 +00:00
Compare commits
9 Commits
ccdf6713f9
...
1fae45b46a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fae45b46a | ||
|
|
298560c02e | ||
| ab515c20ad | |||
|
|
529ee8cc81 | ||
|
|
ab2380ec55 | ||
|
|
afb280da14 | ||
|
|
0f93c393a3 | ||
|
|
088516e3b7 | ||
|
|
f3d282b29a |
@ -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",
|
||||
|
||||
@ -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:
|
||||
|
||||
8
backend/scripts/drop-database.sql
Normal file
8
backend/scripts/drop-database.sql
Normal 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;
|
||||
3
backend/scripts/update-user-passwords.sql
Normal file
3
backend/scripts/update-user-passwords.sql
Normal 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;
|
||||
@ -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",
|
||||
|
||||
171
lib/src/Components/Templates/OverlayItemsIndexPage.spec.tsx
Normal file
171
lib/src/Components/Templates/OverlayItemsIndexPage.spec.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
|
||||
145
lib/src/Components/Templates/filterSortAndPaginate.spec.ts
Normal file
145
lib/src/Components/Templates/filterSortAndPaginate.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
48
lib/src/Components/Templates/filterSortAndPaginate.ts
Normal file
48
lib/src/Components/Templates/filterSortAndPaginate.ts
Normal 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
608
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user