Skip to content

Commit b31ab83

Browse files
authored
Merge branch 'emdash-cms:main' into fix/site-url-reverse-proxy-origin
2 parents 8ce89a2 + 3b6b75b commit b31ab83

11 files changed

Lines changed: 105 additions & 11 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@emdash-cms/admin": patch
3+
---
4+
5+
Fix content list not fetching beyond the first API page when navigating to the last client-side page

.changeset/fix-cli-login.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"emdash": patch
3+
---
4+
5+
Fix CLI login against remote Cloudflare-deployed instances by unwrapping API response envelope and adding admin scope

packages/admin/src/components/ContentEditor.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -549,7 +549,7 @@ export function ContentEditor({
549549
{!isNew && (
550550
<>
551551
{supportsDrafts && hasPendingChanges && onDiscardDraft && (
552-
<Dialog.Root disablePointerDismissal>
552+
<Dialog.Root>
553553
<Dialog.Trigger
554554
render={(p) => (
555555
<Button {...p} type="button" variant="outline" size="sm" icon={<X />}>
@@ -1567,7 +1567,14 @@ function BylineCreditsEditor({
15671567
<div className="mt-6 flex justify-end gap-2">
15681568
<Dialog.Close
15691569
render={(p) => (
1570-
<Button {...p} variant="secondary" onClick={resetQuickCreate}>
1570+
<Button
1571+
{...p}
1572+
variant="secondary"
1573+
onClick={(e) => {
1574+
resetQuickCreate();
1575+
p.onClick?.(e);
1576+
}}
1577+
>
15711578
Cancel
15721579
</Button>
15731580
)}

packages/admin/src/components/ContentList.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,15 @@ export function ContentList({
102102
const totalPages = Math.max(1, Math.ceil(filteredItems.length / PAGE_SIZE));
103103
const paginatedItems = filteredItems.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
104104

105+
// Auto-fetch next API page when user reaches the last client-side page.
106+
// skip when a search query is active
107+
// filteredItems shrinking would otherwise collapse totalPages to 1 and trigger a spurious fetch
108+
React.useEffect(() => {
109+
if (page >= totalPages - 1 && hasMore && onLoadMore && !searchQuery) {
110+
onLoadMore();
111+
}
112+
}, [page, totalPages, hasMore, onLoadMore, searchQuery]);
113+
105114
return (
106115
<div className="space-y-4">
107116
{/* Header */}
@@ -235,7 +244,8 @@ export function ContentList({
235244
{totalPages > 1 && (
236245
<div className="flex items-center justify-between">
237246
<span className="text-sm text-kumo-subtle">
238-
{filteredItems.length} {filteredItems.length === 1 ? "item" : "items"}
247+
{filteredItems.length}
248+
{hasMore && !searchQuery ? "+" : ""} {filteredItems.length === 1 ? "item" : "items"}
239249
{searchQuery && ` matching "${searchQuery}"`}
240250
</span>
241251
<div className="flex items-center gap-2">

packages/admin/src/components/ContentPickerModal.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ export function ContentPickerModal({ open, onOpenChange, onSelect }: ContentPick
109109

110110
return (
111111
<Dialog.Root open={open} onOpenChange={onOpenChange}>
112-
<Dialog className="p-6 w-2xl h-[80vh] flex flex-col" size="lg">
112+
<Dialog className="p-6 max-w-2xl h-[80vh] flex flex-col" size="lg">
113113
<div className="flex items-start justify-between gap-4 mb-4">
114114
<Dialog.Title className="text-lg font-semibold leading-none tracking-tight">
115115
Select Content

packages/admin/src/components/MediaPickerModal.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -336,7 +336,7 @@ export function MediaPickerModal({
336336

337337
return (
338338
<Dialog.Root open={open} onOpenChange={handleClose}>
339-
<Dialog className="p-6 max-w-4xl max-h-[80vh] flex flex-col" size="xl">
339+
<Dialog className="p-6 max-w-4xl max-h-[80vh] flex flex-col overflow-hidden" size="xl">
340340
<div className="flex items-start justify-between gap-4 mb-4">
341341
<Dialog.Title className="text-lg font-semibold leading-none tracking-tight">
342342
{title}
@@ -476,7 +476,7 @@ export function MediaPickerModal({
476476
/>
477477

478478
{/* Media Grid */}
479-
<div className="flex-1 overflow-y-auto min-h-[300px]">
479+
<div className="flex-1 overflow-y-auto min-h-0">
480480
{isLoading ? (
481481
<div className="flex items-center justify-center h-full">
482482
<Loader />

packages/admin/src/components/WelcomeModal.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ export function WelcomeModal({ open, onClose, userName, userRole }: WelcomeModal
6767

6868
return (
6969
<Dialog.Root open={open} onOpenChange={(isOpen: boolean) => !isOpen && handleGetStarted()}>
70-
<Dialog className="p-6 sm:max-w-md" size="lg">
70+
<Dialog className="p-6 sm:max-w-md">
7171
<div className="flex items-start justify-between gap-4">
7272
<div className="flex-1" />
7373
<Dialog.Close

packages/admin/src/router.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -350,7 +350,7 @@ function ContentListPage() {
350350
isLoading={isLoading || isFetchingNextPage}
351351
isTrashedLoading={isTrashedLoading}
352352
hasMore={!!hasNextPage}
353-
onLoadMore={() => void fetchNextPage()}
353+
onLoadMore={React.useCallback(() => void fetchNextPage(), [fetchNextPage])}
354354
trashedCount={trashedData?.items?.length || 0}
355355
onDelete={(id) => deleteMutation.mutate(id)}
356356
onRestore={(id) => restoreMutation.mutate(id)}

packages/admin/src/styles.css

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,18 @@ body {
199199
margin-top: 0 !important;
200200
}
201201

202+
/**
203+
* Prevent dialogs from overflowing the viewport on small screens.
204+
* Kumo's size variants set min-width (e.g. min-w-[32rem] for "lg")
205+
* which overrides max-width in CSS. We need to cap min-width too.
206+
*/
207+
@media (max-width: 639px) {
208+
[role="dialog"] {
209+
min-width: 0 !important;
210+
max-width: calc(100vw - 2rem);
211+
}
212+
}
213+
202214
/**
203215
* Isolate the admin root's stacking context.
204216
*/

packages/admin/tests/components/ContentList.test.tsx

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { ContentList } from "../../src/components/ContentList";
66
import type { ContentItem, TrashedContentItem } from "../../src/lib/api";
77

88
const NO_RESULTS_PATTERN = /No results for/;
9+
const HAS_MORE_ITEMS_PATTERN = /21\+ items/;
910

1011
// ---------------------------------------------------------------------------
1112
// Constants
@@ -304,16 +305,67 @@ describe("ContentList", () => {
304305
expect(screen.getByRole("button", { name: "Load More" }).query()).toBeNull();
305306
});
306307

308+
it("auto-fetches when user navigates to the last client-side page", async () => {
309+
const onLoadMore = vi.fn();
310+
// 21 items = 2 pages of 20; user starts on page 0 (not the last page)
311+
const items = Array.from({ length: 21 }, (_, i) => makeItem({ id: `item_${i}` }));
312+
const screen = await render(
313+
<ContentList {...defaultProps} items={items} hasMore={true} onLoadMore={onLoadMore} />,
314+
);
315+
316+
// On mount, page 0 is not the last page — no fetch yet
317+
expect(onLoadMore).not.toHaveBeenCalled();
318+
319+
// Navigate to page 2 (the last page)
320+
await screen.getByRole("button", { name: "Next page" }).click();
321+
322+
expect(onLoadMore).toHaveBeenCalledOnce();
323+
});
324+
325+
it("does not auto-fetch when a search query is active", async () => {
326+
const onLoadMore = vi.fn();
327+
// 21 items so pagination exists, but search will collapse to 1 result / 1 page
328+
const items = [
329+
...Array.from({ length: 20 }, (_, i) =>
330+
makeItem({ id: `item_${i}`, data: { title: `Post ${i}` } }),
331+
),
332+
makeItem({ id: "unique", data: { title: "Unique Title" } }),
333+
];
334+
const screen = await render(
335+
<ContentList {...defaultProps} items={items} hasMore={true} onLoadMore={onLoadMore} />,
336+
);
337+
338+
// No fetch on mount (page 0 is not the last page with 21 items)
339+
expect(onLoadMore).not.toHaveBeenCalled();
340+
341+
// Search collapses results to 1 item — totalPages becomes 1, but should NOT fetch
342+
await screen.getByRole("searchbox").fill("Unique Title");
343+
344+
expect(onLoadMore).not.toHaveBeenCalled();
345+
});
346+
347+
it("shows '+' suffix on item count when hasMore is true and no search is active", async () => {
348+
const items = Array.from({ length: 21 }, (_, i) => makeItem({ id: `item_${i}` }));
349+
const screen = await render(<ContentList {...defaultProps} items={items} hasMore={true} />);
350+
351+
await expect.element(screen.getByText(HAS_MORE_ITEMS_PATTERN)).toBeInTheDocument();
352+
});
353+
307354
it("calls onLoadMore when Load More is clicked", async () => {
308355
const onLoadMore = vi.fn();
309356
const items = [makeItem()];
310357
const screen = await render(
311358
<ContentList {...defaultProps} items={items} hasMore={true} onLoadMore={onLoadMore} />,
312359
);
313360

361+
// With 1 item and hasMore=true, the auto-fetch effect fires on mount
362+
// because page 0 is already the last client-side page.
363+
// The button click adds a second call on top of that.
364+
expect(onLoadMore).toHaveBeenCalledOnce();
365+
314366
await screen.getByRole("button", { name: "Load More" }).click();
315367

316-
expect(onLoadMore).toHaveBeenCalledOnce();
368+
expect(onLoadMore).toHaveBeenCalledTimes(2);
317369
});
318370
});
319371

0 commit comments

Comments
 (0)