Skip to content
Open
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
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