Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-byline-picker-search.md
Original file line number Diff line number Diff line change
@@ -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.
101 changes: 75 additions & 26 deletions packages/admin/src/components/ContentEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
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";
Expand All @@ -37,8 +38,9 @@
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";
Expand Down Expand Up @@ -997,6 +999,7 @@
<BylineCreditsEditor
credits={activeBylines}
bylines={availableBylines ?? []}
selectedBylineDetails={item?.bylines?.map((entry) => entry.byline)}
bylinesLoaded={availableBylinesLoaded}
onChange={handleBylinesChange}
onQuickCreate={onQuickCreateByline}
Expand Down Expand Up @@ -1922,6 +1925,12 @@
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<BylineSummary>;
onQuickEdit?: (
Expand All @@ -1944,6 +1953,7 @@
function BylineCreditsEditor({
credits,
bylines,
selectedBylineDetails,
onChange,
onQuickCreate,
onQuickEdit,
Expand All @@ -1952,7 +1962,8 @@
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<string | null>(null);
Expand All @@ -1963,9 +1974,37 @@
const [editError, setEditError] = React.useState<string | null>(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({

Check failure on line 1982 in packages/admin/src/components/ContentEditor.tsx

View workflow job for this annotation

GitHub Actions / Browser Tests

[chromium] tests/components/ContentEditor.test.tsx > ContentEditor > byline picker search (#1217) > renders a credited byline that is not in the initial picker list

Error: No QueryClient set, use QueryClientProvider to set one ❯ useQueryClient ../../node_modules/.pnpm/@TanStack+react-query@5.90.21_react@19.2.4/node_modules/@tanstack/react-query/src/QueryClientProvider.tsx:18:10 ❯ useBaseQuery ../../node_modules/.pnpm/@TanStack+react-query@5.90.21_react@19.2.4/node_modules/@tanstack/react-query/src/useBaseQuery.ts:54:17 ❯ useQuery ../../node_modules/.pnpm/@TanStack+react-query@5.90.21_react@19.2.4/node_modules/@tanstack/react-query/src/useQuery.ts:51:9 ❯ useQuery src/components/ContentEditor.tsx:1982:23 ❯ Object.react_stack_bottom_frame ../../node_modules/.pnpm/react-dom@19.2.4_react@19.2.4/node_modules/react-dom/cjs/react-dom-client.development.js:25904:19 ❯ renderWithHooks ../../node_modules/.pnpm/react-dom@19.2.4_react@19.2.4/node_modules/react-dom/cjs/react-dom-client.development.js:7662:21 ❯ updateFunctionComponent ../../node_modules/.pnpm/react-dom@19.2.4_react@19.2.4/node_modules/react-dom/cjs/react-dom-client.development.js:10166:18 ❯ beginWork ../../node_modules/.pnpm/react-dom@19.2.4_react@19.2.4/node_modules/react-dom/cjs/react-dom-client.development.js:11778:17 ❯ runWithFiberInDEV ../../node_modules/.pnpm/react-dom@19.2.4_react@19.2.4/node_modules/react-dom/cjs/react-dom-client.development.js:871:29 ❯ performUnitOfWork ../../node_modules/.pnpm/react-dom@19.2.4_react@19.2.4/node_modules/react-dom/cjs/react-dom-client.development.js:17641:21

Check failure on line 1982 in packages/admin/src/components/ContentEditor.tsx

View workflow job for this annotation

GitHub Actions / Browser Tests

[chromium] tests/components/ContentEditor.test.tsx > ContentEditor > byline picker search (#1217) > searches the server and adds a byline from outside the initial list

Error: No QueryClient set, use QueryClientProvider to set one ❯ useQueryClient ../../node_modules/.pnpm/@TanStack+react-query@5.90.21_react@19.2.4/node_modules/@tanstack/react-query/src/QueryClientProvider.tsx:18:10 ❯ useBaseQuery ../../node_modules/.pnpm/@TanStack+react-query@5.90.21_react@19.2.4/node_modules/@tanstack/react-query/src/useBaseQuery.ts:54:17 ❯ useQuery ../../node_modules/.pnpm/@TanStack+react-query@5.90.21_react@19.2.4/node_modules/@tanstack/react-query/src/useQuery.ts:51:9 ❯ useQuery src/components/ContentEditor.tsx:1982:23 ❯ Object.react_stack_bottom_frame ../../node_modules/.pnpm/react-dom@19.2.4_react@19.2.4/node_modules/react-dom/cjs/react-dom-client.development.js:25904:19 ❯ renderWithHooks ../../node_modules/.pnpm/react-dom@19.2.4_react@19.2.4/node_modules/react-dom/cjs/react-dom-client.development.js:7662:21 ❯ updateFunctionComponent ../../node_modules/.pnpm/react-dom@19.2.4_react@19.2.4/node_modules/react-dom/cjs/react-dom-client.development.js:10166:18 ❯ beginWork ../../node_modules/.pnpm/react-dom@19.2.4_react@19.2.4/node_modules/react-dom/cjs/react-dom-client.development.js:11778:17 ❯ runWithFiberInDEV ../../node_modules/.pnpm/react-dom@19.2.4_react@19.2.4/node_modules/react-dom/cjs/react-dom-client.development.js:871:29 ❯ performUnitOfWork ../../node_modules/.pnpm/react-dom@19.2.4_react@19.2.4/node_modules/react-dom/cjs/react-dom-client.development.js:17641:21

Check failure on line 1982 in packages/admin/src/components/ContentEditor.tsx

View workflow job for this annotation

GitHub Actions / Browser Tests

[chromium] tests/components/ContentEditor.test.tsx > ContentEditor > saving > shows the locale empty-state CTA once the picker query resolves empty

Error: No QueryClient set, use QueryClientProvider to set one ❯ useQueryClient ../../node_modules/.pnpm/@TanStack+react-query@5.90.21_react@19.2.4/node_modules/@tanstack/react-query/src/QueryClientProvider.tsx:18:10 ❯ useBaseQuery ../../node_modules/.pnpm/@TanStack+react-query@5.90.21_react@19.2.4/node_modules/@tanstack/react-query/src/useBaseQuery.ts:54:17 ❯ useQuery ../../node_modules/.pnpm/@TanStack+react-query@5.90.21_react@19.2.4/node_modules/@tanstack/react-query/src/useQuery.ts:51:9 ❯ useQuery src/components/ContentEditor.tsx:1982:23 ❯ Object.react_stack_bottom_frame ../../node_modules/.pnpm/react-dom@19.2.4_react@19.2.4/node_modules/react-dom/cjs/react-dom-client.development.js:25904:19 ❯ renderWithHooks ../../node_modules/.pnpm/react-dom@19.2.4_react@19.2.4/node_modules/react-dom/cjs/react-dom-client.development.js:7662:21 ❯ updateFunctionComponent ../../node_modules/.pnpm/react-dom@19.2.4_react@19.2.4/node_modules/react-dom/cjs/react-dom-client.development.js:10166:18 ❯ beginWork ../../node_modules/.pnpm/react-dom@19.2.4_react@19.2.4/node_modules/react-dom/cjs/react-dom-client.development.js:11778:17 ❯ runWithFiberInDEV ../../node_modules/.pnpm/react-dom@19.2.4_react@19.2.4/node_modules/react-dom/cjs/react-dom-client.development.js:871:29 ❯ performUnitOfWork ../../node_modules/.pnpm/react-dom@19.2.4_react@19.2.4/node_modules/react-dom/cjs/react-dom-client.development.js:17641:21

Check failure on line 1982 in packages/admin/src/components/ContentEditor.tsx

View workflow job for this annotation

GitHub Actions / Browser Tests

[chromium] tests/components/ContentEditor.test.tsx > ContentEditor > saving > suppresses the locale empty-state CTA until the picker query resolves

Error: No QueryClient set, use QueryClientProvider to set one ❯ useQueryClient ../../node_modules/.pnpm/@TanStack+react-query@5.90.21_react@19.2.4/node_modules/@tanstack/react-query/src/QueryClientProvider.tsx:18:10 ❯ useBaseQuery ../../node_modules/.pnpm/@TanStack+react-query@5.90.21_react@19.2.4/node_modules/@tanstack/react-query/src/useBaseQuery.ts:54:17 ❯ useQuery ../../node_modules/.pnpm/@TanStack+react-query@5.90.21_react@19.2.4/node_modules/@tanstack/react-query/src/useQuery.ts:51:9 ❯ useQuery src/components/ContentEditor.tsx:1982:23 ❯ Object.react_stack_bottom_frame ../../node_modules/.pnpm/react-dom@19.2.4_react@19.2.4/node_modules/react-dom/cjs/react-dom-client.development.js:25904:19 ❯ renderWithHooks ../../node_modules/.pnpm/react-dom@19.2.4_react@19.2.4/node_modules/react-dom/cjs/react-dom-client.development.js:7662:21 ❯ updateFunctionComponent ../../node_modules/.pnpm/react-dom@19.2.4_react@19.2.4/node_modules/react-dom/cjs/react-dom-client.development.js:10166:18 ❯ beginWork ../../node_modules/.pnpm/react-dom@19.2.4_react@19.2.4/node_modules/react-dom/cjs/react-dom-client.development.js:11778:17 ❯ runWithFiberInDEV ../../node_modules/.pnpm/react-dom@19.2.4_react@19.2.4/node_modules/react-dom/cjs/react-dom-client.development.js:871:29 ❯ performUnitOfWork ../../node_modules/.pnpm/react-dom@19.2.4_react@19.2.4/node_modules/react-dom/cjs/react-dom-client.development.js:17641:21
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<string, BylineSummary>());
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;
Expand Down Expand Up @@ -2021,29 +2060,39 @@
</RouterLinkButton>
</div>
)}
<div className="flex gap-2">
<Select
value={selectedBylineId}
onValueChange={(v) => setSelectedBylineId(v ?? "")}
items={{
"": t`Select byline...`,
...Object.fromEntries(availableToAdd.map((b) => [b.id, b.displayName])),
}}
aria-label={t`Select byline`}
className="w-full"
<div className="space-y-2">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t`Search bylines to add...`}
aria-label={t`Search bylines`}
/>
<Button
type="button"
variant="secondary"
onClick={() => {
if (!selectedBylineId) return;
onChange([...credits, { bylineId: selectedBylineId, roleLabel: null }]);
setSelectedBylineId("");
}}
disabled={!selectedBylineId}
>
{t`Add`}
</Button>
{searchEnabled && searchResults.isLoading ? (
<p className="text-sm text-kumo-subtle">{t`Searching...`}</p>
) : availableToAdd.length > 0 ? (
<ul className="max-h-48 divide-y overflow-y-auto rounded border">
{availableToAdd.map((b) => (
<li key={b.id}>
<button
type="button"
className="flex w-full items-center justify-between gap-2 p-2 text-start hover:bg-kumo-tint"
onClick={() => addByline(b.id)}
>
<span className="min-w-0">
<span className="block truncate text-sm font-medium">{b.displayName}</span>
<span className="block truncate text-xs text-kumo-subtle">{b.slug}</span>
</span>
<span className="text-xs text-kumo-subtle">{t`Add`}</span>
</button>
</li>
))}
</ul>
) : searchEnabled ? (
<p className="text-sm text-kumo-subtle">{t`No matching bylines.`}</p>
) : null}
{hasMoreResults && (
<p className="text-xs text-kumo-subtle">{t`Keep typing to narrow down more bylines.`}</p>
)}
</div>

{credits.length > 0 ? (
Expand Down
82 changes: 81 additions & 1 deletion packages/admin/tests/components/ContentEditor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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> = {}): 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.
Expand Down Expand Up @@ -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 })),
};
});

Expand Down Expand Up @@ -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();
});
});
});
Loading