From 8681f73f8dd3254617118231f2d5a7a1e0bdebef Mon Sep 17 00:00:00 2001 From: scottbuscemi Date: Fri, 29 May 2026 15:36:40 -0700 Subject: [PATCH] fix(admin): add search to the byline picker and remove the 100-byline cap The content-entity byline picker was a plain Select over the first 100 bylines with no search, so bylines past the first page were unreachable and a credited byline outside that page failed to render at all. Replace it with a debounced server-side search (fetchBylines already supports search + cursor) and resolve credited bylines from the saved entry so they always render. Closes #1217. --- .changeset/fix-byline-picker-search.md | 5 + .../admin/src/components/ContentEditor.tsx | 101 +++++++++++++----- .../tests/components/ContentEditor.test.tsx | 82 +++++++++++++- 3 files changed, 161 insertions(+), 27 deletions(-) create mode 100644 .changeset/fix-byline-picker-search.md diff --git a/.changeset/fix-byline-picker-search.md b/.changeset/fix-byline-picker-search.md new file mode 100644 index 000000000..527d1a7f3 --- /dev/null +++ b/.changeset/fix-byline-picker-search.md @@ -0,0 +1,5 @@ +--- +"@emdash-cms/admin": patch +--- + +Add search to the byline picker on content entities and remove the effective 100-byline cap. The picker now performs a debounced server-side search via the bylines API instead of rendering a fixed dropdown of the first 100 results, so bylines beyond the first page can be found and credited. Credited bylines from the saved entry are also resolved from the entry itself, so a credit that falls outside the initial list still renders its name instead of disappearing. diff --git a/packages/admin/src/components/ContentEditor.tsx b/packages/admin/src/components/ContentEditor.tsx index 523ad9e36..831fdb17b 100644 --- a/packages/admin/src/components/ContentEditor.tsx +++ b/packages/admin/src/components/ContentEditor.tsx @@ -25,6 +25,7 @@ import { ArrowSquareOut, ImageBroken, } from "@phosphor-icons/react"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; import type { Editor } from "@tiptap/react"; import * as React from "react"; @@ -37,8 +38,9 @@ import type { UserListItem, TranslationSummary, } from "../lib/api"; -import { getPreviewUrl, getDraftStatus } from "../lib/api"; +import { fetchBylines, getPreviewUrl, getDraftStatus } from "../lib/api"; import { fromDatetimeLocalInputValue, toDatetimeLocalInputValue } from "../lib/datetime-local.js"; +import { useDebouncedValue } from "../lib/hooks.js"; import { formatFileSize, getFileIcon } from "../lib/media-utils"; import { usePluginAdmins } from "../lib/plugin-context.js"; import { contentUrl, isSafeUrl } from "../lib/url.js"; @@ -997,6 +999,7 @@ export function ContentEditor({ entry.byline)} bylinesLoaded={availableBylinesLoaded} onChange={handleBylinesChange} onQuickCreate={onQuickCreateByline} @@ -1922,6 +1925,12 @@ interface AuthorSelectorProps { interface BylineCreditsEditorProps { credits: BylineCreditInput[]; bylines: BylineSummary[]; + /** + * Full byline details for the entry's already-selected credits. Seeded from + * the saved entry so credited bylines always render their name/slug even when + * they fall outside the initial (unsearched) picker list. + */ + selectedBylineDetails?: BylineSummary[]; onChange: (bylines: BylineCreditInput[]) => void; onQuickCreate?: (input: { slug: string; displayName: string }) => Promise; onQuickEdit?: ( @@ -1944,6 +1953,7 @@ interface BylineCreditsEditorProps { function BylineCreditsEditor({ credits, bylines, + selectedBylineDetails, onChange, onQuickCreate, onQuickEdit, @@ -1952,7 +1962,8 @@ function BylineCreditsEditor({ bylinesLoaded = true, }: BylineCreditsEditorProps) { const { t } = useLingui(); - const [selectedBylineId, setSelectedBylineId] = React.useState(""); + const [search, setSearch] = React.useState(""); + const debouncedSearch = useDebouncedValue(search, 300); const [quickName, setQuickName] = React.useState(""); const [quickSlug, setQuickSlug] = React.useState(""); const [quickError, setQuickError] = React.useState(null); @@ -1963,9 +1974,37 @@ function BylineCreditsEditor({ const [editError, setEditError] = React.useState(null); const [isEditing, setIsEditing] = React.useState(false); - const bylineMap = React.useMemo(() => new Map(bylines.map((b) => [b.id, b])), [bylines]); + // Server-side search so the picker isn't limited to the first page of + // bylines (previously capped at 100 with no way to find the rest). When the + // search box is empty we fall back to the parent-provided initial list. + const trimmedSearch = debouncedSearch.trim(); + const searchEnabled = trimmedSearch.length > 0; + const searchResults = useQuery({ + queryKey: ["bylines", "credit-picker", entryLocale ?? null, trimmedSearch], + queryFn: () => + fetchBylines({ search: trimmedSearch, locale: entryLocale ?? undefined, limit: 20 }), + enabled: searchEnabled, + placeholderData: keepPreviousData, + }); + + const resultPool = searchEnabled ? (searchResults.data?.items ?? []) : bylines; + const hasMoreResults = searchEnabled ? !!searchResults.data?.nextCursor : bylines.length >= 100; - const availableToAdd = bylines.filter((b) => !credits.some((c) => c.bylineId === b.id)); + // Resolve credited bylines to their full details for display. A ref-backed + // cache persists across searches so selected rows keep rendering even after + // the search results that introduced them change. + const knownBylines = React.useRef(new Map()); + for (const b of selectedBylineDetails ?? []) knownBylines.current.set(b.id, b); + for (const b of bylines) knownBylines.current.set(b.id, b); + for (const b of searchResults.data?.items ?? []) knownBylines.current.set(b.id, b); + const bylineMap = knownBylines.current; + + const availableToAdd = resultPool.filter((b) => !credits.some((c) => c.bylineId === b.id)); + + const addByline = (bylineId: string) => { + if (credits.some((c) => c.bylineId === bylineId)) return; + onChange([...credits, { bylineId, roleLabel: null }]); + }; const move = (index: number, direction: -1 | 1) => { const target = index + direction; @@ -2021,29 +2060,39 @@ function BylineCreditsEditor({ )} -
- setSearch(e.target.value)} + placeholder={t`Search bylines to add...`} + aria-label={t`Search bylines`} /> - + {searchEnabled && searchResults.isLoading ? ( +

{t`Searching...`}

+ ) : availableToAdd.length > 0 ? ( +
    + {availableToAdd.map((b) => ( +
  • + +
  • + ))} +
+ ) : searchEnabled ? ( +

{t`No matching bylines.`}

+ ) : null} + {hasMoreResults && ( +

{t`Keep typing to narrow down more bylines.`}

+ )}
{credits.length > 0 ? ( diff --git a/packages/admin/tests/components/ContentEditor.test.tsx b/packages/admin/tests/components/ContentEditor.test.tsx index fc349f899..bdf334ca7 100644 --- a/packages/admin/tests/components/ContentEditor.test.tsx +++ b/packages/admin/tests/components/ContentEditor.test.tsx @@ -6,9 +6,27 @@ import { type FieldDescriptor, type ContentEditorProps, } from "../../src/components/ContentEditor"; -import type { ContentItem } from "../../src/lib/api"; +import { fetchBylines } from "../../src/lib/api"; +import type { BylineSummary, ContentItem } from "../../src/lib/api"; import { render } from "../utils/render.tsx"; +function makeByline(overrides: Partial = {}): BylineSummary { + return { + id: "byline-1", + slug: "jane-smith", + displayName: "Jane Smith", + bio: null, + avatarMediaId: null, + websiteUrl: null, + userId: null, + isGuest: false, + createdAt: "2025-01-15T10:30:00Z", + updatedAt: "2025-01-15T10:30:00Z", + locale: "en", + ...overrides, + }; +} + // Mock child components that have complex dependencies. // The mock simulates the real editor's behaviour of freezing initial content on mount: // it captures `value` once via useState initializer and never re-reads it. @@ -85,6 +103,7 @@ vi.mock("../../src/lib/api", async () => { return { ...actual, getPreviewUrl: vi.fn().mockResolvedValue({ url: "https://example.com/preview" }), + fetchBylines: vi.fn(async () => ({ items: [], nextCursor: null })), }; }); @@ -1143,4 +1162,65 @@ describe("ContentEditor", () => { expect(nullCallIndex).toBeLessThan(onEditorReadyCalls.length - 1); }); }); + + // --------------------------------------------------------------------------- + // Bug #1217: the byline picker was a plain Select over the first 100 bylines + // with no search, so bylines beyond the initial page were unreachable, and a + // credited byline outside that page failed to render at all. The picker now + // searches the server and resolves credited bylines from the saved entry. + // --------------------------------------------------------------------------- + describe("byline picker search (#1217)", () => { + it("searches the server and adds a byline from outside the initial list", async () => { + vi.mocked(fetchBylines).mockResolvedValue({ + items: [makeByline({ id: "b-far", slug: "zoe-far", displayName: "Zoe Far" })], + nextCursor: null, + }); + + const item = makeItem({ data: { title: "Hello", body: "" } }); + const screen = await renderEditor({ + isNew: false, + item, + currentUser: { id: "u-1", role: 50 }, + // Empty initial list: the only way to reach "Zoe Far" is via search. + availableBylines: [], + availableBylinesLoaded: true, + }); + + const searchInput = screen.getByLabelText("Search bylines"); + await searchInput.fill("Zoe"); + + // The debounced server search surfaces the result. + await expect.element(screen.getByText("Zoe Far")).toBeInTheDocument(); + await vi.waitFor(() => { + expect(vi.mocked(fetchBylines)).toHaveBeenCalledWith( + expect.objectContaining({ search: "Zoe" }), + ); + }); + + // Clicking the result credits the byline; it now renders with its + // Role label editor and leaves the results list. + await screen.getByRole("button", { name: /Zoe Far/ }).click(); + await expect.element(screen.getByLabelText("Role label")).toBeInTheDocument(); + }); + + it("renders a credited byline that is not in the initial picker list", async () => { + const credited = makeByline({ id: "b-100plus", slug: "ada", displayName: "Ada Lovelace" }); + const item = makeItem({ + data: { title: "Hello", body: "" }, + bylines: [{ byline: credited, sortOrder: 0, roleLabel: "Author" }], + }); + + const screen = await renderEditor({ + isNew: false, + item, + currentUser: { id: "u-1", role: 50 }, + // Initial list does NOT include the credited byline (it would be + // past the old 100-row cap). It must still render from the entry. + availableBylines: [], + availableBylinesLoaded: true, + }); + + await expect.element(screen.getByText("Ada Lovelace")).toBeInTheDocument(); + }); + }); });