From e86097fa816da89c971439c8f53c227531f896bc Mon Sep 17 00:00:00 2001 From: Michael Parker Date: Tue, 31 Mar 2026 16:46:44 +1100 Subject: [PATCH] Add optional Cell component to fields for custom collection list rendering Fields can now define an optional Cell component to control how their values are rendered in collection list views. The image field provides a default Cell that renders 32x32 rounded thumbnails with a fallback to displaying the path string if the image fails to load. --- packages/keystatic/src/app/CollectionPage.tsx | 42 +++++++++++++++---- packages/keystatic/src/form/api.tsx | 5 ++- .../src/form/fields/empty-field-ui.tsx | 1 + .../keystatic/src/form/fields/image/index.tsx | 3 +- .../keystatic/src/form/fields/image/ui.tsx | 29 ++++++++++++- 5 files changed, 69 insertions(+), 11 deletions(-) diff --git a/packages/keystatic/src/app/CollectionPage.tsx b/packages/keystatic/src/app/CollectionPage.tsx index 2d086dd03..8cfbbe0a8 100644 --- a/packages/keystatic/src/app/CollectionPage.tsx +++ b/packages/keystatic/src/app/CollectionPage.tsx @@ -565,17 +565,42 @@ function CollectionTable( ...(hideStatusColumn ? [] : [statusCell]), nameCell, ...collection.columns.map(column => { - let val; - val = item.data?.[column]; + const raw = item.data?.[column]; + const field = collection.schema[column]; + const FieldCell = + field && + 'kind' in field && + field.kind === 'form' && + 'Cell' in field && + field.Cell; + + if (raw == null) { + return ( + + {undefined} + + ); + } - if (val == null) { - val = undefined; - } else { - val = val + ''; + const strVal = raw + ''; + + if (FieldCell) { + return ( + + + + ); } + return ( - - {val} + + {strVal} ); }), @@ -597,6 +622,7 @@ function CollectionTable( ); } + function getItemPath( basePath: string, collection: string, diff --git a/packages/keystatic/src/form/api.tsx b/packages/keystatic/src/form/api.tsx index 4b87714cf..cfa876f03 100644 --- a/packages/keystatic/src/form/api.tsx +++ b/packages/keystatic/src/form/api.tsx @@ -1,4 +1,4 @@ -import { ReactElement, ReactNode } from 'react'; +import { ReactElement, ReactNode, ComponentType } from 'react'; import { Glob } from '../config'; import { ChildField } from './fields/child'; @@ -48,6 +48,7 @@ export type BasicFormField< parse(value: FormFieldStoredValue): ReaderValue; }; label?: string; + Cell?: ComponentType<{ value: any }>; }; export type SlugFormField< @@ -85,6 +86,7 @@ export type SlugFormField< ): ReaderValueAsSlugField; }; label?: string; + Cell?: ComponentType<{ value: any }>; }; export type AssetFormField< @@ -127,6 +129,7 @@ export type AssetFormField< parse(value: FormFieldStoredValue): ReaderValue; }; label?: string; + Cell?: ComponentType<{ value: any }>; }; export type AssetsFormField< diff --git a/packages/keystatic/src/form/fields/empty-field-ui.tsx b/packages/keystatic/src/form/fields/empty-field-ui.tsx index 0289efad7..7b9caf710 100644 --- a/packages/keystatic/src/form/fields/empty-field-ui.tsx +++ b/packages/keystatic/src/form/fields/empty-field-ui.tsx @@ -19,6 +19,7 @@ export let SlugFieldInput = empty, IntegerFieldInput = empty, NumberFieldInput = empty, ImageFieldInput = empty, + ImageCell = empty, FileFieldInput = empty, DatetimeFieldInput = empty, DateFieldInput = empty, diff --git a/packages/keystatic/src/form/fields/image/index.tsx b/packages/keystatic/src/form/fields/image/index.tsx index 759e534f2..2c8bb43dc 100644 --- a/packages/keystatic/src/form/fields/image/index.tsx +++ b/packages/keystatic/src/form/fields/image/index.tsx @@ -3,7 +3,7 @@ import { AssetFormField } from '../../api'; import { FieldDataError } from '../error'; import { RequiredValidation, assertRequired } from '../utils'; import { getSrcPrefix } from './getSrcPrefix'; -import { ImageFieldInput } from '#field-ui/image'; +import { ImageFieldInput, ImageCell } from '#field-ui/image'; export function image({ label, @@ -34,6 +34,7 @@ export function image({ kind: 'form', formKind: 'asset', label, + Cell: ImageCell, Input(props) { return ( ); } + +export function ImageCell({ value }: { value: string | null }) { + const [errored, setErrored] = useState(false); + if (!value) return null; + if (errored) { + return ( + + {value} + + ); + } + return ( + setErrored(true)} + className={css({ + width: tokenSchema.size.scale[400], + height: tokenSchema.size.scale[400], + objectFit: 'cover', + borderRadius: tokenSchema.size.radius.small, + flexShrink: 0, + })} + /> + ); +}