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 (
+
+ );
+ },
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;
| |