Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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 `displayField` 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.
8 changes: 7 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 @@ -512,6 +513,11 @@ export function ContentEditor({

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

// The editor header shows the entry's display title (honoring displayField)
// for existing entries (#1133).
const displayField = manifest?.collections[collection]?.displayField;
const entryTitle = item ? getEntryTitle(item, displayField) : "";

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

Expand Down Expand Up @@ -648,7 +654,7 @@ export function ContentEditor({
</Button>
)}
<h1 className="text-2xl font-bold">
{isNew ? t`New ${collectionLabel}` : t`Edit ${collectionLabel}`}
{isNew ? t`New ${collectionLabel}` : entryTitle || t`Edit ${collectionLabel}`}
</h1>
{i18n && item?.locale && (
<Badge variant="outline" className="uppercase text-xs">
Expand Down
69 changes: 51 additions & 18 deletions packages/admin/src/components/ContentList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,20 @@ 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 } from "../lib/utils";
import { CaretNext, CaretPrev } from "./ArrowIcons.js";
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 displayField/dateField slug is also
* accepted (#1133), which the server validates against the collection.
Comment thread
CacheMeOwside marked this conversation as resolved.
Outdated
*/
export type ContentListSortField = "title" | "status" | "locale" | "updatedAt" | (string & {});
export interface ContentListSort {
field: ContentListSortField;
direction: "asc" | "desc";
Expand Down Expand Up @@ -83,6 +88,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). */
displayField?: 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 @@ -139,15 +148,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 @@ -173,6 +186,8 @@ export function ContentList({
activeLocale,
onLocaleChange,
urlPattern,
displayField,
dateField,
sort,
onSortChange,
total,
Expand Down Expand Up @@ -217,7 +232,7 @@ 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));
return items.filter((item) => getEntryTitle(item, displayField).toLowerCase().includes(query));
}, [items, searchQuery, serverSearch]);

// The query the current `items` reflect: server-side filtering lags behind
Expand Down Expand Up @@ -498,8 +513,10 @@ export function ContentList({
/>
</th>
)}
{/* The Title/Date columns sort by the collection's configured
displayField/dateField when set (#1133) */}
<SortableTh
field="title"
field={displayField ?? "title"}
sort={sort}
onSortChange={onSortChange}
label={t`Title`}
Expand All @@ -519,7 +536,7 @@ export function ContentList({
/>
)}
<SortableTh
field="updatedAt"
field={dateField ?? "updatedAt"}
sort={sort}
onSortChange={onSortChange}
label={t`Date`}
Expand Down Expand Up @@ -575,6 +592,8 @@ export function ContentList({
onDuplicate={onDuplicate}
showLocale={!!i18n}
urlPattern={urlPattern}
displayField={displayField}
dateField={dateField}
selectable={bulkEnabled}
selected={selectedIds.has(item.id)}
onToggleSelect={toggleOne}
Expand Down Expand Up @@ -671,6 +690,7 @@ export function ContentList({
<TrashedListItem
key={item.id}
item={item}
displayField={displayField}
onRestore={onRestore}
onPermanentDelete={onPermanentDelete}
/>
Expand Down Expand Up @@ -943,6 +963,8 @@ interface ContentListItemProps {
onDuplicate?: (id: string) => void;
showLocale?: boolean;
urlPattern?: string;
displayField?: string;
dateField?: string;
selectable?: boolean;
selected?: boolean;
onToggleSelect?: (id: string) => void;
Expand All @@ -955,13 +977,18 @@ function ContentListItem({
onDuplicate,
showLocale,
urlPattern,
displayField,
dateField,
selectable,
selected,
onToggleSelect,
}: ContentListItemProps) {
const { t } = useLingui();
const title = getItemTitle(item);
const date = new Date(item.updatedAt || item.createdAt);
const title = getEntryTitle(item, displayField);
// 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 ?? new Date(item.updatedAt || item.createdAt);

return (
<tr className={cn("hover:bg-kumo-tint/25", selected && "bg-kumo-tint/40")}>
Expand Down Expand Up @@ -1071,13 +1098,19 @@ function ContentListItem({

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

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

return (
Expand Down
31 changes: 16 additions & 15 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";

Expand All @@ -22,17 +23,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 @@ -48,6 +38,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 displayField for entry titles (#1133).
const { data: manifest } = useQuery({
queryKey: ["manifest"],
queryFn: fetchManifest,
enabled: open,
});
const displayField = manifest?.collections[selectedCollection]?.displayField;

// Default to first collection when collections load
React.useEffect(() => {
if (collections.length > 0 && !selectedCollection) {
Expand Down Expand Up @@ -87,7 +86,9 @@ 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));
return allItems.filter((item) =>
getEntryTitle(item, displayField).toLowerCase().includes(query),
);
}, [allItems, debouncedSearch]);

// Reset state when modal opens or collection changes
Expand All @@ -104,7 +105,7 @@ export function ContentPickerModal({ open, onOpenChange, onSelect }: ContentPick
onSelect({
collection: selectedCollection,
id: item.id,
title: getItemTitle(item),
title: getEntryTitle(item, displayField),
});
onOpenChange(false);
};
Expand Down Expand Up @@ -193,7 +194,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, displayField)}</div>
<div className="text-sm text-kumo-subtle flex items-center gap-2">
<span
className={cn(
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 @@ -65,6 +65,8 @@ export interface AdminManifest {
supports: string[];
hasSeo: boolean;
urlPattern?: string;
displayField?: string;
dateField?: string;
fields: Record<
string,
{
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 `displayField`
* (#1133) if set and non-empty, otherwise falls back to `title → name → slug → id`.
Comment thread
CacheMeOwside marked this conversation as resolved.
Outdated
* Shared so every surface (list, picker, editor) shows the same title.
*/
export function getEntryTitle(
item: { data: Record<string, unknown>; slug: string | null; id: string },
displayField?: string,
): string {
const preferred = displayField ? item.data[displayField] : 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 @@ -337,10 +337,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 (#1133), else last-updated.
Comment thread
CacheMeOwside marked this conversation as resolved.
Outdated
// `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 @@ -598,8 +603,10 @@ function ContentListPage() {
activeLocale={activeLocale}
onLocaleChange={handleLocaleChange}
urlPattern={collectionConfig.urlPattern}
displayField={collectionConfig.displayField}
dateField={collectionConfig.dateField}
sort={sort}
onSortChange={setSort}
onSortChange={setSortOverride}
total={total}
onSearchChange={setSearchTerm}
statusFilter={statusFilter}
Expand Down
18 changes: 17 additions & 1 deletion packages/core/src/api/handlers/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { isSqlite } from "../../database/dialect-helpers.js";
import { BylineRepository } from "../../database/repositories/byline.js";
import type { ContentBylineInput } from "../../database/repositories/byline.js";
import { CommentRepository } from "../../database/repositories/comment.js";
import { ContentRepository } from "../../database/repositories/content.js";
import { ContentRepository, isSystemOrderField } from "../../database/repositories/content.js";
import { RedirectRepository } from "../../database/repositories/redirect.js";
import { RevisionRepository } from "../../database/repositories/revision.js";
import { SeoRepository } from "../../database/repositories/seo.js";
Expand Down Expand Up @@ -447,13 +447,29 @@ export async function handleContentList(
where.useFts = await canUseFtsForListFilter(db, collection, where.searchColumns);
}

// Sorting by a non-system field (a collection's displayField/dateField,
// #1133) needs the collection's *actual* sort fields resolved server-side,
// so the orderBy set stays closed. Only query when it's not a system field.
let sortableExtras: string[] | undefined;
if (params.orderBy && !isSystemOrderField(params.orderBy)) {
const coll = await db
.selectFrom("_emdash_collections")
.select(["display_field", "date_field"])
.where("slug", "=", collection)
.executeTakeFirst();
sortableExtras = [coll?.display_field, coll?.date_field].filter(
(slug): slug is string => !!slug,
);
}

const result = await repo.findMany(collection, {
cursor: params.cursor,
limit: params.limit || 50,
where: Object.keys(where).length > 0 ? where : undefined,
orderBy: params.orderBy
? { field: params.orderBy, direction: params.order || "desc" }
: undefined,
sortableExtras,
});

// Hydrate SEO data if the collection has SEO enabled
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/astro/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ export interface ManifestCollection {
supports: string[];
hasSeo: boolean;
urlPattern?: string;
displayField?: string;
dateField?: string;
fields: Record<
string,
{
Expand Down
Loading
Loading