mirror of
https://github.com/utopia-os/utopia-ui.git
synced 2026-04-05 00:56:32 +00:00
Merge branch 'main' into refactor-tab-layout
This commit is contained in:
commit
d4183b8769
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 { hashTagRegex } from '#utils/HashTagRegex'
|
||||||
import { randomColor } from '#utils/RandomColor'
|
import { randomColor } from '#utils/RandomColor'
|
||||||
|
|
||||||
|
import { filterSortAndPaginate } from './filterSortAndPaginate'
|
||||||
import { ItemCard } from './ItemCard'
|
import { ItemCard } from './ItemCard'
|
||||||
import { MapOverlayPage } from './MapOverlayPage'
|
import { MapOverlayPage } from './MapOverlayPage'
|
||||||
|
|
||||||
@ -40,8 +41,11 @@ export const OverlayItemsIndexPage = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [loading, setLoading] = useState<boolean>(false)
|
const [loading, setLoading] = useState<boolean>(false)
|
||||||
const [addItemPopupOpen, setAddItemPopupOpen] = useState<boolean>(false)
|
const [addItemPopupOpen, setAddItemPopupOpen] = useState<boolean>(false)
|
||||||
|
const [itemsToShow, setItemsToShow] = useState<number>(30)
|
||||||
const tabRef = useRef<HTMLFormElement>(null)
|
const tabRef = useRef<HTMLFormElement>(null)
|
||||||
|
const sentinelRef = useRef<HTMLDivElement>(null)
|
||||||
|
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const isLoadingMoreRef = useRef(false)
|
||||||
|
|
||||||
function scroll() {
|
function scroll() {
|
||||||
tabRef.current?.scrollIntoView()
|
tabRef.current?.scrollIntoView()
|
||||||
@ -66,6 +70,45 @@ export const OverlayItemsIndexPage = ({
|
|||||||
|
|
||||||
const layer = layers.find((l) => l.name === layerName)
|
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>) => {
|
const submitNewItem = async (evt: React.FormEvent<HTMLFormElement>) => {
|
||||||
evt.preventDefault()
|
evt.preventDefault()
|
||||||
const formItem: Item = {} as Item
|
const formItem: Item = {} as Item
|
||||||
@ -130,44 +173,18 @@ export const OverlayItemsIndexPage = ({
|
|||||||
<TagsControl />
|
<TagsControl />
|
||||||
</Control>
|
</Control>
|
||||||
</div>
|
</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'>
|
<div className='tw:columns-1 tw:md:columns-2 tw:lg:columns-3 tw:2xl:columns-4 tw:gap-6 tw:pt-4'>
|
||||||
{items
|
{visibleItems.map((i) => (
|
||||||
.filter((i) => i.layer?.name === layerName)
|
<div key={i.id} className='tw:break-inside-avoid tw:mb-6'>
|
||||||
.filter((item) =>
|
<ItemCard
|
||||||
filterTags.length === 0
|
i={i}
|
||||||
? item
|
loading={loading}
|
||||||
: filterTags.some((tag) =>
|
url={url}
|
||||||
getItemTags(item).some(
|
deleteCallback={() => deleteItem(i)}
|
||||||
(filterTag) =>
|
/>
|
||||||
filterTag.name.toLocaleLowerCase() === tag.name.toLocaleLowerCase(),
|
</div>
|
||||||
),
|
))}
|
||||||
),
|
|
||||||
)
|
|
||||||
.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>
|
|
||||||
))}
|
|
||||||
{addItemPopupOpen && (
|
{addItemPopupOpen && (
|
||||||
<form ref={tabRef} autoComplete='off' onSubmit={(e) => submitNewItem(e)}>
|
<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'>
|
<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>
|
</form>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</MapOverlayPage>
|
</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 }
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user