Skip to content

Commit 74fbfbd

Browse files
Recipient message search pagination (#23)
Co-authored-by: me <me@kentcdodds.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent 979f253 commit 74fbfbd

2 files changed

Lines changed: 188 additions & 43 deletions

File tree

app/components/search-bar.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,19 @@ export function SearchBar({
1010
status,
1111
autoFocus = false,
1212
autoSubmit = false,
13+
action,
1314
}: {
1415
status: 'idle' | 'pending' | 'success' | 'error'
1516
autoFocus?: boolean
1617
autoSubmit?: boolean
18+
action?: string
1719
}) {
1820
const id = useId()
1921
const [searchParams] = useSearchParams()
2022
const submit = useSubmit()
2123
const isSubmitting = useIsPending({
2224
formMethod: 'GET',
23-
formAction: '/users',
25+
formAction: action,
2426
})
2527

2628
const handleFormChange = useDebounce((form: HTMLFormElement) => {
@@ -30,7 +32,7 @@ export function SearchBar({
3032
return (
3133
<Form
3234
method="GET"
33-
action="/users"
35+
action={action}
3436
className="flex flex-wrap items-center justify-center gap-2"
3537
onChange={(e) => autoSubmit && handleFormChange(e.currentTarget)}
3638
>

app/routes/_app+/recipients+/$recipientId.past.tsx

Lines changed: 184 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -4,48 +4,79 @@ import {
44
json,
55
type LoaderFunctionArgs,
66
} from '@remix-run/node'
7-
import { useLoaderData } from '@remix-run/react'
7+
import { Link, useLoaderData, useSearchParams } from '@remix-run/react'
88
import { GeneralErrorBoundary } from '#app/components/error-boundary.tsx'
9+
import { SearchBar } from '#app/components/search-bar.tsx'
10+
import { Button } from '#app/components/ui/button.tsx'
11+
import { Icon } from '#app/components/ui/icon.tsx'
912
import { requireUserId } from '#app/utils/auth.server.ts'
1013
import { prisma } from '#app/utils/db.server.ts'
14+
import { cn, useDelayedIsPending } from '#app/utils/misc.tsx'
15+
16+
const MESSAGES_PER_PAGE = 100
1117

1218
export async function loader({ params, request }: LoaderFunctionArgs) {
1319
const userId = await requireUserId(request)
20+
const url = new URL(request.url)
21+
const searchQuery = url.searchParams.get('search') ?? ''
22+
const page = Math.max(1, parseInt(url.searchParams.get('page') ?? '1', 10) || 1)
23+
1424
const recipient = await prisma.recipient.findUnique({
1525
where: { id: params.recipientId, userId },
1626
select: {
1727
name: true,
1828
phoneNumber: true,
19-
messages: {
20-
select: { id: true, content: true, sentAt: true, order: true },
21-
orderBy: { order: 'asc' },
22-
where: { sentAt: { not: null } },
23-
},
2429
},
2530
})
2631

2732
invariantResponse(recipient, 'Not found', { status: 404 })
2833

29-
const { messages, ...recipientProps } = recipient
34+
// Build the where clause for messages
35+
const messageWhere = {
36+
recipientId: params.recipientId,
37+
sentAt: { not: null },
38+
...(searchQuery
39+
? { content: { contains: searchQuery } }
40+
: {}),
41+
}
42+
43+
// Get total count for pagination
44+
const totalMessages = await prisma.message.count({
45+
where: messageWhere,
46+
})
47+
48+
const totalPages = Math.max(1, Math.ceil(totalMessages / MESSAGES_PER_PAGE))
49+
const currentPage = Math.min(page, totalPages)
50+
51+
// Get paginated messages
52+
const messages = await prisma.message.findMany({
53+
where: messageWhere,
54+
select: { id: true, content: true, sentAt: true },
55+
orderBy: { sentAt: 'desc' },
56+
skip: (currentPage - 1) * MESSAGES_PER_PAGE,
57+
take: MESSAGES_PER_PAGE,
58+
})
3059

3160
return json({
32-
recipient: recipientProps,
33-
messageCountDisplay: messages.length.toLocaleString(),
34-
pastMessages: messages
35-
.filter((m) => m.sentAt)
36-
.sort((m1, m2) => m2.sentAt!.getTime() - m1.sentAt!.getTime())
37-
.map((m) => ({
38-
id: m.id,
39-
sentAtDisplay: m.sentAt!.toLocaleDateString('en-US', {
40-
weekday: 'short',
41-
year: 'numeric',
42-
month: 'short',
43-
day: 'numeric',
44-
hour: 'numeric',
45-
minute: 'numeric',
46-
}),
47-
content: m.content,
48-
})),
61+
recipient,
62+
searchQuery,
63+
pagination: {
64+
currentPage,
65+
totalPages,
66+
totalMessages,
67+
},
68+
pastMessages: messages.map((m) => ({
69+
id: m.id,
70+
sentAtDisplay: m.sentAt!.toLocaleDateString('en-US', {
71+
weekday: 'short',
72+
year: 'numeric',
73+
month: 'short',
74+
day: 'numeric',
75+
hour: 'numeric',
76+
minute: 'numeric',
77+
}),
78+
content: m.content,
79+
})),
4980
})
5081
}
5182

@@ -57,29 +88,141 @@ export const meta: MetaFunction<typeof loader> = ({ data }) => {
5788
]
5889
}
5990

60-
export default function RecipientRoute() {
61-
const data = useLoaderData<typeof loader>()
91+
function Pagination({
92+
currentPage,
93+
totalPages,
94+
totalMessages,
95+
searchQuery,
96+
}: {
97+
currentPage: number
98+
totalPages: number
99+
totalMessages: number
100+
searchQuery: string
101+
}) {
102+
const [searchParams] = useSearchParams()
103+
104+
const buildPageUrl = (page: number) => {
105+
const params = new URLSearchParams(searchParams)
106+
if (page === 1) {
107+
params.delete('page')
108+
} else {
109+
params.set('page', page.toString())
110+
}
111+
const queryString = params.toString()
112+
return queryString ? `?${queryString}` : '.'
113+
}
114+
115+
const hasPrevPage = currentPage > 1
116+
const hasNextPage = currentPage < totalPages
62117

63118
return (
64-
<div>
65-
<p className="mb-8">
66-
You have sent <strong>{data.messageCountDisplay}</strong>{' '}
67-
{data.pastMessages.length === 1 ? 'message' : 'messages'} to{' '}
68-
{data.recipient.name}.
119+
<div className="flex flex-col items-center gap-4 sm:flex-row sm:justify-between">
120+
<p className="text-sm text-muted-foreground">
121+
{totalMessages === 0
122+
? 'No messages found'
123+
: `Showing ${((currentPage - 1) * MESSAGES_PER_PAGE) + 1}-${Math.min(currentPage * MESSAGES_PER_PAGE, totalMessages)} of ${totalMessages.toLocaleString()} message${totalMessages === 1 ? '' : 's'}`}
124+
{searchQuery ? (
125+
<>
126+
{' '}
127+
matching "<strong>{searchQuery}</strong>"
128+
</>
129+
) : null}
69130
</p>
70-
<ul className="flex flex-col gap-2">
71-
{data.pastMessages.map((m) => (
72-
<li
73-
key={m.id}
74-
className="flex flex-col justify-start gap-2 align-top lg:flex-row"
131+
{totalPages > 1 ? (
132+
<div className="flex items-center gap-2">
133+
<Button
134+
variant="outline"
135+
size="sm"
136+
asChild={hasPrevPage}
137+
disabled={!hasPrevPage}
138+
>
139+
{hasPrevPage ? (
140+
<Link to={buildPageUrl(currentPage - 1)} preventScrollReset>
141+
<Icon name="arrow-left" size="sm" />
142+
Previous
143+
</Link>
144+
) : (
145+
<span>
146+
<Icon name="arrow-left" size="sm" />
147+
Previous
148+
</span>
149+
)}
150+
</Button>
151+
<span className="px-2 text-sm">
152+
Page {currentPage} of {totalPages}
153+
</span>
154+
<Button
155+
variant="outline"
156+
size="sm"
157+
asChild={hasNextPage}
158+
disabled={!hasNextPage}
75159
>
76-
<span className="min-w-36 text-muted-secondary-foreground">
77-
{m.sentAtDisplay}
78-
</span>
79-
<span>{m.content}</span>
160+
{hasNextPage ? (
161+
<Link to={buildPageUrl(currentPage + 1)} preventScrollReset>
162+
Next
163+
<Icon name="arrow-right" size="sm" />
164+
</Link>
165+
) : (
166+
<span>
167+
Next
168+
<Icon name="arrow-right" size="sm" />
169+
</span>
170+
)}
171+
</Button>
172+
</div>
173+
) : null}
174+
</div>
175+
)
176+
}
177+
178+
export default function RecipientRoute() {
179+
const data = useLoaderData<typeof loader>()
180+
const isPending = useDelayedIsPending({
181+
formMethod: 'GET',
182+
})
183+
184+
return (
185+
<div className="flex flex-col gap-6">
186+
<div className="flex flex-col gap-4">
187+
<SearchBar status="idle" autoSubmit />
188+
<Pagination
189+
currentPage={data.pagination.currentPage}
190+
totalPages={data.pagination.totalPages}
191+
totalMessages={data.pagination.totalMessages}
192+
searchQuery={data.searchQuery}
193+
/>
194+
</div>
195+
196+
<ul className={cn('flex flex-col gap-2', { 'opacity-50': isPending })}>
197+
{data.pastMessages.length === 0 ? (
198+
<li className="py-8 text-center text-muted-foreground">
199+
{data.searchQuery
200+
? 'No messages match your search.'
201+
: 'No past messages yet.'}
80202
</li>
81-
))}
203+
) : (
204+
data.pastMessages.map((m) => (
205+
<li
206+
key={m.id}
207+
className="flex flex-col justify-start gap-2 align-top lg:flex-row"
208+
>
209+
<span className="min-w-36 text-muted-secondary-foreground">
210+
{m.sentAtDisplay}
211+
</span>
212+
<span>{m.content}</span>
213+
</li>
214+
))
215+
)}
82216
</ul>
217+
218+
{data.pastMessages.length > 0 && data.pagination.totalPages > 1 ? (
219+
<Pagination
220+
currentPage={data.pagination.currentPage}
221+
totalPages={data.pagination.totalPages}
222+
totalMessages={data.pagination.totalMessages}
223+
searchQuery={data.searchQuery}
224+
/>
225+
) : null}
83226
</div>
84227
)
85228
}

0 commit comments

Comments
 (0)