From 6001bc921861dfaa82f302d9ebcfd102c9c3c9fd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 00:16:22 +0000 Subject: [PATCH 1/4] feat: manual document ordering in collections Adds a `manualSorting` collection option that lets editors manually order docs in a collection (e.g. storefronts) with the order preserved when listing from the API. - Order is stored as a fractional-index string at `sys.sortKey` on both the Drafts and Published copies, so a reorder is a single-doc write and applies to live listings immediately without publishing (and without marking docs as edited). - The CMS gets a "Manual order" sort with drag-and-drop rows (both densities, keyboard accessible) plus "Move to top/bottom" actions, and an "Assign positions" banner that initializes keys for docs created before the option was enabled (or by import scripts). - `listDocs()` defaults to manual order for opted-in collections when no `orderBy`/`query` is passed; `orderBy: 'sys.sortKey'` also works explicitly. New docs are appended to the end of the manual order. - The fractional-indexing helpers (`generateKeyBetween`, `generateNKeysBetween`, `generateKeyAfter`) are vendored from rocicorp/fractional-indexing (MIT) and exported for import scripts. Closes #1303 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018Xgdd2x1BnTarkWKftet4q --- .changeset/manual-collection-sorting.md | 15 + packages/root-cms/cli/generate-types.ts | 5 + packages/root-cms/core/app.tsx | 1 + packages/root-cms/core/client.test.ts | 114 ++++++ packages/root-cms/core/client.ts | 50 ++- packages/root-cms/core/core.ts | 1 + packages/root-cms/core/project.ts | 2 +- packages/root-cms/core/route.ts | 5 + packages/root-cms/core/schema.ts | 31 ++ packages/root-cms/shared/sort-key.test.ts | 164 +++++++++ packages/root-cms/shared/sort-key.ts | 289 +++++++++++++++ .../DocActionsMenu/DocActionsMenu.tsx | 26 ++ packages/root-cms/ui/hooks/useDocsList.ts | 14 +- .../pages/CollectionPage/CollectionPage.css | 64 ++++ .../pages/CollectionPage/CollectionPage.tsx | 348 +++++++++++++++++- .../CollectionPage.visual.test.tsx | 190 ++++++++++ packages/root-cms/ui/utils/doc-sort.test.ts | 64 ++++ packages/root-cms/ui/utils/doc-sort.ts | 55 +++ packages/root-cms/ui/utils/doc.test.ts | 225 ++++++++++- packages/root-cms/ui/utils/doc.ts | 159 ++++++++ 20 files changed, 1796 insertions(+), 26 deletions(-) create mode 100644 .changeset/manual-collection-sorting.md create mode 100644 packages/root-cms/shared/sort-key.test.ts create mode 100644 packages/root-cms/shared/sort-key.ts create mode 100644 packages/root-cms/ui/pages/CollectionPage/CollectionPage.visual.test.tsx create mode 100644 packages/root-cms/ui/utils/doc-sort.test.ts create mode 100644 packages/root-cms/ui/utils/doc-sort.ts diff --git a/.changeset/manual-collection-sorting.md b/.changeset/manual-collection-sorting.md new file mode 100644 index 000000000..cc7e2e864 --- /dev/null +++ b/.changeset/manual-collection-sorting.md @@ -0,0 +1,15 @@ +--- +'@blinkk/root-cms': minor +--- + +feat: manual document ordering in collections (`manualSorting` collection option) + +Collections can now opt into manual ordering via `manualSorting: true` in the +collection schema. Editors get a "Manual order" sort in the CMS with +drag-to-reorder (plus "Move to top/bottom" actions), stored as a +fractional-index string at `sys.sortKey` on both the draft and published +copies of each doc so reordering applies to live listings immediately. +`listDocs()` returns docs in manual order by default for opted-in collections +(or explicitly via `orderBy: 'sys.sortKey'`), and the fractional-indexing +helpers (`generateKeyBetween`, `generateNKeysBetween`, `generateKeyAfter`) +are exported for import scripts. diff --git a/packages/root-cms/cli/generate-types.ts b/packages/root-cms/cli/generate-types.ts index ac87d4d9c..6de7833bc 100644 --- a/packages/root-cms/cli/generate-types.ts +++ b/packages/root-cms/cli/generate-types.ts @@ -75,6 +75,11 @@ export interface RootCMSDoc { publishedAt?: number; publishedBy?: string; locales?: string[]; + /** + * Fractional-index string defining the doc's manual order within the + * collection. See the \`manualSorting\` collection option. + */ + sortKey?: string; }; /** User-entered field values from the CMS. */ fields?: Fields; diff --git a/packages/root-cms/core/app.tsx b/packages/root-cms/core/app.tsx index 302d1c394..464b6dfcf 100644 --- a/packages/root-cms/core/app.tsx +++ b/packages/root-cms/core/app.tsx @@ -186,6 +186,7 @@ function serializeCollection(collection: Collection): Partial { autolock: collection.autolock, autolockReason: collection.autolockReason, sortOptions: collection.sortOptions, + manualSorting: collection.manualSorting, viewOptions: collection.viewOptions, }; } diff --git a/packages/root-cms/core/client.test.ts b/packages/root-cms/core/client.test.ts index 02233391b..5fc3da47b 100644 --- a/packages/root-cms/core/client.test.ts +++ b/packages/root-cms/core/client.test.ts @@ -770,4 +770,118 @@ describe('RootCMSClient Validation', () => { ); }); }); + + describe('listDocs manual 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 manualSorting is enabled', async () => { + const {queryMock, orderByCalls} = createQueryMock(); + const client = await createClient(queryMock); + mockGetCollectionSchema.mockResolvedValue({ + name: 'Products', + manualSorting: true, + fields: [], + }); + + await client.listDocs('Products', {mode: 'published'}); + + expect(orderByCalls).toEqual([['sys.sortKey', undefined]]); + }); + + it('does not order when manualSorting 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 manual sorting default', async () => { + const {queryMock, orderByCalls} = createQueryMock(); + const client = await createClient(queryMock); + mockGetCollectionSchema.mockResolvedValue({ + name: 'Products', + manualSorting: true, + fields: [], + }); + + await client.listDocs('Products', { + mode: 'published', + orderBy: 'sys.createdAt', + orderByDirection: 'desc', + }); + + expect(orderByCalls).toEqual([['sys.createdAt', 'desc']]); + }); + + it('skips the manual sorting default when a query fn is provided', async () => { + const {queryMock, orderByCalls} = createQueryMock(); + const client = await createClient(queryMock); + mockGetCollectionSchema.mockResolvedValue({ + name: 'Products', + manualSorting: 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 manualSorting lookup per collection', async () => { + const {queryMock} = createQueryMock(); + const client = await createClient(queryMock); + mockGetCollectionSchema.mockResolvedValue({ + name: 'Products', + manualSorting: true, + fields: [], + }); + + await client.listDocs('Products', {mode: 'published'}); + await client.listDocs('Products', {mode: 'draft'}); + + expect(mockGetCollectionSchema).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/packages/root-cms/core/client.ts b/packages/root-cms/core/client.ts index 71282c2f1..e62a82ebf 100644 --- a/packages/root-cms/core/client.ts +++ b/packages/root-cms/core/client.ts @@ -51,6 +51,11 @@ export interface Doc { * (re)computed whenever the doc draft is saved in the CMS UI. */ assets?: string[]; + /** + * Fractional-index string defining the doc's manual order within the + * collection. See the `manualSorting` collection option. + */ + sortKey?: string; }; fields: Fields; } @@ -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 `manualSorting` option, when `orderBy` and + * `query` are both unset, results default to the manual order (i.e. + * `orderBy: 'sys.sortKey'`). Pass an explicit `orderBy` to override. + */ orderBy?: string; orderByDirection?: 'asc' | 'desc'; query?: (query: Query) => Query; @@ -266,6 +278,8 @@ export class RootCMSClient { readonly projectId: string; readonly app: App; readonly db: Firestore; + /** Memoized `manualSorting` collection option, see `hasManualSorting()`. */ + private _manualSortingCache = new Map(); constructor(rootConfig: RootConfig) { this.rootConfig = rootConfig; @@ -388,6 +402,26 @@ export class RootCMSClient { return await project.getCollectionSchema(collectionId); } + /** + * Returns whether a collection has the `manualSorting` 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 hasManualSorting(collectionId: string): Promise { + let manualSorting = this._manualSortingCache.get(collectionId); + if (manualSorting === undefined) { + try { + const collection = await this.getCollection(collectionId); + manualSorting = Boolean(collection?.manualSorting); + } catch { + manualSorting = false; + } + this._manualSortingCache.set(collectionId, manualSorting); + } + return manualSorting; + } + /** * Saves draft data to a doc. * @@ -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 `manualSorting` default to the manual 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.hasManualSorting(collectionId)) + ) { + orderBy = 'sys.sortKey'; + } + if (orderBy) { + query = query.orderBy(orderBy, options.orderByDirection); } if (options.query) { query = options.query(query); diff --git a/packages/root-cms/core/core.ts b/packages/root-cms/core/core.ts index 4837e0e30..8e9cbf01b 100644 --- a/packages/root-cms/core/core.ts +++ b/packages/root-cms/core/core.ts @@ -1,3 +1,4 @@ +export * from '../shared/sort-key.js'; export * from './client.js'; export * from './middleware.js'; export * from './route.js'; diff --git a/packages/root-cms/core/project.ts b/packages/root-cms/core/project.ts index 82545333e..28874b706 100644 --- a/packages/root-cms/core/project.ts +++ b/packages/root-cms/core/project.ts @@ -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; } diff --git a/packages/root-cms/core/route.ts b/packages/root-cms/core/route.ts index f9b6f2ef4..30d928729 100644 --- a/packages/root-cms/core/route.ts +++ b/packages/root-cms/core/route.ts @@ -54,6 +54,11 @@ export interface RootCMSDoc { publishedAt?: number; publishedBy?: string; locales?: string[]; + /** + * Fractional-index string defining the doc's manual order within the + * collection. See the `manualSorting` collection option. + */ + sortKey?: string; }; /** User-entered field values from the CMS. */ fields?: Fields; diff --git a/packages/root-cms/core/schema.ts b/packages/root-cms/core/schema.ts index 021e612f1..972409c9e 100644 --- a/packages/root-cms/core/schema.ts +++ b/packages/root-cms/core/schema.ts @@ -610,6 +610,37 @@ export type Collection = SchemaWithTypes & { /** Sort direction. Defaults to ascending. */ direction?: 'asc' | 'desc'; }>; + /** + * Enables manual ordering of docs in the collection, e.g. for curated + * listings like storefronts. + * + * When enabled, the CMS shows a "Manual 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 both the draft + * and published copies of the doc, so reordering applies to live listings + * immediately without publishing (and without marking docs as edited). + * + * On the server, `listDocs()` returns docs in manual 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). + */ + manualSorting?: boolean; /** * Options that control how the document listing for this collection is * rendered in the CMS. diff --git a/packages/root-cms/shared/sort-key.test.ts b/packages/root-cms/shared/sort-key.test.ts new file mode 100644 index 000000000..d3aac00b5 --- /dev/null +++ b/packages/root-cms/shared/sort-key.test.ts @@ -0,0 +1,164 @@ +import {describe, it, expect} from 'vitest'; +import { + compareSortKeys, + generateKeyAfter, + generateKeyBetween, + generateNKeysBetween, +} from './sort-key.js'; + +describe('generateKeyBetween', () => { + it('returns the initial key when both bounds are null', () => { + expect(generateKeyBetween(null, null)).toBe('a0'); + }); + + it('generates keys strictly between the bounds', () => { + const a = generateKeyBetween(null, null); + const b = generateKeyBetween(a, null); + const mid = generateKeyBetween(a, b); + expect(a < mid).toBe(true); + expect(mid < b).toBe(true); + const before = generateKeyBetween(null, a); + expect(before < a).toBe(true); + }); + + it('throws when a >= b', () => { + expect(() => generateKeyBetween('a1', 'a1')).toThrow(); + expect(() => generateKeyBetween('a2', 'a1')).toThrow(); + }); + + it('throws on malformed keys', () => { + expect(() => generateKeyBetween('a10', null)).toThrow(); // trailing zero + expect(() => generateKeyBetween('!', null)).toThrow(); // bad head + expect(() => generateKeyBetween('b1', null)).toThrow(); // truncated integer + }); + + it('never generates keys with a trailing zero', () => { + const prev = generateKeyBetween(null, null); + let next = generateKeyBetween(prev, null); + for (let i = 0; i < 100; i++) { + const mid = generateKeyBetween(prev, next); + expect(mid.endsWith('0')).toBe(false); + next = mid; + } + }); + + it('stays ordered over long append chains', () => { + const keys: string[] = []; + let key: string | null = null; + for (let i = 0; i < 1000; i++) { + key = generateKeyBetween(key, null); + keys.push(key); + } + for (let i = 1; i < keys.length; i++) { + expect(keys[i - 1] < keys[i]).toBe(true); + } + }); + + it('stays ordered over long prepend chains', () => { + const keys: string[] = []; + let key: string | null = null; + for (let i = 0; i < 1000; i++) { + key = generateKeyBetween(null, key); + keys.unshift(key); + } + for (let i = 1; i < keys.length; i++) { + expect(keys[i - 1] < keys[i]).toBe(true); + } + }); + + it('stays ordered over repeated same-gap insertions', () => { + const a = generateKeyBetween(null, null); + let b = generateKeyBetween(a, null); + for (let i = 0; i < 100; i++) { + const mid = generateKeyBetween(a, b); + expect(a < mid).toBe(true); + expect(mid < b).toBe(true); + b = mid; + } + }); + + it('stays ordered over randomized insertions', () => { + // Deterministic PRNG so failures are reproducible. + let seed = 42; + const random = () => { + seed = (seed * 16807) % 2147483647; + return seed / 2147483647; + }; + const keys = [generateKeyBetween(null, null)]; + for (let i = 0; i < 500; i++) { + const index = Math.floor(random() * (keys.length + 1)); + const a = index === 0 ? null : keys[index - 1]; + const b = index === keys.length ? null : keys[index]; + keys.splice(index, 0, generateKeyBetween(a, b)); + } + for (let i = 1; i < keys.length; i++) { + expect(keys[i - 1] < keys[i]).toBe(true); + } + }); +}); + +describe('generateNKeysBetween', () => { + it('handles n=0 and n=1', () => { + expect(generateNKeysBetween(null, null, 0)).toEqual([]); + expect(generateNKeysBetween(null, null, 1)).toEqual(['a0']); + }); + + it('returns n distinct sorted keys between the bounds', () => { + const testCases: Array<[string | null, string | null]> = [ + [null, null], + ['a0', null], + [null, 'a0'], + ['a0', 'a1'], + ['a1', 'a2'], + ['a0V', 'a1'], + ]; + for (const [a, b] of testCases) { + const keys = generateNKeysBetween(a, b, 25); + expect(keys.length).toBe(25); + for (let i = 0; i < keys.length; i++) { + if (i > 0) { + expect(keys[i - 1] < keys[i]).toBe(true); + } + if (a !== null) { + expect(a < keys[i]).toBe(true); + } + if (b !== null) { + expect(keys[i] < b).toBe(true); + } + } + } + }); +}); + +describe('generateKeyAfter', () => { + it('generates a key after the given key', () => { + expect(generateKeyAfter(null)).toBe('a0'); + expect(generateKeyAfter('a0')).toBe('a1'); + const key = generateKeyAfter('b0z'); + expect('b0z' < key).toBe(true); + }); +}); + +describe('compareSortKeys', () => { + it('compares by code point, matching firestore string ordering', () => { + // Digits < uppercase < lowercase in ascii order. + expect(compareSortKeys('Zz', 'a0')).toBeLessThan(0); + expect(compareSortKeys('a0', 'a0V')).toBeLessThan(0); + expect(compareSortKeys('a0V', 'a1')).toBeLessThan(0); + expect(compareSortKeys('a1', 'b00')).toBeLessThan(0); + expect(compareSortKeys('a1', 'a1')).toBe(0); + expect(compareSortKeys('a2', 'a1')).toBeGreaterThan(0); + }); + + it('sorts arrays the same as the generation order', () => { + const keys: string[] = []; + let key: string | null = null; + for (let i = 0; i < 50; i++) { + key = generateKeyBetween(key, null); + keys.push(key); + } + const shuffled = [...keys].reverse(); + shuffled.sort(compareSortKeys); + expect(shuffled).toEqual(keys); + }); +}); diff --git a/packages/root-cms/shared/sort-key.ts b/packages/root-cms/shared/sort-key.ts new file mode 100644 index 000000000..86b7f4af6 --- /dev/null +++ b/packages/root-cms/shared/sort-key.ts @@ -0,0 +1,289 @@ +/** + * Fractional indexing utilities used for manually ordering docs within a + * collection (see the `manualSorting` collection option). + * + * A "sort key" is an opaque base-62 string. Sorting keys in ascending order + * (by code point) yields the manual order. `generateKeyBetween()` returns a + * key strictly between two neighboring keys, so moving an item only requires + * writing a new key to that one item. + * + * The base-62 digit alphabet is in ascending code-point order, which matches + * how Firestore orders string fields, so `orderBy('sys.sortKey')` and + * in-memory sorting with `compareSortKeys()` always agree. + * + * Vendored from https://github.com/rocicorp/fractional-indexing (MIT + * license), based on the implementation described in + * https://observablehq.com/@dgreensp/implementing-fractional-indexing. + */ + +const BASE_62_DIGITS = + '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; + +const ZERO = BASE_62_DIGITS[0]; +const SMALLEST_INTEGER = 'A' + ZERO.repeat(26); +const INTEGER_ZERO = 'a' + ZERO; + +/** + * Returns a key that sorts strictly between `a` and `b` (by code point). + * Pass `a = null` for "before everything" and `b = null` for "after + * everything"; `generateKeyBetween(null, null)` returns the initial key. + * Throws if `a >= b` or if either key is malformed. + */ +export function generateKeyBetween(a: string | null, b: string | null): string { + if (a !== null) { + validateSortKey(a); + } + if (b !== null) { + validateSortKey(b); + } + if (a !== null && b !== null && a >= b) { + throw new Error(`invalid sort key range: ${a} >= ${b}`); + } + if (a === null) { + if (b === null) { + return INTEGER_ZERO; + } + const ib = getIntegerPart(b); + const fb = b.slice(ib.length); + if (ib === SMALLEST_INTEGER) { + return ib + midpoint('', fb); + } + if (ib < b) { + return ib; + } + const res = decrementInteger(ib); + if (res === null) { + throw new Error('cannot decrement any more'); + } + return res; + } + + if (b === null) { + const ia = getIntegerPart(a); + const fa = a.slice(ia.length); + const i = incrementInteger(ia); + return i === null ? ia + midpoint(fa, null) : i; + } + + const ia = getIntegerPart(a); + const fa = a.slice(ia.length); + const ib = getIntegerPart(b); + const fb = b.slice(ib.length); + if (ia === ib) { + return ia + midpoint(fa, fb); + } + const i = incrementInteger(ia); + if (i === null) { + throw new Error('cannot increment any more'); + } + if (i < b) { + return i; + } + return ia + midpoint(fa, null); +} + +/** + * Returns `n` distinct keys in sorted order, all strictly between `a` and `b` + * (same semantics as {@link generateKeyBetween}). Used for bulk-assigning + * positions, e.g. when initializing the manual order of an existing + * collection. + */ +export function generateNKeysBetween( + a: string | null, + b: string | null, + n: number +): string[] { + if (n === 0) { + return []; + } + if (n === 1) { + return [generateKeyBetween(a, b)]; + } + if (b === null) { + let c = generateKeyBetween(a, b); + const result = [c]; + for (let i = 0; i < n - 1; i++) { + c = generateKeyBetween(c, b); + result.push(c); + } + return result; + } + if (a === null) { + let c = generateKeyBetween(a, b); + const result = [c]; + for (let i = 0; i < n - 1; i++) { + c = generateKeyBetween(a, c); + result.push(c); + } + result.reverse(); + return result; + } + const mid = Math.floor(n / 2); + const c = generateKeyBetween(a, b); + return [ + ...generateNKeysBetween(a, c, mid), + c, + ...generateNKeysBetween(c, b, n - mid - 1), + ]; +} + +/** + * Returns a key that sorts after `a` (e.g. after the current max key to + * append an item to the end of the list). Pass `null` when the list has no + * keys yet. + */ +export function generateKeyAfter(a: string | null): string { + return generateKeyBetween(a, null); +} + +/** + * Compares two sort keys by code point (the same ordering Firestore uses for + * string fields). Intentionally not `localeCompare()`, whose locale-aware + * collation can disagree with Firestore's byte ordering. + */ +export function compareSortKeys(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0; +} + +/** + * Returns the midpoint between two fractional parts. `a` may be empty, `b` + * may be null (meaning "no upper bound"); `a < b` is required when `b` is + * non-null. Fractional parts never have trailing zeros. + */ +function midpoint(a: string, b: string | null): string { + if (b !== null && a >= b) { + throw new Error(`invalid sort key range: ${a} >= ${b}`); + } + if (a.slice(-1) === ZERO || (b && b.slice(-1) === ZERO)) { + throw new Error('trailing zero in sort key'); + } + if (b) { + // Remove the longest common prefix, padding `a` with zeros as we go. + // Note that `b` needs no padding: it can't end while traversing the + // common prefix (no trailing zeros). + let n = 0; + while ((a[n] || ZERO) === b[n]) { + n++; + } + if (n > 0) { + return b.slice(0, n) + midpoint(a.slice(n), b.slice(n)); + } + } + // The first digits (or lack of digit) differ. + const digitA = a ? BASE_62_DIGITS.indexOf(a[0]) : 0; + const digitB = + b !== null ? BASE_62_DIGITS.indexOf(b[0]) : BASE_62_DIGITS.length; + if (digitB - digitA > 1) { + const midDigit = Math.round(0.5 * (digitA + digitB)); + return BASE_62_DIGITS[midDigit]; + } + // The first digits are consecutive. + if (b && b.length > 1) { + return b.slice(0, 1); + } + // `b` is null or has length 1 (a single digit). The first digit of the + // result is the same as the first digit of `a`; recurse on the rest, e.g. + // midpoint('49', '5') -> '4' + midpoint('9', null) -> '495'. + return BASE_62_DIGITS[digitA] + midpoint(a.slice(1), null); +} + +function getIntegerLength(head: string): number { + if (head >= 'a' && head <= 'z') { + return head.charCodeAt(0) - 'a'.charCodeAt(0) + 2; + } + if (head >= 'A' && head <= 'Z') { + return 'Z'.charCodeAt(0) - head.charCodeAt(0) + 2; + } + throw new Error(`invalid sort key head: ${head}`); +} + +function validateInteger(int: string) { + if (int.length !== getIntegerLength(int[0])) { + throw new Error(`invalid integer part of sort key: ${int}`); + } +} + +function getIntegerPart(key: string): string { + const integerPartLength = getIntegerLength(key[0]); + if (integerPartLength > key.length) { + throw new Error(`invalid sort key: ${key}`); + } + return key.slice(0, integerPartLength); +} + +function validateSortKey(key: string) { + if (key === SMALLEST_INTEGER) { + throw new Error(`invalid sort key: ${key}`); + } + // getIntegerPart() throws if the first character is bad or the key is too + // short. + const i = getIntegerPart(key); + const f = key.slice(i.length); + if (f.slice(-1) === ZERO) { + throw new Error(`invalid sort key: ${key}`); + } +} + +/** Increments the integer part of a key. Returns null at the largest integer. */ +function incrementInteger(x: string): string | null { + validateInteger(x); + const [head, ...digs] = x.split(''); + let carry = true; + for (let i = digs.length - 1; carry && i >= 0; i--) { + const d = BASE_62_DIGITS.indexOf(digs[i]) + 1; + if (d === BASE_62_DIGITS.length) { + digs[i] = ZERO; + } else { + digs[i] = BASE_62_DIGITS[d]; + carry = false; + } + } + if (carry) { + if (head === 'Z') { + return 'a' + ZERO; + } + if (head === 'z') { + return null; + } + const h = String.fromCharCode(head.charCodeAt(0) + 1); + if (h > 'a') { + digs.push(ZERO); + } else { + digs.pop(); + } + return h + digs.join(''); + } + return head + digs.join(''); +} + +/** Decrements the integer part of a key. Returns null at the smallest integer. */ +function decrementInteger(x: string): string | null { + validateInteger(x); + const [head, ...digs] = x.split(''); + let borrow = true; + for (let i = digs.length - 1; borrow && i >= 0; i--) { + const d = BASE_62_DIGITS.indexOf(digs[i]) - 1; + if (d === -1) { + digs[i] = BASE_62_DIGITS.slice(-1); + } else { + digs[i] = BASE_62_DIGITS[d]; + borrow = false; + } + } + if (borrow) { + if (head === 'a') { + return 'Z' + BASE_62_DIGITS.slice(-1); + } + if (head === 'A') { + return null; + } + const h = String.fromCharCode(head.charCodeAt(0) - 1); + if (h < 'Z') { + digs.push(BASE_62_DIGITS.slice(-1)); + } else { + digs.pop(); + } + return h + digs.join(''); + } + return head + digs.join(''); +} diff --git a/packages/root-cms/ui/components/DocActionsMenu/DocActionsMenu.tsx b/packages/root-cms/ui/components/DocActionsMenu/DocActionsMenu.tsx index 18894d3a7..43dbc0c15 100644 --- a/packages/root-cms/ui/components/DocActionsMenu/DocActionsMenu.tsx +++ b/packages/root-cms/ui/components/DocActionsMenu/DocActionsMenu.tsx @@ -6,6 +6,8 @@ import { IconArchive, IconArchiveOff, IconArrowBack, + IconArrowBarToDown, + IconArrowBarToUp, IconCloudOff, IconCopy, IconDotsVertical, @@ -58,6 +60,12 @@ export interface DocActionsMenuProps { data?: CMSDoc; onDelete?: () => void; onAction?: (event: DocActionEvent) => void; + /** + * When provided (i.e. the docs list is in "Manual order" mode), renders + * "Move to top" / "Move to bottom" menu items that call back with the + * requested position. + */ + onMoveTo?: (position: 'top' | 'bottom') => void; } export function DocActionsMenu(props: DocActionsMenuProps) { @@ -366,6 +374,24 @@ export function DocActionsMenu(props: DocActionsMenuProps) { } > + {props.onMoveTo && ( + } + onClick={() => props.onMoveTo!('top')} + disabled={!canEdit} + > + Move to top + + )} + {props.onMoveTo && ( + } + onClick={() => props.onMoveTo!('bottom')} + disabled={!canEdit} + > + Move to bottom + + )} {!isArchived && ( } diff --git a/packages/root-cms/ui/hooks/useDocsList.ts b/packages/root-cms/ui/hooks/useDocsList.ts index a286d6458..4e86e39eb 100644 --- a/packages/root-cms/ui/hooks/useDocsList.ts +++ b/packages/root-cms/ui/hooks/useDocsList.ts @@ -8,6 +8,7 @@ import { } from 'firebase/firestore'; import {useEffect, useState} from 'preact/hooks'; import {setDocToCache} from '../utils/doc-cache.js'; +import {sortDocsManualOrder} from '../utils/doc-sort.js'; import {notifyErrors} from '../utils/notifications.js'; import {withTimeout} from '../utils/with-timeout.js'; import {useFirebase} from './useFirebase.js'; @@ -60,6 +61,12 @@ export function useDocsList(collectionId: string, options: UseDocsListOptions) { dbQuery = titleField ? query(dbCollection, queryOrderby(titleField, direction)) : query(dbCollection, queryOrderby(documentId(), direction)); + } else if (orderBy === 'manual') { + // Manual order is sorted in memory below. A firestore + // `orderBy('sys.sortKey')` query would silently exclude docs that don't + // have a sort key yet (e.g. docs created before the `manualSorting` + // option was enabled), so fetch in the default (document id) order. + dbQuery = dbCollection; } else { const col = window.__ROOT_CTX.collections[collectionId] as any; const custom = col?.sortOptions?.find((s: any) => s.id === orderBy); @@ -76,7 +83,7 @@ export function useDocsList(collectionId: string, options: UseDocsListOptions) { undefined, 'loading docs' ); - const docs: any[] = []; + let docs: any[] = []; snapshot.docs.forEach((d) => { const data = d.data(); const slug = d.id; @@ -93,6 +100,9 @@ export function useDocsList(collectionId: string, options: UseDocsListOptions) { } docs.push(docData); }); + if (orderBy === 'manual') { + docs = sortDocsManualOrder(docs); + } setDocs(docs); }); setLoading(false); @@ -103,7 +113,7 @@ export function useDocsList(collectionId: string, options: UseDocsListOptions) { listDocs(); }, [collectionId, options.orderBy, includeArchived]); - return [loading, listDocs, docs] as const; + return [loading, listDocs, docs, setDocs] as const; } /** diff --git a/packages/root-cms/ui/pages/CollectionPage/CollectionPage.css b/packages/root-cms/ui/pages/CollectionPage/CollectionPage.css index 3f54cc6c8..19124931f 100644 --- a/packages/root-cms/ui/pages/CollectionPage/CollectionPage.css +++ b/packages/root-cms/ui/pages/CollectionPage/CollectionPage.css @@ -245,6 +245,70 @@ padding: 5px 12px; } +/* Compact docs list with reordering enabled ("Manual order" sort): prepend a + * narrow track for the drag handle. */ +.CollectionPage__collection__docsList--compact.CollectionPage__collection__docsList--reorderable + .CollectionPage__collection__docsList__doc, +.CollectionPage__collection__docsList--compact.CollectionPage__collection__docsList--reorderable + .CollectionPage__collection__docsList__header { + grid-template-columns: + 16px 40px minmax(0, 0.8fr) minmax(0, 2.5fr) + minmax(0, var(--docs-status-width, 80px)) 110px + 110px 28px; +} + +.CollectionPage__collection__docsList__doc__handle { + display: flex; + align-items: center; + flex-shrink: 0; + color: var(--color-text-subtle); + cursor: grab; + line-height: 0; +} + +.CollectionPage__collection__docsList__doc__handle:active { + cursor: grabbing; +} + +.CollectionPage__collection__docsList:not( + .CollectionPage__collection__docsList--compact + ) + .CollectionPage__collection__docsList__doc__handle { + align-self: stretch; + padding-right: 12px; +} + +.CollectionPage__collection__docsList__doc--dragging { + background-color: #f8f9fa; + border: 1px solid #dedede; + border-radius: 4px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05), rgba(0, 0, 0, 0.05) 0 10px 15px -5px, + rgba(0, 0, 0, 0.04) 0 7px 7px -5px; +} + +/* "Assign positions" banner shown in the "Manual order" view when some docs + * don't have a sort key yet. */ +.CollectionPage__collection__assignPositions { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 12px; + padding: 8px 12px; + background-color: var(--color-surface, #fff); + border: 1px solid var(--color-border); + border-left: 3px solid #f59f00; + border-radius: 4px; +} + +.CollectionPage__collection__assignPositions__text { + flex: 1; + font-size: 13px; +} + +.CollectionPage__collection__assignPositions__text code { + font-size: 12px; +} + .CollectionPage__collection__docsList__header { border-bottom: 1px solid var(--color-border); } diff --git a/packages/root-cms/ui/pages/CollectionPage/CollectionPage.tsx b/packages/root-cms/ui/pages/CollectionPage/CollectionPage.tsx index 9182f13b0..9fd51a1be 100644 --- a/packages/root-cms/ui/pages/CollectionPage/CollectionPage.tsx +++ b/packages/root-cms/ui/pages/CollectionPage/CollectionPage.tsx @@ -1,5 +1,11 @@ import './CollectionPage.css'; +import { + DragDropContext, + Draggable, + Droppable, + DropResult, +} from '@hello-pangea/dnd'; import { ActionIcon, Button, @@ -9,6 +15,7 @@ import { Switch, Tooltip, } from '@mantine/core'; +import {showNotification} from '@mantine/notifications'; import { IconBaselineDensityMedium, IconBaselineDensitySmall, @@ -16,9 +23,15 @@ import { IconChevronUp, IconChevronDown, IconCirclePlus, + IconGripVertical, } from '@tabler/icons-preact'; +import {ComponentChildren} from 'preact'; import {useEffect, useState} from 'preact/hooks'; import {useLocation} from 'preact-iso'; +import { + generateKeyBetween, + generateNKeysBetween, +} from '../../../shared/sort-key.js'; import {CollectionTree} from '../../components/CollectionTree/CollectionTree.js'; import {ConditionalTooltip} from '../../components/ConditionalTooltip/ConditionalTooltip.js'; import {DocActionsMenu} from '../../components/DocActionsMenu/DocActionsMenu.js'; @@ -35,7 +48,12 @@ import {useProjectRoles} from '../../hooks/useProjectRoles.js'; import {Layout} from '../../layout/Layout.js'; import {joinClassNames} from '../../utils/classes.js'; import {getDocServingUrl} from '../../utils/doc-urls.js'; -import {testPublishingLocked} from '../../utils/doc.js'; +import { + cmsAssignSortKeys, + cmsSetDocSortKey, + fetchMaxSortKey, + testPublishingLocked, +} from '../../utils/doc.js'; import {getNestedValue} from '../../utils/objects.js'; import {testCanEdit} from '../../utils/permissions.js'; import {formatDateTime, getTimeAgo} from '../../utils/time.js'; @@ -127,9 +145,12 @@ CollectionPage.Collection = (props: CollectionProps) => { const currentUserEmail = window.firebase.user.email || ''; const canEdit = testCanEdit(roles, currentUserEmail); + const collection = window.__ROOT_CTX.collections[props.collection]; + const manualSortingEnabled = Boolean(collection?.manualSorting); + const [orderBy, setOrderBy] = useLocalStorage( `root::CollectionPage:${props.collection}:orderBy`, - 'modifiedAt' + manualSortingEnabled ? 'manual' : 'modifiedAt' ); const [showArchived, setShowArchived] = useLocalStorage( `root::CollectionPage:${props.collection}:showArchived`, @@ -137,12 +158,16 @@ CollectionPage.Collection = (props: CollectionProps) => { ); const [newDocModalOpen, setNewDocModalOpen] = useState(false); - const collection = window.__ROOT_CTX.collections[props.collection]; if (!collection) { route('/cms/content'); return <>; } + // Guard against a stale "manual" value in localStorage (e.g. when the + // collection's `manualSorting` option is later removed from the schema). + const effectiveOrderBy = + orderBy === 'manual' && !manualSortingEnabled ? 'modifiedAt' : orderBy; + // Collections can force the compact listing via schema // (`viewOptions: {compact: true}`). Otherwise the user's chosen density is // remembered globally (sticky as the user navigates between collections). @@ -155,6 +180,7 @@ CollectionPage.Collection = (props: CollectionProps) => { const compactView = density === 'compact'; const sortOptions = [ + ...(manualSortingEnabled ? [{value: 'manual', label: 'Manual order'}] : []), {value: 'slug', label: 'A-Z'}, {value: 'slugDesc', label: 'Z-A'}, {value: 'title', label: 'Title (A-Z)'}, @@ -169,11 +195,16 @@ CollectionPage.Collection = (props: CollectionProps) => { })) || []), ]; - const [loading, listDocs, docs] = useDocsList(props.collection, { - orderBy, + const [loading, listDocs, docs, setDocs] = useDocsList(props.collection, { + orderBy: effectiveOrderBy, includeArchived: showArchived, }); + const manualMode = effectiveOrderBy === 'manual'; + const keylessCount = manualMode + ? docs.filter((doc: any) => !doc.sys?.sortKey).length + : 0; + // Only blank the page on the very first load. Subsequent reloads (e.g. after // changing the sort) keep the previous docs visible and dim them with a // spinner overlay instead. @@ -211,7 +242,7 @@ CollectionPage.Collection = (props: CollectionProps) => {