diff --git a/.changeset/reference-field-admin.md b/.changeset/reference-field-admin.md new file mode 100644 index 0000000000..3bd69a246d --- /dev/null +++ b/.changeset/reference-field-admin.md @@ -0,0 +1,5 @@ +--- +"@emdash-cms/admin": minor +--- + +Reference fields are now a real, working field type. Previously "reference" was just a plain text box with nowhere to point; now you get a proper relationship picker. Configure it in the schema editor (choose the target collection and single vs. multiple), then search for, pick, and reorder linked entries right in the entry editor — all saved together with the entry in one request. Referenced entries show a read-only "Referenced by" panel so you can see what points at them, and you can jump straight to any linked entry from the picker or the backlinks. diff --git a/.changeset/reference-field-core.md b/.changeset/reference-field-core.md new file mode 100644 index 0000000000..30cf6af8f3 --- /dev/null +++ b/.changeset/reference-field-core.md @@ -0,0 +1,5 @@ +--- +"emdash": minor +--- + +Reference fields now store real relationships between entries instead of an inert string. Selections are saved as first-class content-reference edges, written atomically with the entry in a single transaction, and hydrated on read alongside SEO and bylines — each resolved reference carries a readable display title (from the referenced entry's `title`, then `name`) so pickers and backlinks show a label instead of a slug. Reference fields are storage-less: they no longer add a column to a collection's table, and "Referenced by" backlinks come for free from the edge data. Seed files using a reference field apply its `$ref:` value as an edge (seed shape unchanged). Upgrade note: existing reference columns keep their data but are no longer written to. diff --git a/demos/simple/emdash-env.d.ts b/demos/simple/emdash-env.d.ts index 4c380e8e87..3fa12a1974 100644 --- a/demos/simple/emdash-env.d.ts +++ b/demos/simple/emdash-env.d.ts @@ -26,6 +26,7 @@ export interface Post { featured_image?: { id: string; src?: string; alt?: string; width?: number; height?: number; provider?: string; previewUrl?: string; meta?: Record }; content?: PortableTextBlock[]; excerpt?: string; + relevant_posts?: string; createdAt: Date; updatedAt: Date; publishedAt: Date | null; diff --git a/packages/admin/src/components/ContentEditor.tsx b/packages/admin/src/components/ContentEditor.tsx index f2025f4b6e..6050dc5206 100644 --- a/packages/admin/src/components/ContentEditor.tsx +++ b/packages/admin/src/components/ContentEditor.tsx @@ -6,6 +6,7 @@ import { InputArea, Label, LinkButton, + Loader, Select, Sidebar, Switch, @@ -19,7 +20,12 @@ import { X, ArrowsInSimple, ArrowsOutSimple, + CaretUp, + CaretDown, + Plus, + Trash, } from "@phosphor-icons/react"; +import { Link } from "@tanstack/react-router"; import type { Editor } from "@tiptap/react"; import * as React from "react"; @@ -31,7 +37,7 @@ import type { UserListItem, TranslationSummary, } from "../lib/api"; -import { getPreviewUrl, getDraftStatus } from "../lib/api"; +import { fetchReferenceChildren, getPreviewUrl, getDraftStatus } from "../lib/api"; import { fromDatetimeLocalInputValue, toDatetimeLocalInputValue } from "../lib/datetime-local.js"; import { formatFileSize, getFileIcon } from "../lib/media-utils"; import { usePluginAdmins } from "../lib/plugin-context.js"; @@ -41,6 +47,7 @@ import { getLocaleDir } from "../locales/config.js"; import { useLocale } from "../locales/useLocale.js"; import { ArrowPrev } from "./ArrowIcons.js"; import { BlockKitFieldWidget } from "./BlockKitFieldWidget.js"; +import { ContentPickerModal, type PickedContentEntry } from "./ContentPickerModal.js"; import { ContentSettingsPanel, DiscardDraftDialog, @@ -93,6 +100,78 @@ export interface FieldDescriptor { validation?: Record; } +/** + * A single staged reference row in the editor. `title` comes from the picker + * for freshly added rows and from the server's resolved refs for hydrated rows; + * it falls back to slug/id for display when the entry has no title/name. + */ +export type ReferenceEntryRow = { + id: string; + slug: string | null; + title?: string; + locale?: string | null; +}; + +type ReferenceGroupState = { + /** The last-saved id order — the diff baseline for dirty tracking. */ + baseline: ReferenceEntryRow[]; + /** The user's current staged selection. */ + current: ReferenceEntryRow[]; + /** Set while more pages of the hydrated set remain to be loaded. */ + nextCursor?: string; + loading: boolean; + /** Set when a page load failed. Stops auto-paging so a failing request never + * retries in a tight loop; cleared when the state is reseeded for a new entry. */ + error?: boolean; +}; + +/** Seed reference state from a hydrated item (first page per relation group). */ +function seedReferenceState(item?: ContentItem | null): Record { + const out: Record = {}; + const refs = item?.references; + if (!refs) return out; + for (const [group, page] of Object.entries(refs)) { + const rows: ReferenceEntryRow[] = page.children.map((c) => ({ + id: c.id, + slug: c.slug, + title: c.title ?? undefined, + locale: c.locale, + })); + out[group] = { baseline: rows, current: rows, nextCursor: page.nextCursor, loading: false }; + } + return out; +} + +/** Order-sensitive id comparison of two reference-row lists. */ +function sameReferenceIds(a: ReferenceEntryRow[], b: ReferenceEntryRow[]): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i]?.id !== b[i]?.id) return false; + } + return true; +} + +/** + * Build the `references` save payload from staged state. Only groups whose id + * list has changed are included: the server replaces edges for every group it + * receives, so sending an untouched (and possibly not-yet-fully-loaded) group + * would risk overwriting it with a partial list. Untouched groups are omitted + * and left as-is on the server. + */ +function buildReferencesPayload( + state: Record, +): Record | undefined { + const out: Record = {}; + let any = false; + for (const [group, s] of Object.entries(state)) { + if (!sameReferenceIds(s.baseline, s.current)) { + out[group] = s.current.map((r) => r.id); + any = true; + } + } + return any ? out : undefined; +} + /** Simplified user info for current user context */ export interface CurrentUserInfo { id: string; @@ -120,12 +199,14 @@ export interface ContentEditorProps { data: Record; slug?: string; bylines?: BylineCreditInput[]; + references?: Record; }) => void; /** Callback for autosave (debounced, skips revision creation) */ onAutosave?: (payload: { data: Record; slug?: string; bylines?: BylineCreditInput[]; + references?: Record; }) => void; /** Whether autosave is in progress */ isAutosaving?: boolean; @@ -264,6 +345,24 @@ export function ContentEditor({ // would wipe them. const [bylinesTouched, setBylinesTouched] = React.useState(false); + // Staged reference-field selections, keyed by relation translation group. + // Seeded from the hydrated first page; the picker fills titles for + // newly added rows. Edges save inside the content payload — never via edge + // POSTs. + const [referenceState, setReferenceState] = React.useState>( + () => seedReferenceState(item), + ); + // Mirror in a ref so save/autosave/load-more callbacks read fresh state + // without re-subscribing. + const referenceStateRef = React.useRef(referenceState); + referenceStateRef.current = referenceState; + // Snapshot of the reference groups sent in the in-flight autosave, applied + // as the new baseline when the autosave resolves (mirrors the data path's + // pendingAutosaveStateRef, since autosave patches the cache without a refetch). + const pendingAutosaveReferencesRef = React.useRef | null>( + null, + ); + // Track portableText editor for document outline. Only the "content" // field wires its editor into this slot (see onEditorReady below). const [portableTextEditor, setPortableTextEditor] = React.useState(null); @@ -341,6 +440,8 @@ export function ContentEditor({ }), ); pendingAutosaveStateRef.current = null; + pendingAutosaveReferencesRef.current = null; + setReferenceState(seedReferenceState(item)); setBylinesTouched(false); } @@ -370,8 +471,17 @@ export function ContentEditor({ ); pendingAutosaveStateRef.current = null; setBylinesTouched(false); + // Re-seed references only when the item carries hydrated references. + // Autosave patches the content cache with a server item that has no + // `references` key (hydration is opt-in on the editor GET route only) — + // re-seeding from that would wipe the staged rows. The autosave baseline + // reset instead runs off `lastAutosaveAt` below. + if (item.references) { + setReferenceState(seedReferenceState(item)); + pendingAutosaveReferencesRef.current = null; + } } - }, [item?.updatedAt, itemDataString, item?.slug, item?.status]); + }, [item?.updatedAt, itemDataString, item?.slug, item?.status, item?.references]); const activeBylines = isNew ? (selectedBylines ?? []) : internalBylines; @@ -398,11 +508,85 @@ export function ContentEditor({ }), [formData, slug, activeBylines], ); - const isDirty = isNew || currentData !== lastSavedData; + // References live outside `serializeEditorState` — they carry their own + // baseline/current diff (order-sensitive id lists). + const referencesDirty = React.useMemo( + () => Object.values(referenceState).some((s) => !sameReferenceIds(s.baseline, s.current)), + [referenceState], + ); + const isDirty = isNew || currentData !== lastSavedData || referencesDirty; const saveFeedbackActive = isSaveFeedbackActive ?? isSaving; const autosaveFeedbackActive = isAutosaveFeedbackActive ?? isAutosaving; const isContentOperationPending = Boolean(isSaving); + // Replace a relation group's staged current selection (add/remove/reorder). + // Upserts the group so a field with no hydrated rows can take its first pick. + const handleReferenceCurrentChange = React.useCallback( + (group: string, rows: ReferenceEntryRow[]) => { + setReferenceState((prev) => { + const existing = prev[group]; + return { + ...prev, + [group]: existing + ? { ...existing, current: rows } + : { baseline: [], current: rows, loading: false }, + }; + }); + }, + [], + ); + + // Page the rest of a relation's hydrated set. The full set must be loaded + // before reorder/remove so a save never emits a partial (truncating) list. + const handleLoadMoreReferences = React.useCallback( + async (group: string) => { + if (!item?.id) return; + const st = referenceStateRef.current[group]; + if (!st || !st.nextCursor || st.loading) return; + const cursor = st.nextCursor; + setReferenceState((prev) => { + const cur = prev[group]; + return cur ? { ...prev, [group]: { ...cur, loading: true } } : prev; + }); + try { + const res = await fetchReferenceChildren(collection, item.id, group, { cursor }); + const rows: ReferenceEntryRow[] = res.children.map((c) => ({ + id: c.id, + slug: c.slug, + title: c.title ?? undefined, + locale: c.locale, + })); + setReferenceState((prev) => { + const cur = prev[group]; + if (!cur) return prev; + // Loading appends to the baseline. If the user hasn't diverged yet + // (current === baseline), mirror the append into current too so the + // newly loaded rows appear without registering as an edit. + const unedited = sameReferenceIds(cur.baseline, cur.current); + const seen = new Set(cur.baseline.map((r) => r.id)); + const nextBaseline = [...cur.baseline, ...rows.filter((r) => !seen.has(r.id))]; + return { + ...prev, + [group]: { + baseline: nextBaseline, + current: unedited ? nextBaseline : cur.current, + nextCursor: res.nextCursor, + loading: false, + }, + }; + }); + } catch { + setReferenceState((prev) => { + const cur = prev[group]; + // Flag the failure so the auto-page effect stops retrying — clearing + // only `loading` would leave `nextCursor` set and spin the request. + return cur ? { ...prev, [group]: { ...cur, loading: false, error: true } } : prev; + }); + } + }, + [collection, item?.id], + ); + // Autosave with debounce // Track pending autosave to cancel on manual save const autosaveTimeoutRef = React.useRef | null>(null); @@ -412,12 +596,31 @@ export function ContentEditor({ slugRef.current = slug; React.useEffect(() => { - if (!autosaveCompletionToken || !pendingAutosaveStateRef.current) { + if (!autosaveCompletionToken) { return; } - setLastSavedData(pendingAutosaveStateRef.current); - pendingAutosaveStateRef.current = null; + if (pendingAutosaveStateRef.current) { + setLastSavedData(pendingAutosaveStateRef.current); + pendingAutosaveStateRef.current = null; + } + + // Mark the reference groups that autosave just persisted as saved by + // advancing their baseline to the sent snapshot. Editing further before + // the autosave resolved leaves `current` ahead of this baseline, so the + // group stays dirty and re-autosaves. + if (pendingAutosaveReferencesRef.current) { + const snapshot = pendingAutosaveReferencesRef.current; + pendingAutosaveReferencesRef.current = null; + setReferenceState((prev) => { + const next = { ...prev }; + for (const [group, rows] of Object.entries(snapshot)) { + const cur = next[group]; + if (cur) next[group] = { ...cur, baseline: rows }; + } + return next; + }); + } }, [autosaveCompletionToken]); const hasInvalidUrls = React.useCallback( @@ -456,11 +659,22 @@ export function ContentEditor({ data: Record; slug?: string; bylines?: BylineCreditInput[]; + references?: Record; } = { data: formDataRef.current, slug: slugRef.current || undefined, }; if (bylinesTouched) payload.bylines = activeBylines; + const references = buildReferencesPayload(referenceStateRef.current); + if (references) { + payload.references = references; + // Remember what we sent so the baseline can advance on resolve. + const snapshot: Record = {}; + for (const group of Object.keys(references)) { + snapshot[group] = referenceStateRef.current[group]?.current ?? []; + } + pendingAutosaveReferencesRef.current = snapshot; + } pendingAutosaveStateRef.current = serializeEditorState({ data: payload.data, slug: payload.slug || "", @@ -485,6 +699,7 @@ export function ContentEditor({ activeBylines, bylinesTouched, hasInvalidUrls, + referenceState, ]); // Cancel pending autosave on manual save @@ -500,11 +715,14 @@ export function ContentEditor({ data: Record; slug?: string; bylines?: BylineCreditInput[]; + references?: Record; } = { data: formData, slug: slug || undefined, }; if (isNew || bylinesTouched) payload.bylines = activeBylines; + const references = buildReferencesPayload(referenceStateRef.current); + if (references) payload.references = references; onSave?.(payload); }; @@ -795,6 +1013,12 @@ export function ContentEditor({ field.kind === "portableText" ? handleBlockSidebarClose : undefined } manifest={manifest} + referenceState={referenceState} + onReferenceChange={handleReferenceCurrentChange} + onLoadMoreReferences={handleLoadMoreReferences} + // Existing entries carry their locale on `item`; new entries only + // have the URL-derived `entryLocale`. Mirror ContentSettingsPanel. + entryLocale={item?.locale ?? entryLocale} /> ); return fieldEl; @@ -1074,6 +1298,14 @@ interface FieldRendererProps { onBlockSidebarClose?: () => void; /** Admin manifest for resolving sandboxed field widget elements */ manifest?: import("../lib/api/client.js").AdminManifest | null; + /** Staged reference selections for all relation groups (reference fields). */ + referenceState?: Record; + /** Replace a relation group's staged current selection. */ + onReferenceChange?: (group: string, rows: ReferenceEntryRow[]) => void; + /** Page the rest of a relation's hydrated set. */ + onLoadMoreReferences?: (group: string) => void; + /** Locale of the editing entry; threaded to reference pickers. */ + entryLocale?: string | null; } /** @@ -1090,6 +1322,10 @@ function FieldRenderer({ onBlockSidebarOpen, onBlockSidebarClose, manifest, + referenceState, + onReferenceChange, + onLoadMoreReferences, + entryLocale, }: FieldRendererProps) { const { t } = useLingui(); const pluginAdmins = usePluginAdmins(); @@ -1371,6 +1607,40 @@ function FieldRenderer({ ); } + case "reference": { + const relationGroup = + typeof field.validation?.relation === "string" ? field.validation.relation : undefined; + const targetCollection = + typeof field.validation?.targetCollection === "string" + ? field.validation.targetCollection + : undefined; + const multiple = field.validation?.multiple !== false; + if (!relationGroup || !targetCollection) { + return ( +
+ + {label} + +

+ {t`This reference field isn't fully configured.`} +

+
+ ); + } + return ( + onReferenceChange?.(relationGroup, rows)} + onLoadMore={() => onLoadMoreReferences?.(relationGroup)} + entryLocale={entryLocale} + /> + ); + } + case "json": { const jsonString = typeof value === "string" ? value : value != null ? JSON.stringify(value, null, 2) : ""; @@ -1413,6 +1683,203 @@ function FieldRenderer({ } } +/** Display label for a staged reference row: title, then slug, then id. */ +function referenceRowLabel(row: ReferenceEntryRow): string { + return row.title || row.slug || row.id; +} + +/** + * Reference field editor. Renders the staged selections with remove/reorder + * controls and a picker to add more. All mutations flow through `onChange` + * into the parent's `referenceState`; nothing is persisted until the content + * entry saves (edges ride in the `references` payload key). + */ +function ReferenceFieldRenderer({ + label, + labelClass, + targetCollection, + multiple, + state, + onChange, + onLoadMore, + entryLocale, +}: { + label: string; + labelClass?: string; + targetCollection: string; + multiple: boolean; + state?: ReferenceGroupState; + onChange: (rows: ReferenceEntryRow[]) => void; + onLoadMore: () => void; + /** Locale of the editing entry; scopes the picker to one variant per target. */ + entryLocale?: string | null; +}) { + const { t } = useLingui(); + const [pickerOpen, setPickerOpen] = React.useState(false); + + const rows = state?.current ?? []; + const nextCursor = state?.nextCursor; + const loading = state?.loading ?? false; + const loadError = state?.error ?? false; + // Reorder/remove are gated until the full hydrated set is loaded, so a save + // can never emit a truncated list that would delete the unloaded tail. + const fullyLoaded = !nextCursor && !loading; + + // Auto-page the remaining hydrated set so the field is edit-ready. Chains: + // each load advances `nextCursor`, re-firing until the set is exhausted. A + // failed page sets `error`, which halts the chain so a throwing request never + // retries in a tight loop; reseeding for a new entry clears it. + React.useEffect(() => { + if (nextCursor && !loading && !loadError) onLoadMore(); + }, [nextCursor, loading, loadError, onLoadMore]); + + const selectedIds = React.useMemo(() => new Set(rows.map((r) => r.id)), [rows]); + + const move = (index: number, delta: number) => { + const target = index + delta; + if (target < 0 || target >= rows.length) return; + const next = [...rows]; + const [moved] = next.splice(index, 1); + if (moved) next.splice(target, 0, moved); + onChange(next); + }; + + const remove = (index: number) => { + onChange(rows.filter((_, i) => i !== index)); + }; + + const handleConfirm = (picked: PickedContentEntry[]) => { + const additions: ReferenceEntryRow[] = picked.map((p) => ({ + id: p.id, + slug: p.slug, + title: p.title, + locale: p.locale, + })); + if (multiple) { + const existing = new Set(rows.map((r) => r.id)); + onChange([...rows, ...additions.filter((a) => !existing.has(a.id))]); + } else { + // Single-value: the picked entry replaces the current selection. + onChange(additions.slice(0, 1)); + } + }; + + return ( +
+ + {label} + +
+ {rows.length === 0 ? ( +

{t`No references selected.`}

+ ) : ( +
    + {rows.map((row, index) => { + return ( +
  • + +
    + {referenceRowLabel(row)} +
    + {row.slug && ( +
    + {row.slug} +
    + )} + + } + aria-label={t`Open ${referenceRowLabel(row)} in a new tab`} + /> + {multiple && ( +
    + + +
    + )} + +
  • + ); + })} +
+ )} + + {!fullyLoaded && ( +
+ {t`Loading references...`} +
+ )} + + +
+ + +
+ ); +} + const URL_PROTOCOL_PATTERN = /^https?:\/\//; function isValidUrl(val: string): boolean { diff --git a/packages/admin/src/components/ContentPickerModal.tsx b/packages/admin/src/components/ContentPickerModal.tsx index 112219a2cf..486804b95b 100644 --- a/packages/admin/src/components/ContentPickerModal.tsx +++ b/packages/admin/src/components/ContentPickerModal.tsx @@ -1,14 +1,26 @@ /** * Content Picker Modal * - * A modal for browsing and selecting content items to add to menus. - * Uses cursor pagination to allow browsing beyond the initial page. + * A modal for browsing and selecting content entries. Serves two callers: + * + * - **Menus** browse across collections (a collection dropdown is shown) and + * pick a single entry. + * - **Reference fields** lock to a single target collection (dropdown hidden), + * and either pick one entry or stage several (`multiple`), disabling entries + * already linked (`selectedIds`). + * + * Search is served by the content list's `q` filter, which uses the + * collection's FTS5 index when available (LIKE fallback otherwise) — the same + * search the admin content list uses. Results are cursor-paginated through + * `useInfiniteQuery` so pages accumulate in the query cache; nothing is + * mirrored into local state, so reopening the modal shows the cached results + * immediately rather than an empty list. */ -import { Button, Dialog, Input, Loader, Select } from "@cloudflare/kumo"; +import { Button, Checkbox, Dialog, Input, Loader, Select } from "@cloudflare/kumo"; import { useLingui } from "@lingui/react/macro"; import { MagnifyingGlass, FolderOpen, X } from "@phosphor-icons/react"; -import { useQuery } from "@tanstack/react-query"; +import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; import * as React from "react"; import { fetchCollections, fetchContentList, getDraftStatus } from "../lib/api"; @@ -16,10 +28,40 @@ import type { ContentItem } from "../lib/api"; import { useDebouncedValue } from "../lib/hooks"; import { cn } from "../lib/utils"; +/** A chosen content entry, carrying its collection and a display title. */ +export interface PickedContentEntry { + collection: string; + id: string; + slug: string | null; + title: string; + /** Locale of the picked variant, so links/badges keep locale context before hydration. */ + locale?: string; +} + interface ContentPickerModalProps { open: boolean; onOpenChange: (open: boolean) => void; - onSelect: (item: { collection: string; id: string; title: string }) => void; + /** + * Lock the picker to a single collection and hide the dropdown (reference + * fields). When omitted, a collection dropdown is shown (menus). + */ + collection?: string; + /** Allow staging several entries before confirming. Defaults to single-select. */ + multiple?: boolean; + /** Ids already linked in the target field — rendered checked and disabled. */ + selectedIds?: ReadonlySet; + /** Emit the chosen entries. Single-select emits a one-element array. */ + onConfirm: (rows: PickedContentEntry[]) => void; + /** Optional dialog title override. */ + title?: string; + /** + * The editing entry's locale (reference fields). When set, translations of the + * same entry collapse to one row, preferring this locale and falling back to + * another when the entry has no variant here — mirroring how the reference + * list resolves edges (`resolveEntries`/`pickVariant`). Edges are keyed by + * translation group, so a cross-locale target is still a valid pick. + */ + locale?: string; } function getItemTitle(item: { data: Record; slug: string | null; id: string }) { @@ -33,88 +75,135 @@ function getItemTitle(item: { data: Record; slug: string | null ); } -export function ContentPickerModal({ open, onOpenChange, onSelect }: ContentPickerModalProps) { +const EMPTY_SELECTED: ReadonlySet = new Set(); + +export function ContentPickerModal({ + open, + onOpenChange, + collection, + multiple = false, + selectedIds = EMPTY_SELECTED, + onConfirm, + title, + locale, +}: ContentPickerModalProps) { const { t } = useLingui(); + const locked = !!collection; const [searchQuery, setSearchQuery] = React.useState(""); const debouncedSearch = useDebouncedValue(searchQuery, 300); - const [selectedCollection, setSelectedCollection] = React.useState(""); - const [allItems, setAllItems] = React.useState([]); - const [nextCursor, setNextCursor] = React.useState(); - const [isLoadingMore, setIsLoadingMore] = React.useState(false); + const [dropdownCollection, setDropdownCollection] = React.useState(""); + // Staged picks (multiple mode) — keyed by id so we retain title/slug. + const [picked, setPicked] = React.useState>({}); const { data: collections = [] } = useQuery({ queryKey: ["collections"], queryFn: fetchCollections, - enabled: open, + enabled: open && !locked, }); - // Default to first collection when collections load + // Default the dropdown to the first collection once collections load. React.useEffect(() => { - if (collections.length > 0 && !selectedCollection) { - setSelectedCollection(collections[0]!.slug); + if (!locked && collections.length > 0 && !dropdownCollection) { + setDropdownCollection(collections[0]!.slug); } - }, [collections, selectedCollection]); + }, [locked, collections, dropdownCollection]); - const { data: contentResult, isLoading: contentLoading } = useQuery({ - queryKey: ["content-picker", selectedCollection, { limit: 50 }], - queryFn: () => fetchContentList(selectedCollection, { limit: 50 }), - enabled: open && !!selectedCollection, - }); + const activeCollection = collection ?? dropdownCollection; - // Sync initial page into accumulated items + // Reset transient UI state when the modal opens. Result pages come from the + // query cache (below), so there is nothing to re-fetch or re-sync here. React.useEffect(() => { - if (contentResult) { - setAllItems(contentResult.items); - setNextCursor(contentResult.nextCursor); + if (open) { + setSearchQuery(""); + setPicked({}); + if (!locked) setDropdownCollection(""); } - }, [contentResult]); + }, [open, locked]); - const handleLoadMore = async () => { - if (!nextCursor || isLoadingMore) return; - setIsLoadingMore(true); - try { - const result = await fetchContentList(selectedCollection, { + const trimmedSearch = debouncedSearch.trim(); + const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({ + queryKey: ["content-picker", activeCollection, trimmedSearch], + queryFn: ({ pageParam }) => + fetchContentList(activeCollection, { limit: 50, - cursor: nextCursor, - }); - setAllItems((prev) => [...prev, ...result.items]); - setNextCursor(result.nextCursor); - } finally { - setIsLoadingMore(false); - } - }; - - const filteredItems = React.useMemo(() => { - if (!debouncedSearch) return allItems; - const query = debouncedSearch.toLowerCase(); - return allItems.filter((item) => getItemTitle(item).toLowerCase().includes(query)); - }, [allItems, debouncedSearch]); + cursor: pageParam, + search: trimmedSearch || undefined, + }), + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => lastPage.nextCursor, + enabled: open && !!activeCollection, + }); - // Reset state when modal opens or collection changes - React.useEffect(() => { - if (open) { - setSearchQuery(""); - setSelectedCollection(""); - setAllItems([]); - setNextCursor(undefined); + const items = React.useMemo(() => { + const flat = data?.pages.flatMap((page) => page.items) ?? []; + if (!locale) return flat; + // Reference fields link by translation group, so translations of the same + // entry are the same target. Collapse them to one row, preferring the + // editor locale and falling back to the lowest locale code (deterministic), + // mirroring `pickVariant` in the reference-list resolver. + const byGroup = new Map(); + const order: string[] = []; + for (const item of flat) { + const key = item.translationGroup ?? item.id; + const existing = byGroup.get(key); + if (!existing) { + byGroup.set(key, item); + order.push(key); + } else if ( + existing.locale !== locale && + (item.locale === locale || item.locale < existing.locale) + ) { + byGroup.set(key, item); + } } - }, [open]); + return order.map((key) => byGroup.get(key)!); + }, [data, locale]); - const handleSelect = (item: ContentItem) => { - onSelect({ - collection: selectedCollection, - id: item.id, - title: getItemTitle(item), + const togglePicked = (item: ContentItem) => { + setPicked((prev) => { + const next = { ...prev }; + if (next[item.id]) { + delete next[item.id]; + } else { + next[item.id] = { + collection: activeCollection, + id: item.id, + slug: item.slug, + title: getItemTitle(item), + locale: item.locale, + }; + } + return next; }); + }; + + const handleSingleChoose = (item: ContentItem) => { + onConfirm([ + { + collection: activeCollection, + id: item.id, + slug: item.slug, + title: getItemTitle(item), + locale: item.locale, + }, + ]); + onOpenChange(false); + }; + + const handleConfirmMultiple = () => { + onConfirm(Object.values(picked)); onOpenChange(false); }; + const pickedCount = Object.keys(picked).length; + const dialogTitle = title ?? (multiple ? t`Add references` : t`Select content`); + return (
- {t`Select Content`} + {dialogTitle}
- {/* Search and collection filter */} + {/* Search and (unlocked) collection filter */}
@@ -145,27 +234,28 @@ export function ContentPickerModal({ open, onOpenChange, onSelect }: ContentPick autoFocus />
- { + setDropdownCollection(v ?? ""); + setPicked({}); + }} + items={Object.fromEntries(collections.map((col) => [col.slug, col.label]))} + aria-label={t`Collection`} + /> + )}
{/* Content list */}
- {contentLoading ? ( + {isLoading ? (
{t`Loading content...`}
- ) : filteredItems.length === 0 ? ( + ) : items.length === 0 ? (
- {searchQuery ? ( + {trimmedSearch ? ( <>

{t`No content found`}

@@ -180,55 +270,91 @@ export function ContentPickerModal({ open, onOpenChange, onSelect }: ContentPick
) : (
- {filteredItems.map((item) => { + {items.map((item) => { const status = getDraftStatus(item); + const alreadyLinked = selectedIds.has(item.id); + const isPicked = alreadyLinked || !!picked[item.id]; + const statusDot = ( + + ); + const statusLabel = + status === "published" + ? t`Published` + : status === "published_with_changes" + ? t`Modified` + : t`Draft`; + const meta = ( +
+ {statusDot} + {statusLabel} + {item.slug && ( + <> + / + {item.slug} + + )} +
+ ); + + if (multiple) { + return ( + + ); + } + return ( ); })} - {nextCursor && !searchQuery && ( + {hasNextPage && (
+ {multiple && ( + + )}
diff --git a/packages/admin/src/components/ContentSettingsPanel.tsx b/packages/admin/src/components/ContentSettingsPanel.tsx index f108f9fffe..40ea81e1ab 100644 --- a/packages/admin/src/components/ContentSettingsPanel.tsx +++ b/packages/admin/src/components/ContentSettingsPanel.tsx @@ -34,6 +34,7 @@ import type { GalleryAttributes } from "./editor/GalleryNode"; import { ImageDetailPanel } from "./editor/ImageDetailPanel"; import type { ImageAttributes } from "./editor/ImageDetailPanel"; import type { BlockSidebarPanel } from "./PortableTextEditor"; +import { ReferencesSidebar } from "./ReferencesSidebar.js"; import { RevisionHistory } from "./RevisionHistory"; import { RouterLinkButton } from "./RouterLinkButton.js"; import { SaveButton } from "./SaveButton"; @@ -617,6 +618,17 @@ export const ContentSettingsPanel = React.memo(function ContentSettingsPanel({ )} + {item && !isNew && ( + + + + )} + {hasSeo && !isNew && onSeoChange && (
diff --git a/packages/admin/src/components/FieldEditor.tsx b/packages/admin/src/components/FieldEditor.tsx index 435ea9dddb..571efcae3c 100644 --- a/packages/admin/src/components/FieldEditor.tsx +++ b/packages/admin/src/components/FieldEditor.tsx @@ -20,8 +20,10 @@ import { Trash, X, } from "@phosphor-icons/react"; +import { useQuery } from "@tanstack/react-query"; import * as React from "react"; +import { fetchCollections } from "../lib/api"; import type { FieldType, CreateFieldInput, SchemaField } from "../lib/api"; import { cn } from "../lib/utils"; import { AllowedTypesEditor } from "./AllowedTypesEditor"; @@ -77,6 +79,8 @@ interface FieldFormState { minItems: string; maxItems: string; allowedMimeTypes: string[]; + targetCollection: string; + allowMultiple: boolean; } function getInitialFormState(field?: SchemaField): FieldFormState { @@ -101,6 +105,8 @@ function getInitialFormState(field?: SchemaField): FieldFormState { minItems: (field.validation as Record)?.minItems?.toString() ?? "", maxItems: (field.validation as Record)?.maxItems?.toString() ?? "", allowedMimeTypes: field.validation?.allowedMimeTypes ?? [], + targetCollection: field.validation?.targetCollection ?? "", + allowMultiple: field.validation?.multiple ?? true, }; } return { @@ -121,6 +127,8 @@ function getInitialFormState(field?: SchemaField): FieldFormState { minItems: "", maxItems: "", allowedMimeTypes: [], + targetCollection: "", + allowMultiple: true, }; } @@ -130,16 +138,24 @@ function getInitialFormState(field?: SchemaField): FieldFormState { export function FieldEditor({ open, onOpenChange, field, onSave, isSaving }: FieldEditorProps) { const { t } = useLingui(); const [formState, setFormState] = React.useState(() => getInitialFormState(field)); + const [refError, setRefError] = React.useState(false); + + const { data: collections = [] } = useQuery({ + queryKey: ["collections"], + queryFn: fetchCollections, + }); // Reset state when dialog opens React.useEffect(() => { if (open) { setFormState(getInitialFormState(field)); + setRefError(false); } }, [open, field]); const { step, selectedType, slug, label, required, unique, searchable } = formState; const { minLength, maxLength, min, max, pattern, options } = formState; + const { targetCollection, allowMultiple } = formState; const setField = (key: K, value: FieldFormState[K]) => setFormState((prev) => ({ ...prev, [key]: value })); @@ -265,6 +281,11 @@ export function FieldEditor({ open, onOpenChange, field, onSave, isSaving }: Fie const handleSave = () => { if (!selectedType || !slug || !label) return; + if (selectedType === "reference" && !targetCollection) { + setRefError(true); + return; + } + const validation: CreateFieldInput["validation"] = {}; // Build validation based on field type @@ -311,6 +332,11 @@ export function FieldEditor({ open, onOpenChange, field, onSave, isSaving }: Fie validation.allowedMimeTypes = formState.allowedMimeTypes; } + if (selectedType === "reference") { + validation.targetCollection = targetCollection; + validation.multiple = allowMultiple; + } + // Only include searchable for text-based fields const isSearchableType = selectedType === "string" || @@ -517,6 +543,34 @@ export function FieldEditor({ open, onOpenChange, field, onSave, isSaving }: Fie /> )} + {selectedType === "reference" && ( +
+

{t`Reference`}

+