diff --git a/.changeset/lemon-ladybugs-itch.md b/.changeset/lemon-ladybugs-itch.md new file mode 100644 index 000000000..c25bdde95 --- /dev/null +++ b/.changeset/lemon-ladybugs-itch.md @@ -0,0 +1,6 @@ +--- +'@keystatic/core': patch +'keystatic-docs': patch +--- + +Implement columns definition for collection view. diff --git a/docs/keystatic.config.tsx b/docs/keystatic.config.tsx index b5b2ad850..b4bc4b80b 100644 --- a/docs/keystatic.config.tsx +++ b/docs/keystatic.config.tsx @@ -173,6 +173,7 @@ const markdocConfig: Config = { }, }, paragraph: { ...Markdoc.nodes.paragraph, render: 'Paragraph' }, + table: { ...Markdoc.nodes.table, render: 'Table' }, fence: { render: 'CodeBlock', attributes: { @@ -295,7 +296,14 @@ export default config({ contentField: 'content', }, previewUrl: makePreviewUrl('/blog/{slug}'), - columns: ['title', 'publishedOn'], + columns: { + definition: [ + { key: 'title', isRowHeader: true }, + { key: 'draft', label: 'Draft', width: 140 }, + { key: 'publishedOn', label: 'Published', width: 140, align: 'end' }, + ], + defaultSort: { column: 'publishedOn', direction: 'descending' }, + }, schema: { title: fields.slug({ name: { @@ -372,7 +380,16 @@ export default config({ path: 'src/content/projects/*', format: { contentField: 'content' }, entryLayout: 'content', - columns: ['title', 'type', 'url', 'repoUrl', 'sortIndex'], + columns: { + definition: [ + { key: 'title', isRowHeader: true }, + { key: 'type', width: 120 }, + { key: 'url', allowsSorting: false }, + { key: 'repoUrl', label: 'Repo', allowsSorting: false }, + { key: 'sortIndex', label: 'Sort', width: 80, align: 'end' }, + ], + defaultSort: { column: 'sortIndex', direction: 'ascending' }, + }, schema: { title: fields.slug({ name: { label: 'Title' } }), type: fields.select({ @@ -417,6 +434,13 @@ export default config({ label: 'Resources', path: 'src/content/resources/*', slugField: 'title', + columns: { + definition: [ + { key: 'title', isRowHeader: true }, + { key: 'sortIndex', label: 'Sort', width: 80, align: 'end' }, + ], + defaultSort: { column: 'sortIndex', direction: 'ascending' }, + }, schema: { title: fields.slug({ name: { label: 'Title' } }), type: fields.conditional( diff --git a/docs/src/components/markdoc-renderer.tsx b/docs/src/components/markdoc-renderer.tsx index db67b901f..e80659abe 100644 --- a/docs/src/components/markdoc-renderer.tsx +++ b/docs/src/components/markdoc-renderer.tsx @@ -145,6 +145,27 @@ const getRenderers = (highlighter: shiki.Highlighter | undefined) => ({ ); }, + Table: (props: { children: ReactNode }) => { + return ( + + {props.children} +
+ ); + }, CloudImage: ({ src, alt, diff --git a/docs/src/content/pages/collections.mdoc b/docs/src/content/pages/collections.mdoc index 24adc2bed..419115cee 100644 --- a/docs/src/content/pages/collections.mdoc +++ b/docs/src/content/pages/collections.mdoc @@ -1,16 +1,16 @@ --- title: Collections summary: >- - Think of a collection as anything you'd want multiple instances of. A series + Think of a collection as anything you’d want multiple instances of. A series of blog posts, cooking recipes, or testimonials from happy customers. --- -Think of a `collection` as anything you'd want multiple instances of. A series of blog posts, cooking recipes, or testimonials from happy customers. +Think of a `collection` as anything you’d want multiple instances of. A series of blog posts, cooking recipes, or testimonials from happy customers. Collections are defined within the `collections` key of the Keystatic `config`. Each collection has its own key and is wrapped in a `collection()` function. ## Example -Here's how you'd define a `testimonial` collection, where each entry has an `author` and a `quote` fields: +Here’s how you’d define a `testimonial` collection, where each entry has an `author` and a `quote` fields: ```jsx // keystatic.config.ts @@ -37,29 +37,84 @@ export default config({ ### Columns -`columns` — show additional fields in the collection list view. +By default, only the “slug” of each entry is displayed in the collection table. You can show additional fields by passing a `columns` option. -By default, only the `slug` of each entry is displayed in the collection list. +{% aside icon="⏱️" %} +Fetching data for each entry takes time. Large collections may benefit from displaying the slug only. +{% /aside %} -You can show additional fields by passing a `columns` option, which is an array of field keys: +#### Basic columns + +Pass an array of field keys to show them in the collection table: ```ts columns: ['title', 'publishedOn'] ``` -### Label +#### Columns definition + +For more control, use a `columns` object with a `definition` array. Column definitions require a `defaultSort` option. + +```ts +columns: { + definition: [ + { key: 'title', isRowHeader: true }, + { + key: 'publishedOn', + label: 'Published', + width: 140, + align: 'end' + }, + ], + defaultSort: { + column: 'publishedOn', + direction: 'descending' + } +} +``` + +{% table %} +- Property +- Description +--- +- `align` +- The alignment of the column’s contents relative to its allotted width. +--- +- `allowsSorting` +- Whether the column allows sorting. Defaults to `true`. +--- +- `isRowHeader` +- Whether a column is a [row header](https://www.w3.org/TR/wai-aria-1.1/#rowheader) and should be announced by assistive technology during row navigation. +--- +- `key` (required) +- The key of the column. +--- +- `label` +- The label of the column. Defaults to the label of the matching schema field, by `key`. +--- +- `maxWidth` +- The maximum width of the column. +--- +- `minWidth` +- The minimum width of the column. +--- +- `width` +- The width of the column. +{% /table %} + +### Label `label` — defines the name of the collection. This is used in the Admin UI to label the collection. ### Entry layout -`entryLayout` — change the layout of the Admin UI for a collection entry. +`entryLayout` — change the layout of the Admin UI for a collection entry. Learn more on the [Entry Layout](/docs/entry-layout) page. ### Format -`format` — provides options around the data format of your collection entries. +`format` — provides options around the data format of your collection entries. Learn more on the [Format Options](/docs/format-options) page. @@ -75,7 +130,7 @@ By default, Keystatic will store entries at the root of your project, in a direc You can learn more about the `path` option on the [Content organisation page](/docs/content-organisation). -### Parse slug for sort +### Parse slug for sort `parseSlugForSort` — a function to transform the `slug` of each entry into a value to be used for sorting the collection list view. @@ -83,15 +138,15 @@ You can learn more about the `path` option on the [Content organisation page](/d `previewURL` — used to configure [Real-time Previews](/docs/recipes/real-time-previews) of your content. -### Schema +### Schema `schema` — defines the fields that each entry in the collection should have. ### Slug field -`slugField` — defines what field in your collection `schema` should be used as the slug for each item. +`slugField` — defines what field in your collection `schema` should be used as the slug for each item. -It's recommended to combine it with the [slug field](/docs/fields/slug) to let users customise and regenerate each slug in the Admin UI. +It’s recommended to combine it with the [slug field](/docs/fields/slug) to let users customise and regenerate each slug in the Admin UI. ```typescript testimonials: collection({ @@ -105,7 +160,7 @@ testimonials: collection({ ### Template -`template` — the path to a content file (existing collection entry or "template") to use as a starting point for new entries. +`template` — the path to a content file (existing collection entry or “template”) to use as a starting point for new entries. --- diff --git a/packages/keystatic/src/app/CollectionPage.tsx b/packages/keystatic/src/app/CollectionPage.tsx index 45f1705f6..5f36295fa 100644 --- a/packages/keystatic/src/app/CollectionPage.tsx +++ b/packages/keystatic/src/app/CollectionPage.tsx @@ -1,4 +1,5 @@ import { useLocalizedStringFormatter } from '@react-aria/i18n'; +import { warning } from 'emery'; import { isHotkey } from 'is-hotkey'; import React, { Key, @@ -38,7 +39,7 @@ import { } from '@keystar/ui/table'; import { Heading, Text } from '@keystar/ui/typography'; -import { Config } from '../config'; +import { Collection, ColumnConfig, Config } from '../config'; import { sortBy } from './collection-sort'; import l10nMessages from './l10n/index.json'; import { useRouter } from './router'; @@ -279,8 +280,8 @@ function CollectionPageContent(props: CollectionPageContentProps) { return ; } -const SLUG = '@@slug'; -const STATUS = '@@status'; +const SLUG_KEY = '@@slug'; +const STATUS_KEY = '@@status'; function CollectionTable( props: CollectionPageContentProps & { @@ -296,17 +297,18 @@ function CollectionTable( const currentBranch = useCurrentBranch(); let isLocalMode = isLocalConfig(props.config); let router = useRouter(); - let [sortDescriptor, setSortDescriptor] = useState({ - column: SLUG, - direction: 'ascending', - }); - let hideStatusColumn = + + const collection = props.config.collections![props.collection]!; + const hideStatusColumn = isLocalMode || currentBranch === repoInfo?.defaultBranch; + const { columnData, defaultSort, includesCustomColumns } = useMemo(() => { + return getTableConfig(collection, hideStatusColumn); + }, [collection, hideStatusColumn]); + const [sortDescriptor, setSortDescriptor] = + useState(defaultSort); const baseCommit = useBaseCommit(); - const collection = props.config.collections![props.collection]!; - const entriesWithStatus = useMemo(() => { const defaultEntries = new Map( getEntriesInCollectionWithTreeKey( @@ -334,7 +336,10 @@ function CollectionTable( const mainFiles = useData( useCallback(async () => { - if (!collection.columns?.length) return undefined; + if (!includesCustomColumns) { + return undefined; + } + const formatInfo = getCollectionFormat(props.config, props.collection); const entries = await Promise.all( entriesWithStatus.map(async entry => { @@ -398,11 +403,12 @@ function CollectionTable( }) ); }, [ + baseCommit, collection, - props.config, - props.collection, entriesWithStatus, - baseCommit, + includesCustomColumns, + props.collection, + props.config, repoInfo, ]) ); @@ -436,10 +442,10 @@ function CollectionTable( row: typeof a, other: Record | undefined ) => { - if (sortDescriptor.column === SLUG) { + if (sortDescriptor.column === SLUG_KEY) { return collection.parseSlugForSort?.(row.name) ?? row.name; } - if (sortDescriptor.column === STATUS) { + if (sortDescriptor.column === STATUS_KEY) { return row.status; } return other?.[sortDescriptor.column!] ?? row.name; @@ -459,33 +465,6 @@ function CollectionTable( sortDescriptor.direction, ]); - const columns = useMemo(() => { - if (collection.columns?.length) { - return [ - ...(hideStatusColumn - ? [] - : [{ name: 'Status', key: STATUS, minWidth: 32, width: 32 }]), - { - name: 'Slug', - key: SLUG, - }, - ...collection.columns.map(column => { - const schema = collection.schema[column]; - return { - name: ('label' in schema && schema.label) || column, - key: column, - }; - }), - ]; - } - return hideStatusColumn - ? [{ name: 'Name', key: SLUG }] - : [ - { name: 'Status', key: STATUS, minWidth: 32, width: 32 }, - { name: 'Name', key: SLUG }, - ]; - }, [collection, hideStatusColumn]); - return ( - - {({ name, key, ...options }) => - key === STATUS ? ( - + + {({ label, key, ...options }) => + key === STATUS_KEY ? ( + ) : ( - - {name} + + {label} ) } @@ -538,7 +517,7 @@ function CollectionTable( {item => { const statusCell = ( - + {item.status === 'Added' ? ( ) : item.status === 'Changed' ? ( @@ -547,35 +526,37 @@ function CollectionTable( ); const nameCell = ( - + {item.name as string} ); - if (collection.columns?.length) { + + if (includesCustomColumns) { return ( - {[ - ...(hideStatusColumn ? [] : [statusCell]), - nameCell, - ...collection.columns.map(column => { - let val; - val = item.data?.[column]; - - if (val == null) { - val = undefined; - } else { - val = val + ''; - } - return ( - - {val} - - ); - }), - ]} + {columnData.map(column => { + if (column.key === STATUS_KEY) { + return statusCell; + } + + let val; + val = item.data?.[column.key]; + + if (val == null) { + val = undefined; + } else { + val = val + ''; + } + return ( + + {val} + + ); + })} ); } + return hideStatusColumn ? ( {nameCell} ) : ( @@ -608,3 +589,83 @@ export function useDebouncedValue(value: T, delay = 300): T { return debouncedValue; } + +function getTableConfig( + collection: Collection, + hideStatusColumn: boolean +): { + columnData: ColumnConfig[]; + defaultSort: SortDescriptor; + includesCustomColumns: boolean; +} { + let defaultColumns = hideStatusColumn + ? [] + : [ + { + allowsSorting: false, + key: STATUS_KEY, + label: 'Status', + minWidth: 32, + width: 32, + }, + ]; + + if (Array.isArray(collection.columns) && collection.columns.length > 0) { + return { + columnData: [ + ...defaultColumns, + ...collection.columns.map(key => { + const schema = collection.schema[key]; + return { + key, + label: ('label' in schema && schema.label) || key, + }; + }), + ], + defaultSort: { + column: collection.columns[0], + direction: 'ascending', + }, + includesCustomColumns: true, + }; + } + + if (collection.columns && 'definition' in collection.columns) { + const hasRowHeader = collection.columns.definition.some( + column => column.isRowHeader + ); + warning( + hasRowHeader, + 'To best support screen reader users, please set the `isRowHeader` property for at least one column in the definition.' + ); + + return { + columnData: [ + ...defaultColumns, + ...collection.columns.definition.map(({ key, ...rest }, index) => { + const schema = collection.schema[key]; + return { + key, + label: ('label' in schema && schema.label) || key, + isRowHeader: !hasRowHeader && index === 0 ? true : undefined, + ...rest, + }; + }), + ], + defaultSort: collection.columns.defaultSort, + includesCustomColumns: true, + }; + } + + return { + columnData: [ + ...defaultColumns, + { isRowHeader: true, key: SLUG_KEY, label: 'Slug' }, + ], + defaultSort: { + column: SLUG_KEY, + direction: 'ascending', + }, + includesCustomColumns: false, + }; +} diff --git a/packages/keystatic/src/config.tsx b/packages/keystatic/src/config.tsx index 4c95368f3..5b719b127 100644 --- a/packages/keystatic/src/config.tsx +++ b/packages/keystatic/src/config.tsx @@ -26,7 +26,7 @@ export type Collection< entryLayout?: EntryLayout; format?: Format; previewUrl?: string; - columns?: string[]; + columns?: ColumnsConfig; template?: string; parseSlugForSort?: (slug: string) => string | number; slugField: SlugField; @@ -74,6 +74,50 @@ type UserInterface = { type Navigation = K[] | { [section: string]: K[] }; +// Columns +// ---------------------------------------------------------------------------- +type Columns = { + /** The default field and direction used to initially sort the data. */ + defaultSort: SortDescriptor; + /** Defines which fields to render. */ + definition: ColumnConfig[]; +}; +export type ColumnConfig = { + /** + * The alignment of the column's contents relative to its allotted width. + * @default 'start' + */ + align?: 'start' | 'center' | 'end'; + /** Whether the column allows sorting. */ + allowsSorting?: boolean; + /** + * Whether a column is a [row header](https://www.w3.org/TR/wai-aria-1.1/#rowheader) and should be announced by assistive + * technology during row navigation. + */ + isRowHeader?: boolean; + /** The key of the column. */ + key: Key; + /** + * The label of the column. Defaults to the label of the matching schema + * field, by `key`. + */ + label?: string; + /** The maximum width of the column. */ + maxWidth?: ColumnWidth; + /** The minimum width of the column. */ + minWidth?: ColumnWidth; + /** The width of the column. */ + width?: ColumnWidth; +}; +type ColumnWidth = number | `${number}%`; + +type SortDescriptor = { + /** The key of the column to sort by. */ + column: Key; + /** The direction to sort by. */ + direction: 'ascending' | 'descending'; +}; + // Storage // ---------------------------------------------------------------------------- @@ -172,6 +216,8 @@ export function config< return config; } +type ColumnsConfig = Columns | FieldKey[]; + export function collection< Schema extends Record, SlugField extends { @@ -181,17 +227,19 @@ export function collection< }[keyof Schema], >( collection: Collection & { - columns?: { - [K in keyof Schema]: Schema[K] extends - | FormField< - any, - any, - string | number | boolean | Date | null | undefined - > - | SlugFormField - ? K & string - : never; - }[keyof Schema][]; + columns?: ColumnsConfig< + { + [K in keyof Schema]: Schema[K] extends + | FormField< + any, + any, + string | number | boolean | Date | null | undefined + > + | SlugFormField + ? K & string + : never; + }[keyof Schema] + >; } ): Collection { return collection;