mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 15:51:00 +00:00
feat(collections): filter by category on the phone (#2618)
The phone toolbar gets a category chip beside the status and label chips, fed by the same categoryOptions the desktop dropdown uses, and no longer clears a category picked on desktop. Managing labels moves into the label panel so the row keeps four chips at phone width.
This commit is contained in:
@@ -182,7 +182,7 @@
|
||||
"mobile/screens/collections/MCollShareSheet.tsx": 14,
|
||||
"mobile/screens/collections/MCollSheetKit.tsx": 5,
|
||||
"mobile/screens/collections/MCollTripPickerSheet.tsx": 4,
|
||||
"mobile/screens/collections/MCollections.tsx": 14,
|
||||
"mobile/screens/collections/MCollections.tsx": 13,
|
||||
"mobile/screens/dashboard/MDashWidgets.tsx": 22,
|
||||
"mobile/screens/dashboard/MDashboard.tsx": 30,
|
||||
"mobile/screens/dashboard/MNewTripSheet.tsx": 12,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ReactNode, useEffect, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router'
|
||||
import {
|
||||
Bookmark, Check, CheckCheck, CheckSquare, ChevronDown, Copy, CopyPlus,
|
||||
FolderInput, Layers, List, Map as MapIcon, Pencil, Plus, Search, Share2,
|
||||
FolderInput, Layers, List, Map as MapIcon, Pencil, Search, Settings2, Shapes, Share2,
|
||||
Tag, Tags, Trash2, X,
|
||||
} from 'lucide-react'
|
||||
import type { CollectionStatus } from '@trek/shared'
|
||||
@@ -22,7 +22,7 @@ import MCollTripPickerSheet from './MCollTripPickerSheet'
|
||||
import MCollListPickerSheet from './MCollListPickerSheet'
|
||||
import MCollTours from './MCollTours'
|
||||
import MCollInvites from './MCollInvites'
|
||||
import { STATUS_SPEC } from './collectionsMobileModel'
|
||||
import { STATUS_SPEC, categoryMeta } from './collectionsMobileModel'
|
||||
import MConfirmSheet from '../settings/MConfirmSheet'
|
||||
import MCollListNav from './MCollListNav'
|
||||
import { useIsSplit } from '../../../hooks/sizeClass'
|
||||
@@ -46,6 +46,30 @@ const CHIP_ON = 'border border-transparent bg-m-act text-m-actfg'
|
||||
const DROP_PANEL =
|
||||
'rounded-[14px] border border-[color:var(--m-rowbr)] bg-m-sheetop shadow-[0_20px_44px_-18px_rgba(0,0,0,.45)]'
|
||||
|
||||
/** The filter chips that open a dropdown; at most one is open at a time. */
|
||||
type FilterDrop = 'status' | 'category' | 'label'
|
||||
|
||||
/** One row of the status or category dropdown: icon, name, count. */
|
||||
function DropRow({ icon, label, count, on, onPick }: {
|
||||
icon: ReactNode
|
||||
label: string
|
||||
count: number
|
||||
on: boolean
|
||||
onPick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPick}
|
||||
className={`flex w-full items-center gap-[9px] rounded-[11px] p-[10px] text-[0.8125rem] font-semibold text-m-ink ${on ? 'bg-[color:var(--m-ic)]' : ''}`}
|
||||
>
|
||||
{icon}
|
||||
<span dir="auto" className="min-w-0 flex-1 truncate text-start">{label}</span>
|
||||
<span className="font-geist text-[0.6875rem] font-bold text-m-faint">{count}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyNote({ icon, scene, title }: { icon?: ReactNode; scene?: TrekScene; title: string }) {
|
||||
return (
|
||||
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-[6px] px-6 text-center">
|
||||
@@ -73,8 +97,8 @@ export default function MCollections() {
|
||||
// With room for two panes the lists stand in a column beside the places
|
||||
// (MCollListNav) instead of behind the header's dropdown.
|
||||
const split = useIsSplit()
|
||||
const [statusDrop, setStatusDrop] = useState(false)
|
||||
const [labelDrop, setLabelDrop] = useState(false)
|
||||
const [filterDrop, setFilterDrop] = useState<FilterDrop | null>(null)
|
||||
const toggleDrop = (drop: FilterDrop) => setFilterDrop(cur => (cur === drop ? null : drop))
|
||||
|
||||
// The bottom-nav "+" hands off via the ?create= contract.
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
@@ -102,18 +126,17 @@ export default function MCollections() {
|
||||
// Close transient popovers when the active list changes under them.
|
||||
useEffect(() => {
|
||||
setDrop(false)
|
||||
setStatusDrop(false)
|
||||
setLabelDrop(false)
|
||||
setFilterDrop(null)
|
||||
}, [c.activeId])
|
||||
|
||||
// The mobile toolbar has no category or rating filter control, but the shared
|
||||
// hook still applies whatever was set on desktop — keep both cleared so
|
||||
// resizing to phone doesn't silently filter the list and map (#1435).
|
||||
const { categoryFilter, setCategoryFilter, ratingFilter, setRatingFilter } = c
|
||||
// The mobile toolbar has no rating filter control, but the shared hook still
|
||||
// applies whatever was set on desktop: keep it cleared so resizing to phone
|
||||
// doesn't silently filter the list and map (#1435). The category filter has
|
||||
// its own chip here (#2618), so it carries over.
|
||||
const { ratingFilter, setRatingFilter } = c
|
||||
useEffect(() => {
|
||||
if (categoryFilter !== 'all') setCategoryFilter('all')
|
||||
if (ratingFilter !== 'all') setRatingFilter('all')
|
||||
}, [categoryFilter, setCategoryFilter, ratingFilter, setRatingFilter])
|
||||
}, [ratingFilter, setRatingFilter])
|
||||
|
||||
const title = c.isAllSaved ? t('collections.allSaved') : (c.activeCollection?.name ?? t('collections.title'))
|
||||
const isRealList = !c.isAllSaved && typeof c.activeId === 'number'
|
||||
@@ -127,6 +150,15 @@ export default function MCollections() {
|
||||
const statusLabel = c.statusFilter === 'all'
|
||||
? t('collections.status.filterAll')
|
||||
: t(STATUS_SPEC[c.statusFilter].labelKey)
|
||||
// A category picked on another list (or on desktop) stays set when this list
|
||||
// has none of it, so the chip stays reachable to clear it again.
|
||||
const showCategoryChip = c.categoryOptions.length > 0 || c.categoryFilter !== 'all'
|
||||
const categoryLabel = c.categoryFilter === 'all'
|
||||
? t('collections.status.filterAll')
|
||||
: (c.categoryOptions.find(o => o.id === c.categoryFilter)?.name
|
||||
?? c.categories.find(o => o.id === c.categoryFilter)?.name
|
||||
?? t('collections.status.filterAll'))
|
||||
const categoryTotal = c.categoryOptions.reduce((n, o) => n + o.count, 0)
|
||||
const labelLabel = c.labelFilter.length === 0
|
||||
? t('collections.status.filterAll')
|
||||
: c.labelFilter.length === 1
|
||||
@@ -148,7 +180,17 @@ export default function MCollections() {
|
||||
|
||||
const pickStatusFilter = (f: StatusFilter) => {
|
||||
c.setStatusFilter(f)
|
||||
setStatusDrop(false)
|
||||
setFilterDrop(null)
|
||||
}
|
||||
|
||||
const pickCategoryFilter = (f: number | 'all') => {
|
||||
c.setCategoryFilter(f)
|
||||
setFilterDrop(null)
|
||||
}
|
||||
|
||||
const openLabelManager = () => {
|
||||
setFilterDrop(null)
|
||||
c.setShowLabelManager(true)
|
||||
}
|
||||
|
||||
const toggleLabelFilter = (id: number) => {
|
||||
@@ -280,19 +322,32 @@ export default function MCollections() {
|
||||
<div className="relative mt-2 flex items-center gap-[6px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setStatusDrop(v => !v); setLabelDrop(false) }}
|
||||
aria-expanded={statusDrop}
|
||||
onClick={() => toggleDrop('status')}
|
||||
aria-expanded={filterDrop === 'status'}
|
||||
className={`${FILTER_CHIP} ${c.statusFilter !== 'all' ? CHIP_ON : CHIP_IDLE}`}
|
||||
>
|
||||
<Layers size={13} strokeWidth={2} className="flex-none" />
|
||||
<span className="truncate">{statusLabel}</span>
|
||||
<ChevronDown size={12} strokeWidth={2} className={`flex-none ${c.statusFilter !== 'all' ? '' : 'text-m-faint'}`} />
|
||||
</button>
|
||||
{showCategoryChip && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleDrop('category')}
|
||||
aria-expanded={filterDrop === 'category'}
|
||||
aria-label={`${t('categories.title')}: ${categoryLabel}`}
|
||||
className={`${FILTER_CHIP} ${c.categoryFilter !== 'all' ? CHIP_ON : CHIP_IDLE}`}
|
||||
>
|
||||
<Shapes size={13} strokeWidth={2} className="flex-none" />
|
||||
<span dir="auto" className="truncate">{categoryLabel}</span>
|
||||
<ChevronDown size={12} strokeWidth={2} className={`flex-none ${c.categoryFilter !== 'all' ? '' : 'text-m-faint'}`} />
|
||||
</button>
|
||||
)}
|
||||
{isRealList && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setLabelDrop(v => !v); setStatusDrop(false) }}
|
||||
aria-expanded={labelDrop}
|
||||
onClick={() => toggleDrop('label')}
|
||||
aria-expanded={filterDrop === 'label'}
|
||||
className={`${FILTER_CHIP} ${c.labelFilter.length > 0 ? CHIP_ON : CHIP_IDLE}`}
|
||||
>
|
||||
<Tag size={13} strokeWidth={2} className="flex-none" />
|
||||
@@ -300,12 +355,6 @@ export default function MCollections() {
|
||||
<ChevronDown size={12} strokeWidth={2} className={`flex-none ${c.labelFilter.length > 0 ? '' : 'text-m-faint'}`} />
|
||||
</button>
|
||||
)}
|
||||
{canManageLabels && (
|
||||
<button type="button" onClick={() => c.setShowLabelManager(true)} className={`${FILTER_CHIP} ${CHIP_IDLE}`}>
|
||||
<Plus size={12} strokeWidth={2.2} className="flex-none" />
|
||||
<span className="truncate">{t('collections.labels.title')}</span>
|
||||
</button>
|
||||
)}
|
||||
{showSelect && (
|
||||
<button type="button" onClick={() => c.setSelectMode(true)} className={`${FILTER_CHIP} ${CHIP_IDLE}`}>
|
||||
<CheckSquare size={13} strokeWidth={2} className="flex-none" />
|
||||
@@ -313,48 +362,69 @@ export default function MCollections() {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{(statusDrop || labelDrop) && (
|
||||
{filterDrop && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
className="fixed inset-0 z-10 cursor-default"
|
||||
onClick={() => { setStatusDrop(false); setLabelDrop(false) }}
|
||||
onClick={() => setFilterDrop(null)}
|
||||
/>
|
||||
)}
|
||||
{statusDrop && (
|
||||
{filterDrop === 'status' && (
|
||||
<div className={`absolute inset-x-0 top-[calc(100%+6px)] z-20 p-[6px] ${DROP_PANEL}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => pickStatusFilter('all')}
|
||||
className={`flex w-full items-center gap-[9px] rounded-[11px] p-[10px] text-[0.8125rem] font-semibold text-m-ink ${c.statusFilter === 'all' ? 'bg-[color:var(--m-ic)]' : ''}`}
|
||||
>
|
||||
<Layers size={15} strokeWidth={2} />
|
||||
<span className="flex-1 text-start">{t('collections.status.filterAll')}</span>
|
||||
<span className="font-geist text-[0.6875rem] font-bold text-m-faint">{c.counts.all}</span>
|
||||
</button>
|
||||
<DropRow
|
||||
icon={<Layers size={15} strokeWidth={2} className="flex-none" />}
|
||||
label={t('collections.status.filterAll')}
|
||||
count={c.counts.all}
|
||||
on={c.statusFilter === 'all'}
|
||||
onPick={() => pickStatusFilter('all')}
|
||||
/>
|
||||
{STATUS_ORDER.map(s => {
|
||||
const meta = STATUS_SPEC[s]
|
||||
const Icon = meta.icon
|
||||
return (
|
||||
<button
|
||||
<DropRow
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => pickStatusFilter(s)}
|
||||
className={`flex w-full items-center gap-[9px] rounded-[11px] p-[10px] text-[0.8125rem] font-semibold text-m-ink ${c.statusFilter === s ? 'bg-[color:var(--m-ic)]' : ''}`}
|
||||
>
|
||||
<Icon size={15} strokeWidth={2} style={{ color: meta.color }} />
|
||||
<span className="flex-1 text-start">{t(meta.labelKey)}</span>
|
||||
<span className="font-geist text-[0.6875rem] font-bold text-m-faint">{c.counts[s]}</span>
|
||||
</button>
|
||||
icon={<Icon size={15} strokeWidth={2} className="flex-none" style={{ color: meta.color }} />}
|
||||
label={t(meta.labelKey)}
|
||||
count={c.counts[s]}
|
||||
on={c.statusFilter === s}
|
||||
onPick={() => pickStatusFilter(s)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{labelDrop && (
|
||||
<div className={`absolute inset-x-0 top-[calc(100%+6px)] z-20 max-h-[240px] overflow-y-auto p-[6px] ${DROP_PANEL}`}>
|
||||
{filterDrop === 'category' && (
|
||||
<div className={`absolute inset-x-0 top-[calc(100%+6px)] z-20 max-h-[300px] overflow-y-auto p-[6px] ${DROP_PANEL}`}>
|
||||
<DropRow
|
||||
icon={<Shapes size={15} strokeWidth={2} className="flex-none" />}
|
||||
label={t('collections.status.filterAll')}
|
||||
count={categoryTotal}
|
||||
on={c.categoryFilter === 'all'}
|
||||
onPick={() => pickCategoryFilter('all')}
|
||||
/>
|
||||
{c.categoryOptions.map(o => {
|
||||
const meta = categoryMeta(o)
|
||||
const Icon = meta?.icon ?? Shapes
|
||||
return (
|
||||
<DropRow
|
||||
key={o.id}
|
||||
icon={<Icon size={15} strokeWidth={2} className="flex-none" style={{ color: meta?.color }} />}
|
||||
label={o.name}
|
||||
count={o.count}
|
||||
on={c.categoryFilter === o.id}
|
||||
onPick={() => pickCategoryFilter(o.id)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{filterDrop === 'label' && (
|
||||
<div className={`absolute inset-x-0 top-[calc(100%+6px)] z-20 max-h-[300px] overflow-y-auto p-[6px] ${DROP_PANEL}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { c.setLabelFilter([]); setLabelDrop(false) }}
|
||||
onClick={() => { c.setLabelFilter([]); setFilterDrop(null) }}
|
||||
className={`flex w-full items-center gap-[9px] rounded-[11px] p-[10px] text-[0.8125rem] font-semibold text-m-ink ${c.labelFilter.length === 0 ? 'bg-[color:var(--m-ic)]' : ''}`}
|
||||
>
|
||||
<Tag size={15} strokeWidth={2} />
|
||||
@@ -379,6 +449,18 @@ export default function MCollections() {
|
||||
{c.labelOptions.length === 0 && (
|
||||
<div className="p-[10px] text-center font-geist text-[0.71875rem] text-m-faint">{t('collections.labels.empty')}</div>
|
||||
)}
|
||||
{/* Managing the labels sits under their list, which keeps the row at
|
||||
four chips now that the category chip joined it (#2618). */}
|
||||
{canManageLabels && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openLabelManager}
|
||||
className="mt-1 flex w-full items-center gap-[9px] rounded-[11px] border-t border-[color:var(--m-rowbr)] p-[10px] text-[0.8125rem] font-semibold text-m-ink"
|
||||
>
|
||||
<Settings2 size={15} strokeWidth={2} className="flex-none" />
|
||||
<span className="flex-1 text-start">{c.labelOptions.length > 0 ? t('collections.labels.manage') : t('collections.labels.add')}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-MOB-COLSCR-001 to FE-MOB-COLSCR-034
|
||||
// FE-MOB-COLSCR-001 to FE-MOB-COLSCR-038
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor, within } from '../../../helpers/render'
|
||||
@@ -169,6 +169,7 @@ function makeHook(over: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
view: 'list',
|
||||
statusFilter: 'all',
|
||||
categoryFilter: 'all',
|
||||
categoryOptions: [],
|
||||
ratingFilter: 'all',
|
||||
search: '',
|
||||
selectedPlaceId: null,
|
||||
@@ -366,7 +367,7 @@ describe('MCollections', () => {
|
||||
expect(fn('setLabelFilter')).toHaveBeenCalledWith([2, 1])
|
||||
})
|
||||
|
||||
it('FE-MOB-COLSCR-013: a list without labels says so, and the manage chip opens the manager', () => {
|
||||
it('FE-MOB-COLSCR-013: a list without labels says so, and the label panel opens the manager', () => {
|
||||
render(<MCollections />)
|
||||
|
||||
// Status chip and label chip both read "all" while nothing is filtered.
|
||||
@@ -375,8 +376,10 @@ describe('MCollections', () => {
|
||||
fireEvent.click(chips[1])
|
||||
expect(screen.getByText('collections.labels.empty')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /collections.labels.title/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'collections.labels.add' }))
|
||||
expect(fn('setShowLabelManager')).toHaveBeenCalledWith(true)
|
||||
// Opening the manager closes the panel.
|
||||
expect(screen.queryByText('collections.labels.empty')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-COLSCR-014: the backdrop under an open dropdown closes it again', () => {
|
||||
@@ -653,12 +656,12 @@ describe('MCollections', () => {
|
||||
expect(fn('setShowAddPlace')).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-COLSCR-029: desktop-only category and rating filters are cleared on phone (#1435)', async () => {
|
||||
it('FE-MOB-COLSCR-029: the desktop-only rating filter is cleared on phone, the category filter is kept (#1435, #2618)', async () => {
|
||||
mocks.coll = makeHook({ categoryFilter: 3, ratingFilter: 4 })
|
||||
render(<MCollections />)
|
||||
|
||||
await waitFor(() => expect(fn('setCategoryFilter')).toHaveBeenCalledWith('all'))
|
||||
expect(fn('setRatingFilter')).toHaveBeenCalledWith('all')
|
||||
await waitFor(() => expect(fn('setRatingFilter')).toHaveBeenCalledWith('all'))
|
||||
expect(fn('setCategoryFilter')).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-COLSCR-030: the add sheet has no fixed target while the union is active', () => {
|
||||
@@ -671,6 +674,64 @@ describe('MCollections', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// #2618: the phone could filter by status and label, but not by category.
|
||||
describe('MCollections: category filter (#2618)', () => {
|
||||
const categoryOptions = [
|
||||
{ id: 3, name: 'Restaurant', color: '#E8843D', icon: 'utensils', count: 2 },
|
||||
{ id: 5, name: 'Museum', color: null, icon: null, count: 1 },
|
||||
]
|
||||
|
||||
it('FE-MOB-COLSCR-035: the chip lists the categories present with their counts and picks one', () => {
|
||||
mocks.coll = makeHook({ categoryOptions })
|
||||
render(<MCollections />)
|
||||
|
||||
const chip = screen.getByRole('button', { name: 'categories.title: collections.status.filterAll' })
|
||||
expect(chip).toHaveAttribute('aria-expanded', 'false')
|
||||
fireEvent.click(chip)
|
||||
expect(chip).toHaveAttribute('aria-expanded', 'true')
|
||||
// The "all" row counts every categorised place.
|
||||
expect(screen.getByRole('button', { name: /^collections.status.filterAll\s*3$/ })).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Restaurant\s*2$/ }))
|
||||
expect(fn('setCategoryFilter')).toHaveBeenCalledWith(3)
|
||||
// Picking closes the panel.
|
||||
expect(screen.queryByRole('button', { name: /^Museum\s*1$/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-COLSCR-036: an active category names itself and the "all" row clears it', () => {
|
||||
mocks.coll = makeHook({ categoryOptions, categoryFilter: 5 })
|
||||
render(<MCollections />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'categories.title: Museum' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /^collections.status.filterAll\s*3$/ }))
|
||||
expect(fn('setCategoryFilter')).toHaveBeenCalledWith('all')
|
||||
})
|
||||
|
||||
it('FE-MOB-COLSCR-037: without categories the chip stays away, unless a category is still set from another list', () => {
|
||||
const { unmount } = render(<MCollections />)
|
||||
expect(screen.queryByRole('button', { name: /^categories.title/ })).not.toBeInTheDocument()
|
||||
unmount()
|
||||
|
||||
mocks.coll = makeHook({ categoryFilter: 7, categories: [{ id: 7, name: 'Beach' }] })
|
||||
render(<MCollections />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'categories.title: Beach' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /^collections.status.filterAll\s*0$/ }))
|
||||
expect(fn('setCategoryFilter')).toHaveBeenCalledWith('all')
|
||||
})
|
||||
|
||||
it('FE-MOB-COLSCR-038: opening one filter panel closes the other', () => {
|
||||
mocks.coll = makeHook({ categoryOptions })
|
||||
render(<MCollections />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'categories.title: collections.status.filterAll' }))
|
||||
expect(screen.getByRole('button', { name: /^Museum\s*1$/ })).toBeInTheDocument()
|
||||
// The status chip is the first chip reading "all".
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'collections.status.filterAll' })[0])
|
||||
expect(screen.queryByRole('button', { name: /^Museum\s*1$/ })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /collections.status.idea/ })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// #2645: the phone had no way to answer a list invite, and the notification's
|
||||
// link landed on "Start your first collection".
|
||||
describe('MCollections: invites (#2645)', () => {
|
||||
|
||||
Reference in New Issue
Block a user