Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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
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.
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 @@ -512,6 +513,12 @@ export function ContentEditor({

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

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

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

Expand Down Expand Up @@ -648,7 +655,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
71 changes: 52 additions & 19 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.
*/
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,8 +232,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, displayField).toLowerCase().includes(query));
}, [items, searchQuery, serverSearch, displayField]);

// 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 @@ -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
33 changes: 17 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";

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,8 +86,10 @@ 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, displayField).toLowerCase().includes(query),
);
}, [allItems, debouncedSearch, displayField]);

// Reset state when modal opens or collection changes
React.useEffect(() => {
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`.
* 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.
// `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