Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/custom-collection-sorting.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@blinkk/root-cms': patch
---

feat: add custom document ordering to collections
5 changes: 5 additions & 0 deletions packages/root-cms/cli/generate-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,11 @@ export interface RootCMSDoc<Fields extends {}> {
publishedAt?: number;
publishedBy?: string;
locales?: string[];
/**
* Fractional-index string defining the doc's custom order within the
* collection. See the \`customSorting\` collection option.
*/
sortKey?: string;
};
/** User-entered field values from the CMS. */
fields?: Fields;
Expand Down
1 change: 1 addition & 0 deletions packages/root-cms/core/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ function serializeCollection(collection: Collection): Partial<Collection> {
autolock: collection.autolock,
autolockReason: collection.autolockReason,
sortOptions: collection.sortOptions,
customSorting: collection.customSorting,
viewOptions: collection.viewOptions,
};
}
Expand Down
114 changes: 114 additions & 0 deletions packages/root-cms/core/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -770,4 +770,118 @@ describe('RootCMSClient Validation', () => {
);
});
});

describe('listDocs custom sorting', () => {
/**
* Creates a chainable firestore query mock that records `orderBy()` calls
* and returns an empty result set.
*/
function createQueryMock() {
const orderByCalls: any[] = [];
const queryMock: any = {
limit: vi.fn(() => queryMock),
offset: vi.fn(() => queryMock),
orderBy: vi.fn((...args: any[]) => {
orderByCalls.push(args);
return queryMock;
}),
where: vi.fn(() => queryMock),
get: vi.fn(async () => ({forEach: () => {}})),
};
return {queryMock, orderByCalls};
}

async function createClient(queryMock: any) {
const {RootCMSClient} = await import('./client.js');
const client = new RootCMSClient(mockRootConfig);
(client as any).db = {collection: vi.fn(() => queryMock)};
return client;
}

it('defaults to orderBy sys.sortKey when customSorting is enabled', async () => {
const {queryMock, orderByCalls} = createQueryMock();
const client = await createClient(queryMock);
mockGetCollectionSchema.mockResolvedValue({
name: 'Products',
customSorting: true,
fields: [],
});

await client.listDocs('Products', {mode: 'published'});

expect(orderByCalls).toEqual([['sys.sortKey', undefined]]);
});

it('does not order when customSorting is not enabled', async () => {
const {queryMock, orderByCalls} = createQueryMock();
const client = await createClient(queryMock);
mockGetCollectionSchema.mockResolvedValue({name: 'Pages', fields: []});

await client.listDocs('Pages', {mode: 'published'});

expect(orderByCalls).toEqual([]);
});

it('explicit orderBy takes precedence over the custom sorting default', async () => {
const {queryMock, orderByCalls} = createQueryMock();
const client = await createClient(queryMock);
mockGetCollectionSchema.mockResolvedValue({
name: 'Products',
customSorting: true,
fields: [],
});

await client.listDocs('Products', {
mode: 'published',
orderBy: 'sys.createdAt',
orderByDirection: 'desc',
});

expect(orderByCalls).toEqual([['sys.createdAt', 'desc']]);
});

it('skips the custom sorting default when a query fn is provided', async () => {
const {queryMock, orderByCalls} = createQueryMock();
const client = await createClient(queryMock);
mockGetCollectionSchema.mockResolvedValue({
name: 'Products',
customSorting: true,
fields: [],
});

await client.listDocs('Products', {
mode: 'published',
query: (q: any) => q.where('sys.locales', 'array-contains', 'en'),
});

expect(orderByCalls).toEqual([]);
expect(queryMock.where).toHaveBeenCalled();
});

it('degrades to no default when the collection schema fails to load', async () => {
const {queryMock, orderByCalls} = createQueryMock();
const client = await createClient(queryMock);
mockGetCollectionSchema.mockRejectedValue(new Error('no schemas'));

await expect(
client.listDocs('Products', {mode: 'published'})
).resolves.toEqual({docs: []});
expect(orderByCalls).toEqual([]);
});

it('memoizes the customSorting lookup per collection', async () => {
const {queryMock} = createQueryMock();
const client = await createClient(queryMock);
mockGetCollectionSchema.mockResolvedValue({
name: 'Products',
customSorting: true,
fields: [],
});

await client.listDocs('Products', {mode: 'published'});
await client.listDocs('Products', {mode: 'draft'});

expect(mockGetCollectionSchema).toHaveBeenCalledTimes(1);
});
});
});
50 changes: 48 additions & 2 deletions packages/root-cms/core/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ export interface Doc<Fields = any> {
* (re)computed whenever the doc draft is saved in the CMS UI.
*/
assets?: string[];
/**
* Fractional-index string defining the doc's custom order within the
* collection. See the `customSorting` collection option.
*/
sortKey?: string;
};
fields: Fields;
}
Expand Down Expand Up @@ -177,6 +182,13 @@ export interface ListDocsOptions {
mode: DocMode;
offset?: number;
limit?: number;
/**
* DB field path to order results by, e.g. `sys.createdAt`.
*
* For collections with the `customSorting` option, when `orderBy` and
* `query` are both unset, results default to the custom order (i.e.
* `orderBy: 'sys.sortKey'`). Pass an explicit `orderBy` to override.
*/
orderBy?: string;
orderByDirection?: 'asc' | 'desc';
query?: (query: Query) => Query;
Expand Down Expand Up @@ -266,6 +278,8 @@ export class RootCMSClient {
readonly projectId: string;
readonly app: App;
readonly db: Firestore;
/** Memoized `customSorting` collection option, see `hasCustomSorting()`. */
private _customSortingCache = new Map<string, boolean>();

constructor(rootConfig: RootConfig) {
this.rootConfig = rootConfig;
Expand Down Expand Up @@ -388,6 +402,26 @@ export class RootCMSClient {
return await project.getCollectionSchema(collectionId);
}

/**
* Returns whether a collection has the `customSorting` option enabled.
* The result is memoized per collection since this is called on the
* `listDocs()` hot path. Returns false when the collection schema cannot
* be loaded (e.g. in standalone scripts outside the vite server).
*/
private async hasCustomSorting(collectionId: string): Promise<boolean> {
let customSorting = this._customSortingCache.get(collectionId);
if (customSorting === undefined) {
try {
const collection = await this.getCollection(collectionId);
customSorting = Boolean(collection?.customSorting);
} catch {
customSorting = false;
}
this._customSortingCache.set(collectionId, customSorting);
}
return customSorting;
}

/**
* Saves draft data to a doc.
*
Expand Down Expand Up @@ -603,8 +637,20 @@ export class RootCMSClient {
if (options.offset) {
query = query.offset(options.offset);
}
if (options.orderBy) {
query = query.orderBy(options.orderBy, options.orderByDirection);
let orderBy = options.orderBy;
// Collections with `customSorting` default to the custom order. The
// default is skipped when a `query` fn is provided, since adding an
// orderBy to a filtered query may require a composite index (and would
// exclude docs that don't have a `sys.sortKey`).
if (
!orderBy &&
!options.query &&
(await this.hasCustomSorting(collectionId))
) {
orderBy = 'sys.sortKey';
}
if (orderBy) {
query = query.orderBy(orderBy, options.orderByDirection);
}
if (options.query) {
query = options.query(query);
Expand Down
1 change: 1 addition & 0 deletions packages/root-cms/core/core.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from '../shared/sort-key.js';
export * from './client.js';
export * from './middleware.js';
export * from './route.js';
Expand Down
2 changes: 1 addition & 1 deletion packages/root-cms/core/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ export function getCollectionSchema(

const fileId = `/collections/${collectionId}.schema.ts`;
const module = SCHEMA_MODULES[fileId];
if (!module.default) {
if (!module?.default) {
console.warn(`collection schema not exported in: ${fileId}`);
return null;
}
Expand Down
5 changes: 5 additions & 0 deletions packages/root-cms/core/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ export interface RootCMSDoc<Fields = any> {
publishedAt?: number;
publishedBy?: string;
locales?: string[];
/**
* Fractional-index string defining the doc's custom order within the
* collection. See the `customSorting` collection option.
*/
sortKey?: string;
};
/** User-entered field values from the CMS. */
fields?: Fields;
Expand Down
32 changes: 32 additions & 0 deletions packages/root-cms/core/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -610,6 +610,38 @@ export type Collection = SchemaWithTypes & {
/** Sort direction. Defaults to ascending. */
direction?: 'asc' | 'desc';
}>;
/**
* Enables custom (user-defined) ordering of docs in the collection, e.g.
* for curated listings like storefronts.
*
* When enabled, the CMS shows a "Custom order" sort option where editors
* can drag docs (or use "Move to top/bottom") to reorder them. The order is
* stored as a fractional-index string at `sys.sortKey` on the draft doc.
* Like any other edit, reordering updates `sys.modifiedAt`, and docs need
* to be published for the new order to take effect on live (published)
* listings.
*
* On the server, `listDocs()` returns docs in the custom order by default
* for collections with this option (when no `orderBy` or `query` option is
* passed). To order explicitly, use `listDocs(id, {orderBy:
* 'sys.sortKey'})`.
*
* NOTE: firestore's `orderBy()` excludes docs that don't have the ordered
* field. Docs created outside the CMS (e.g. import scripts using
* `saveDraftData()`) have no `sys.sortKey` and are excluded from ordered
* results until positions are assigned — the CMS shows an "assign
* positions" banner for this (which also serves as the one-time
* initialization when enabling this option on an existing collection).
* Import scripts can assign keys themselves using `generateKeyBetween()` /
* `generateNKeysBetween()` / `generateKeyAfter()` exported from
* `@blinkk/root-cms`.
*
* NOTE: sorting by `sys.sortKey` alone uses firestore's automatic
* single-field index. As with `sortOptions`, combining it with a `where()`
* filter may require a new composite index (the first such query returns an
* error with a link for creating the index).
*/
customSorting?: boolean;
/**
* Options that control how the document listing for this collection is
* rendered in the CMS.
Expand Down
Loading
Loading