Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
0acd346
feat(core): configurable displayField and dateField per collection
CacheMeOwside Jul 12, 2026
3d5b62b
style: format
emdashbot[bot] Jul 12, 2026
b7d853f
ci: update query-count snapshots
emdashbot[bot] Jul 12, 2026
562c0b5
add fixes as per PR comments
CacheMeOwside Jul 12, 2026
c8b1920
Merge remote-tracking branch 'origin/main' into feat/1133-configurabl…
CacheMeOwside Jul 12, 2026
b0e5e7a
ci: update query-count snapshots
emdashbot[bot] Jul 12, 2026
555cd6b
reject field type changes that break displayField/dateField
CacheMeOwside Jul 12, 2026
0212cb1
Merge branch 'feat/1133-configurable-diplay-and-date-field-in-collect…
CacheMeOwside Jul 12, 2026
90181c0
rename displayField to titleField
CacheMeOwside Jul 17, 2026
3fdd7d1
match content-list search and suggestions against configured titleField
CacheMeOwside Jul 24, 2026
0ed81e7
Merge remote-tracking branch 'origin/main' into feat/1133-configurabl…
CacheMeOwside Jul 24, 2026
9bd9ea2
ci: update query-count snapshots
emdashbot[bot] Jul 25, 2026
1083ca0
update integration tests
CacheMeOwside Jul 25, 2026
7495be9
style: format
emdashbot[bot] Jul 25, 2026
55ccedc
removed reference to PR from comments
CacheMeOwside Jul 25, 2026
bfe8043
Remove PR reference
CacheMeOwside Jul 27, 2026
5ffe759
Merge branch 'main' into feat/1133-configurable-diplay-and-date-field…
CacheMeOwside Jul 27, 2026
16ef5d4
Merge remote-tracking branch 'origin/main' into feat/1133-configurabl…
CacheMeOwside Aug 13, 2026
f717123
Merge remote-tracking branch 'origin/main' into feat/1133-configurabl…
CacheMeOwside Aug 13, 2026
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
6 changes: 6 additions & 0 deletions .changeset/collection-display-date-fields.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"emdash": minor
"@emdash-cms/admin": minor
---

Adds `titleField` and `dateField` optional collection options to choose which field is used as an entry's title and which date the content list shows and sorts by.
9 changes: 8 additions & 1 deletion packages/admin/src/components/ContentEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import type {
} from "../lib/api";
import { getPreviewUrl, getDraftStatus } from "../lib/api";
import { fromDatetimeLocalInputValue, toDatetimeLocalInputValue } from "../lib/datetime-local.js";
import { getEntryTitle } from "../lib/entryTitle.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 @@ -519,6 +520,12 @@ export function ContentEditor({

const urlPattern = manifest?.collections[collection]?.urlPattern;

// When the collection configures a titleField, the editor header
// shows the entry's title for existing entries; otherwise it keeps the
// generic "Edit <label>".
const titleField = manifest?.collections[collection]?.titleField;
const entryTitle = item && titleField ? getEntryTitle(item, titleField) : "";

const handlePreview = async () => {
if (!item?.id) return;

Expand Down Expand Up @@ -646,7 +653,7 @@ export function ContentEditor({
/>
)}
<h1 className="min-w-0 truncate text-lg font-semibold">
{isNew ? t`New ${itemLabel}` : t`Edit ${itemLabel}`}
{isNew ? t`New ${itemLabel}` : entryTitle || t`Edit ${itemLabel}`}
</h1>
{i18n && item?.locale && (
<Badge variant="outline" className="uppercase text-xs">
Expand Down
66 changes: 47 additions & 19 deletions packages/admin/src/components/ContentList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { Link } from "@tanstack/react-router";
import * as React from "react";

import type { ContentAuthor, ContentDateField, ContentItem, TrashedContentItem } from "../lib/api";
import { getEntryTitle } from "../lib/entryTitle.js";
import { useDebouncedValue } from "../lib/hooks.js";
import { contentUrl } from "../lib/url.js";
import { cn, parseTimestamp } from "../lib/utils";
Expand All @@ -47,8 +48,12 @@ import {
import { LocaleSwitcher } from "./LocaleSwitcher";
import { RouterLinkButton } from "./RouterLinkButton.js";

/** Sortable content list columns. Maps to the server's order field whitelist. */
export type ContentListSortField = "title" | "status" | "locale" | "updatedAt";
/**
* Sortable content list columns. The named values map to the server's system
* order fields; a collection's configured titleField/dateField slug is also
* accepted, which the server validates against the collection.
*/
export type ContentListSortField = "title" | "status" | "locale" | "updatedAt" | (string & {});
export interface ContentListSort {
field: ContentListSortField;
direction: "asc" | "desc";
Expand Down Expand Up @@ -104,6 +109,10 @@ export interface ContentListProps {
onLocaleChange?: (locale: string) => void;
/** URL pattern for published content links (e.g. `/blog/{slug}`) */
urlPattern?: string;
/** Collection field slug powering the Title column (falls back to the title chain). */
titleField?: string;
/** Collection field slug (datetime) powering the Date column (falls back to updated date). */
dateField?: string;
/**
* Controlled sort state. When `onSortChange` is also provided, the column
* headers become sort controls that invoke it. Uncontrolled sort keeps
Expand Down Expand Up @@ -163,15 +172,19 @@ type ViewTab = "all" | "trash";

const PAGE_SIZE = 20;

function getItemTitle(item: { data: Record<string, unknown>; slug: string | null; id: string }) {
const rawTitle = item.data.title;
const rawName = item.data.name;
return (
(typeof rawTitle === "string" ? rawTitle : "") ||
(typeof rawName === "string" ? rawName : "") ||
item.slug ||
item.id
);
const DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/;

/**
* Parse a dateField value for the Date column. Returns null if missing or
* unparseable (so the caller falls back to a system date instead of showing
* "Invalid Date"). Bare `YYYY-MM-DD` is read as local midnight to avoid a
* previous-day shift in negative-UTC timezones.
*/
function parseListDate(value: unknown): Date | null {
if (typeof value !== "string" || !value) return null;
const normalized = DATE_ONLY_RE.test(value) ? `${value}T00:00:00` : value;
const parsed = new Date(normalized);
return Number.isNaN(parsed.getTime()) ? null : parsed;
}

/**
Expand All @@ -198,6 +211,8 @@ export function ContentList({
activeLocale,
onLocaleChange,
urlPattern,
titleField,
dateField,
sort,
onSortChange,
total,
Expand Down Expand Up @@ -244,8 +259,8 @@ export function ContentList({
const filteredItems = React.useMemo(() => {
if (serverSearch || !searchQuery) return items;
const query = searchQuery.toLowerCase();
return items.filter((item) => getItemTitle(item).toLowerCase().includes(query));
}, [items, searchQuery, serverSearch]);
return items.filter((item) => getEntryTitle(item, titleField).toLowerCase().includes(query));
}, [items, searchQuery, serverSearch, titleField]);

// The query the current `items` reflect: server-side filtering lags behind
// typing by the debounce, so the empty-state message must use the debounced
Expand Down Expand Up @@ -529,8 +544,10 @@ export function ContentList({
/>
</th>
)}
{/* The Title/Date columns sort by the collection's configured
titleField/dateField when set */}
<SortableTh
field="title"
field={titleField ?? "title"}
sort={sort}
onSortChange={onSortChange}
label={t`Title`}
Expand Down Expand Up @@ -559,7 +576,7 @@ export function ContentList({
/>
)}
<SortableTh
field="updatedAt"
field={dateField ?? "updatedAt"}
sort={sort}
onSortChange={onSortChange}
label={t`Date`}
Expand Down Expand Up @@ -615,6 +632,8 @@ export function ContentList({
onDuplicate={onDuplicate}
showLocale={!!i18n}
urlPattern={urlPattern}
titleField={titleField}
dateField={dateField}
listColumns={listColumns}
selectable={bulkEnabled}
selected={selectedIds.has(item.id)}
Expand Down Expand Up @@ -712,6 +731,7 @@ export function ContentList({
<TrashedListItem
key={item.id}
item={item}
titleField={titleField}
onRestore={onRestore}
onPermanentDelete={onPermanentDelete}
/>
Expand Down Expand Up @@ -1011,6 +1031,8 @@ interface ContentListItemProps {
onDuplicate?: (id: string) => void;
showLocale?: boolean;
urlPattern?: string;
titleField?: string;
dateField?: string;
listColumns: ContentListColumn[];
selectable?: boolean;
selected?: boolean;
Expand All @@ -1024,14 +1046,19 @@ function ContentListItem({
onDuplicate,
showLocale,
urlPattern,
titleField,
dateField,
listColumns,
selectable,
selected,
onToggleSelect,
}: ContentListItemProps) {
const { t } = useLingui();
const title = getItemTitle(item);
const date = parseTimestamp(item.updatedAt || item.createdAt);
const title = getEntryTitle(item, titleField);
// A configured dateField drives the Date column; fall back to the
// last-updated / created date when it's unset, empty, or unparseable.
const customDate = dateField ? parseListDate(item.data[dateField]) : null;
const date = customDate ?? parseTimestamp(item.updatedAt || item.createdAt);

return (
<tr className={cn("hover:bg-kumo-tint/25", selected && "bg-kumo-tint/40")}>
Expand Down Expand Up @@ -1242,13 +1269,14 @@ function scalarListColumnValue(value: unknown): string | undefined {

interface TrashedListItemProps {
item: TrashedContentItem;
titleField?: string;
onRestore?: (id: string) => void;
onPermanentDelete?: (id: string) => void;
}

function TrashedListItem({ item, onRestore, onPermanentDelete }: TrashedListItemProps) {
function TrashedListItem({ item, titleField, onRestore, onPermanentDelete }: TrashedListItemProps) {
const { t } = useLingui();
const title = getItemTitle(item);
const title = getEntryTitle(item, titleField);
const deletedDate = parseTimestamp(item.deletedAt);

return (
Expand Down
31 changes: 15 additions & 16 deletions packages/admin/src/components/ContentPickerModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ import { MagnifyingGlass, FolderOpen, X } from "@phosphor-icons/react";
import { useQuery } from "@tanstack/react-query";
import * as React from "react";

import { fetchCollections, fetchContentList, getDraftStatus } from "../lib/api";
import { fetchCollections, fetchContentList, fetchManifest, getDraftStatus } from "../lib/api";
import type { ContentItem } from "../lib/api";
import { getEntryTitle } from "../lib/entryTitle.js";
import { useDebouncedValue } from "../lib/hooks";
import { cn } from "../lib/utils";
import { ContentStatusLabel, type ContentStatusState } from "./ContentStatusBadge.js";
Expand All @@ -23,17 +24,6 @@ interface ContentPickerModalProps {
onSelect: (item: { collection: string; id: string; title: string }) => void;
}

function getItemTitle(item: { data: Record<string, unknown>; slug: string | null; id: string }) {
const rawTitle = item.data.title;
const rawName = item.data.name;
return (
(typeof rawTitle === "string" ? rawTitle : "") ||
(typeof rawName === "string" ? rawName : "") ||
item.slug ||
item.id
);
}

export function ContentPickerModal({ open, onOpenChange, onSelect }: ContentPickerModalProps) {
const { t } = useLingui();
const [searchQuery, setSearchQuery] = React.useState("");
Expand All @@ -49,6 +39,15 @@ export function ContentPickerModal({ open, onOpenChange, onSelect }: ContentPick
enabled: open,
});

// Reuse the cached manifest (same query key as the rest of the admin) to
// resolve the selected collection's titleField for entry titles.
const { data: manifest } = useQuery({
queryKey: ["manifest"],
queryFn: fetchManifest,
enabled: open,
});
const titleField = manifest?.collections[selectedCollection]?.titleField;

// Default to first collection when collections load
React.useEffect(() => {
if (collections.length > 0 && !selectedCollection) {
Expand Down Expand Up @@ -88,8 +87,8 @@ export function ContentPickerModal({ open, onOpenChange, onSelect }: ContentPick
const filteredItems = React.useMemo(() => {
if (!debouncedSearch) return allItems;
const query = debouncedSearch.toLowerCase();
return allItems.filter((item) => getItemTitle(item).toLowerCase().includes(query));
}, [allItems, debouncedSearch]);
return allItems.filter((item) => getEntryTitle(item, titleField).toLowerCase().includes(query));
}, [allItems, debouncedSearch, titleField]);

// Reset state when modal opens or collection changes
React.useEffect(() => {
Expand All @@ -105,7 +104,7 @@ export function ContentPickerModal({ open, onOpenChange, onSelect }: ContentPick
onSelect({
collection: selectedCollection,
id: item.id,
title: getItemTitle(item),
title: getEntryTitle(item, titleField),
});
onOpenChange(false);
};
Expand Down Expand Up @@ -200,7 +199,7 @@ export function ContentPickerModal({ open, onOpenChange, onSelect }: ContentPick
"focus:outline-none focus:ring-2 focus:ring-kumo-ring focus:ring-offset-2",
)}
>
<div className="font-medium">{getItemTitle(item)}</div>
<div className="font-medium">{getEntryTitle(item, titleField)}</div>
<div className="text-sm text-kumo-subtle flex items-center gap-2">
<ContentStatusLabel state={statusState} />
{item.slug && (
Expand Down
2 changes: 2 additions & 0 deletions packages/admin/src/lib/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ export interface AdminManifest {
supports: string[];
hasSeo: boolean;
urlPattern?: string;
titleField?: string;
dateField?: string;
hidden?: boolean;
listColumns?: string[];
fields: Record<
Expand Down
20 changes: 20 additions & 0 deletions packages/admin/src/lib/entryTitle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* The title to show for a content entry. Uses the collection's `titleField`
* if set and non-empty, otherwise falls back to `title → name → slug → id`.
* Shared so every surface (list, picker, editor) shows the same title.
*/
export function getEntryTitle(
item: { data: Record<string, unknown>; slug: string | null; id: string },
titleField?: string,
): string {
const preferred = titleField ? item.data[titleField] : undefined;
const rawTitle = item.data.title;
const rawName = item.data.name;
return (
(typeof preferred === "string" ? preferred : "") ||
(typeof rawTitle === "string" ? rawTitle : "") ||
(typeof rawName === "string" ? rawName : "") ||
item.slug ||
item.id
);
}
15 changes: 11 additions & 4 deletions packages/admin/src/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -341,10 +341,15 @@ function ContentListPage() {

// Controlled sort state — passed to the list, and included in the query
// key so changing direction invalidates the current cursor chain.
const [sort, setSort] = React.useState<ContentListSort>({
field: "updatedAt",
// Default sorts by the collection's dateField, else last-updated.
// `sortOverride` is the user's explicit choice (null until they click a
// column), keeping the default reactive as the manifest loads and per-collection.
const [sortOverride, setSortOverride] = React.useState<ContentListSort | null>(null);
const sort: ContentListSort = sortOverride ?? {
field: manifest?.collections[collection]?.dateField ?? "updatedAt",
direction: "desc",
});
};
React.useEffect(() => setSortOverride(null), [collection]);

// Server-side search term (debounced inside ContentList). Part of the query
// key so a new term restarts the cursor chain from a filtered first page.
Expand Down Expand Up @@ -630,8 +635,10 @@ function ContentListPage() {
activeLocale={activeLocale}
onLocaleChange={handleLocaleChange}
urlPattern={collectionConfig.urlPattern}
titleField={collectionConfig.titleField}
dateField={collectionConfig.dateField}
sort={sort}
onSortChange={setSort}
onSortChange={setSortOverride}
total={total}
onSearchChange={setSearchTerm}
statusFilter={statusFilter}
Expand Down
Loading
Loading