From 016035db163a221b8295b597a2ae5408bd0fc237 Mon Sep 17 00:00:00 2001 From: John Chantzigoulas Date: Thu, 7 May 2026 19:48:08 +0300 Subject: [PATCH] feat(database): owner filter and pagination for models list Add owner-chip filtering and server-side pagination (limit 10) to the Database Models list. Search, selected owners, and current page are all driven by URL search params (?search, ?owner, ?page), so filter state is shareable, survives reload, and works with browser Back/Forward. --- .../(modules)/database/models-new/page.tsx | 45 ++- .../database-new/models-list-table.tsx | 257 ++++++++++++++++-- src/components/database-new/models-page.tsx | 20 ++ 3 files changed, 299 insertions(+), 23 deletions(-) diff --git a/src/app/(dashboard)/(modules)/database/models-new/page.tsx b/src/app/(dashboard)/(modules)/database/models-new/page.tsx index 460d329d..ace8e5c9 100644 --- a/src/app/(dashboard)/(modules)/database/models-new/page.tsx +++ b/src/app/(dashboard)/(modules)/database/models-new/page.tsx @@ -7,9 +7,45 @@ import { ModelsNewPage } from '@/components/database-new/models-page'; export const dynamic = 'force-dynamic'; -export default async function ModelsNew() { +const PAGE_SIZE = 10; + +type ModelsNewProps = { + searchParams: Promise<{ + page?: string; + search?: string; + owner?: string; + }>; +}; + +function parsePage(raw: string | undefined): number { + const n = Number(raw); + if (!Number.isFinite(n) || n < 1) return 1; + return Math.floor(n); +} + +function parseOwners(raw: string | undefined): string[] { + if (!raw) return []; + return raw + .split(',') + .map(s => s.trim()) + .filter(Boolean); +} + +export default async function ModelsNew(props: Readonly) { + const searchParams = await props.searchParams; + + const page = parsePage(searchParams.page); + const search = searchParams.search?.trim() || ''; + const owners = parseOwners(searchParams.owner); + const [schemasData, modulesData, dbTypeRes] = await Promise.all([ - getSchemas({ limit: 1000, enabled: true }), + getSchemas({ + skip: (page - 1) * PAGE_SIZE, + limit: PAGE_SIZE, + enabled: true, + search: search || undefined, + owner: owners.length > 0 ? owners : undefined, + }), getSchemaOwnerModules({ sort: 'name' }), getDatabaseType(), ]); @@ -20,6 +56,11 @@ export default async function ModelsNew() { modules={modulesData.modules} selectedModelId={null} databaseType={dbTypeRes.result} + count={schemasData.count} + page={page} + pageSize={PAGE_SIZE} + initialSearch={search} + initialOwners={owners} /> ); } diff --git a/src/components/database-new/models-list-table.tsx b/src/components/database-new/models-list-table.tsx index 12c2bbf7..1f5c2f25 100644 --- a/src/components/database-new/models-list-table.tsx +++ b/src/components/database-new/models-list-table.tsx @@ -1,7 +1,7 @@ 'use client'; import * as React from 'react'; -import { useRouter } from 'next/navigation'; +import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { DeclaredSchema } from '@/lib/models/database'; import { Table, @@ -14,6 +14,15 @@ import { import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Badge } from '@/components/ui/badge'; +import { + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from '@/components/ui/pagination'; import { DropdownMenu, DropdownMenuContent, @@ -34,35 +43,114 @@ import { cn } from '@/lib/utils'; type ModelsListTableProps = { schemas: DeclaredSchema[]; modules: string[]; + count: number; + page: number; + pageSize: number; + initialSearch: string; + initialOwners: string[]; onCreateNew: () => void; onSelect: (modelId: string) => void; onDelete?: (modelId: string) => void; }; +const SEARCH_DEBOUNCE_MS = 300; + export function ModelsListTable({ schemas, modules, + count, + page, + pageSize, + initialSearch, + initialOwners, onCreateNew, onSelect, onDelete, }: ModelsListTableProps) { - const [search, setSearch] = React.useState(''); - - const filteredSchemas = React.useMemo(() => { - if (!search) return schemas; - const searchLower = search.toLowerCase(); - return schemas.filter( - schema => - schema.name.toLowerCase().includes(searchLower) || - schema.ownerModule.toLowerCase().includes(searchLower) - ); - }, [schemas, search]); + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + const [searchInput, setSearchInput] = React.useState(initialSearch); + + // Keep the input in sync if the URL changes from outside (e.g. Back button). + React.useEffect(() => { + setSearchInput(initialSearch); + }, [initialSearch]); + + const setParams = React.useCallback( + (patch: Record) => { + const next = new URLSearchParams(searchParams.toString()); + for (const [key, value] of Object.entries(patch)) { + if (value === null || value === '') { + next.delete(key); + } else { + next.set(key, value); + } + } + const qs = next.toString(); + router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false }); + }, + [pathname, router, searchParams] + ); + + // Debounce search input -> URL. + React.useEffect(() => { + if (searchInput === initialSearch) return; + const handle = window.setTimeout(() => { + setParams({ search: searchInput || null, page: null }); + }, SEARCH_DEBOUNCE_MS); + return () => window.clearTimeout(handle); + }, [searchInput, initialSearch, setParams]); + + const selectedOwners = React.useMemo( + () => new Set(initialOwners), + [initialOwners] + ); + + const toggleOwner = (owner: string) => { + const next = new Set(selectedOwners); + if (next.has(owner)) { + next.delete(owner); + } else { + next.add(owner); + } + const ordered = modules.filter(m => next.has(m)); + setParams({ + owner: ordered.length > 0 ? ordered.join(',') : null, + page: null, + }); + }; + + const clearOwners = () => { + setParams({ owner: null, page: null }); + }; + + const clearAllFilters = () => { + setSearchInput(''); + setParams({ search: null, owner: null, page: null }); + }; + + const totalPages = Math.max(1, Math.ceil(count / pageSize)); + const hasFilters = initialSearch.length > 0 || selectedOwners.size > 0; + const showPagination = count > pageSize; + + const goToPage = (target: number) => { + const clamped = Math.min(Math.max(target, 1), totalPages); + if (clamped === page) return; + setParams({ page: clamped === 1 ? null : String(clamped) }); + }; const getFieldCount = (schema: DeclaredSchema) => { const fields = schema.compiledFields || schema.fields || {}; return Object.keys(fields).length; }; + const pageNumbers = React.useMemo( + () => buildPageList(page, totalPages), + [page, totalPages] + ); + return (
{/* Header */} @@ -72,7 +160,10 @@ export function ModelsListTable({

Database Models

- {schemas.length} models + + {count} {count === 1 ? 'model' : 'models'} + {hasFilters ? ' (filtered)' : ''} + ) : ( @@ -138,7 +251,7 @@ export function ModelsListTable({ - {filteredSchemas.map(schema => ( + {schemas.map(schema => ( )} + + {/* Pagination */} + {showPagination && ( +
+ + + + { + e.preventDefault(); + goToPage(page - 1); + }} + /> + + {pageNumbers.map((entry, idx) => + entry === 'ellipsis' ? ( + + + + ) : ( + + { + e.preventDefault(); + goToPage(entry); + }} + > + {entry} + + + ) + )} + + = totalPages} + className={cn( + page >= totalPages && 'pointer-events-none opacity-50' + )} + onClick={e => { + e.preventDefault(); + goToPage(page + 1); + }} + /> + + + +
+ )} ); } + +type OwnerChipProps = { + label: string; + isActive: boolean; + onClick: () => void; +}; + +function OwnerChip({ label, isActive, onClick }: OwnerChipProps) { + return ( + + ); +} + +/** + * Build a compact pagination list with leading/trailing ellipses, matching the + * common 1 ... 4 5 6 ... 20 pattern. Always includes first and last page. + */ +function buildPageList( + current: number, + total: number +): Array { + if (total <= 7) { + return Array.from({ length: total }, (_, i) => i + 1); + } + const pages: Array = [1]; + const start = Math.max(2, current - 1); + const end = Math.min(total - 1, current + 1); + if (start > 2) pages.push('ellipsis'); + for (let i = start; i <= end; i++) pages.push(i); + if (end < total - 1) pages.push('ellipsis'); + pages.push(total); + return pages; +} diff --git a/src/components/database-new/models-page.tsx b/src/components/database-new/models-page.tsx index b93856c2..0bee0eff 100644 --- a/src/components/database-new/models-page.tsx +++ b/src/components/database-new/models-page.tsx @@ -57,6 +57,16 @@ type ModelsNewPageProps = { initialTab?: ModelsNewTab; /** From GET /database/database-type; controls Mongo-only schema settings */ databaseType?: string; + /** Total number of schemas matching current filters (server-side). List view only. */ + count?: number; + /** Current 1-based page index for the list view. */ + page?: number; + /** Page size used by the server fetch for the list view. */ + pageSize?: number; + /** Initial search query parsed from the URL for the list view. */ + initialSearch?: string; + /** Initial owner filter parsed from the URL for the list view. */ + initialOwners?: string[]; }; export function ModelsNewPage({ @@ -68,6 +78,11 @@ export function ModelsNewPage({ authResource, initialTab = 'schema', databaseType, + count, + page, + pageSize, + initialSearch, + initialOwners, }: ModelsNewPageProps) { const router = useRouter(); const [activeTab, setActiveTab] = React.useState(() => @@ -119,6 +134,11 @@ export function ModelsNewPage({