diff --git a/docs/configuration/collections.mdx b/docs/configuration/collections.mdx index 6adc8502f2..9e804750b9 100644 --- a/docs/configuration/collections.mdx +++ b/docs/configuration/collections.mdx @@ -73,6 +73,7 @@ The following options are available: | `fields` \* | Array of field types that will determine the structure and functionality of the data stored within this Collection. [More details](../fields/overview). | | `graphQL` | Manage GraphQL-related properties for this collection. [More](#graphql) | | `hooks` | Entry point for Hooks. [More details](../hooks/overview#collection-hooks). | +| `orderable` | If true, enables custom ordering for the collection, and documents can be reordered via drag and drop. Uses [fractional indexing](https://observablehq.com/@dgreensp/implementing-fractional-indexing) for efficient reordering. | | `labels` | Singular and plural labels for use in identifying this Collection throughout Payload. Auto-generated from slug if not defined. | | `enableQueryPresets` | Enable query presets for this Collection. [More details](../query-presets/overview). | | `lockDocuments` | Enables or disables document locking. By default, document locking is enabled. Set to an object to configure, or set to `false` to disable locking. [More details](../admin/locked-documents). | diff --git a/docs/fields/join.mdx b/docs/fields/join.mdx index 5bd0bfb131..fee3841817 100644 --- a/docs/fields/join.mdx +++ b/docs/fields/join.mdx @@ -138,6 +138,7 @@ powerful Admin UI. | **`name`** \* | To be used as the property name when retrieved from the database. [More](./overview#field-names) | | **`collection`** \* | The `slug`s having the relationship field or an array of collection slugs. | | **`on`** \* | The name of the relationship or upload field that relates to the collection document. Use dot notation for nested paths, like 'myGroup.relationName'. If `collection` is an array, this field must exist for all specified collections | +| **`orderable`** | If true, enables custom ordering and joined documents can be reordered via drag and drop. Uses [fractional indexing](https://observablehq.com/@dgreensp/implementing-fractional-indexing) for efficient reordering. | | **`where`** | A `Where` query to hide related documents from appearing. Will be merged with any `where` specified in the request. | | **`maxDepth`** | Default is 1, Sets a maximum population depth for this field, regardless of the remaining depth when this field is reached. [Max Depth](../queries/depth#max-depth). | | **`label`** | Text used as a field label in the Admin Panel or an object with keys for each language. | diff --git a/packages/next/src/views/List/index.tsx b/packages/next/src/views/List/index.tsx index 62e08b1262..f4c22c6fce 100644 --- a/packages/next/src/views/List/index.tsx +++ b/packages/next/src/views/List/index.tsx @@ -195,6 +195,7 @@ export const renderListView = async ( drawerSlug, enableRowSelections, i18n: req.i18n, + orderableFieldName: collectionConfig.orderable === true ? '_order' : undefined, payload, useAsTitle: collectionConfig.admin.useAsTitle, }) @@ -259,6 +260,7 @@ export const renderListView = async ( defaultSort={sort} listPreferences={listPreferences} modifySearchParams={!isInDrawer} + orderableFieldName={collectionConfig.orderable === true ? '_order' : undefined} > {RenderServerComponent({ clientProps: { diff --git a/packages/next/src/views/Versions/index.tsx b/packages/next/src/views/Versions/index.tsx index b1048573b4..54e05e0088 100644 --- a/packages/next/src/views/Versions/index.tsx +++ b/packages/next/src/views/Versions/index.tsx @@ -193,6 +193,7 @@ export async function VersionsView(props: DocumentViewServerProps) { defaultLimit={limitToUse} defaultSort={sort as string} modifySearchParams + orderableFieldName={collectionConfig?.orderable === true ? '_order' : undefined} > = { duration: number } | false + /** + * If true, enables custom ordering for the collection, and documents in the listView can be reordered via drag and drop. + * New documents are inserted at the end of the list according to this parameter. + * + * Under the hood, a field with {@link https://observablehq.com/@dgreensp/implementing-fractional-indexing|fractional indexing} is used to optimize inserts and reorderings. + * + * @default false + * + * @experimental There may be frequent breaking changes to this API + */ + orderable?: boolean slug: string /** * Add `createdAt` and `updatedAt` fields diff --git a/packages/payload/src/config/orderable/fractional-indexing.js b/packages/payload/src/config/orderable/fractional-indexing.js new file mode 100644 index 0000000000..4416732b05 --- /dev/null +++ b/packages/payload/src/config/orderable/fractional-indexing.js @@ -0,0 +1,318 @@ +// @ts-check + +/** + * THIS FILE IS COPIED FROM: + * https://github.com/rocicorp/fractional-indexing/blob/main/src/index.js + * + * I AM NOT INSTALLING THAT LIBRARY BECAUSE JEST COMPLAINS ABOUT THE ESM MODULE AND THE TESTS FAIL. + * DO NOT MODIFY IT + */ + +// License: CC0 (no rights reserved). + +// This is based on https://observablehq.com/@dgreensp/implementing-fractional-indexing + +export const BASE_62_DIGITS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz' + +// `a` may be empty string, `b` is null or non-empty string. +// `a < b` lexicographically if `b` is non-null. +// no trailing zeros allowed. +// digits is a string such as '0123456789' for base 10. Digits must be in +// ascending character code order! +/** + * @param {string} a + * @param {string | null | undefined} b + * @param {string} digits + * @returns {string} + */ +function midpoint(a, b, digits) { + const zero = digits[0] + if (b != null && a >= b) { + throw new Error(a + ' >= ' + b) + } + if (a.slice(-1) === zero || (b && b.slice(-1) === zero)) { + throw new Error('trailing zero') + } + if (b) { + // remove longest common prefix. pad `a` with 0s as we + // go. note that we don't need to pad `b`, because it can't + // end before `a` while traversing the common prefix. + 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), digits) + } + } + // first digits (or lack of digit) are different + const digitA = a ? digits.indexOf(a[0]) : 0 + const digitB = b != null ? digits.indexOf(b[0]) : digits.length + if (digitB - digitA > 1) { + const midDigit = Math.round(0.5 * (digitA + digitB)) + return digits[midDigit] + } else { + // first digits are consecutive + if (b && b.length > 1) { + return b.slice(0, 1) + } else { + // `b` is null or has length 1 (a single digit). + // the first digit of `a` is the previous digit to `b`, + // or 9 if `b` is null. + // given, for example, midpoint('49', '5'), return + // '4' + midpoint('9', null), which will become + // '4' + '9' + midpoint('', null), which is '495' + return digits[digitA] + midpoint(a.slice(1), null, digits) + } + } +} + +/** + * @param {string} int + * @return {void} + */ + +function validateInteger(int) { + if (int.length !== getIntegerLength(int[0])) { + throw new Error('invalid integer part of order key: ' + int) + } +} + +/** + * @param {string} head + * @return {number} + */ + +function getIntegerLength(head) { + if (head >= 'a' && head <= 'z') { + return head.charCodeAt(0) - 'a'.charCodeAt(0) + 2 + } else if (head >= 'A' && head <= 'Z') { + return 'Z'.charCodeAt(0) - head.charCodeAt(0) + 2 + } else { + throw new Error('invalid order key head: ' + head) + } +} + +/** + * @param {string} key + * @return {string} + */ + +function getIntegerPart(key) { + const integerPartLength = getIntegerLength(key[0]) + if (integerPartLength > key.length) { + throw new Error('invalid order key: ' + key) + } + return key.slice(0, integerPartLength) +} + +/** + * @param {string} key + * @param {string} digits + * @return {void} + */ + +function validateOrderKey(key, digits) { + if (key === 'A' + digits[0].repeat(26)) { + throw new Error('invalid order key: ' + key) + } + // getIntegerPart will throw if the first character is bad, + // or the key is too short. we'd call it to check these things + // even if we didn't need the result + const i = getIntegerPart(key) + const f = key.slice(i.length) + if (f.slice(-1) === digits[0]) { + throw new Error('invalid order key: ' + key) + } +} + +// note that this may return null, as there is a largest integer +/** + * @param {string} x + * @param {string} digits + * @return {string | null} + */ +function incrementInteger(x, digits) { + validateInteger(x) + const [head, ...digs] = x.split('') + let carry = true + for (let i = digs.length - 1; carry && i >= 0; i--) { + const d = digits.indexOf(digs[i]) + 1 + if (d === digits.length) { + digs[i] = digits[0] + } else { + digs[i] = digits[d] + carry = false + } + } + if (carry) { + if (head === 'Z') { + return 'a' + digits[0] + } + if (head === 'z') { + return null + } + const h = String.fromCharCode(head.charCodeAt(0) + 1) + if (h > 'a') { + digs.push(digits[0]) + } else { + digs.pop() + } + return h + digs.join('') + } else { + return head + digs.join('') + } +} + +// note that this may return null, as there is a smallest integer +/** + * @param {string} x + * @param {string} digits + * @return {string | null} + */ + +function decrementInteger(x, digits) { + validateInteger(x) + const [head, ...digs] = x.split('') + let borrow = true + for (let i = digs.length - 1; borrow && i >= 0; i--) { + const d = digits.indexOf(digs[i]) - 1 + if (d === -1) { + digs[i] = digits.slice(-1) + } else { + digs[i] = digits[d] + borrow = false + } + } + if (borrow) { + if (head === 'a') { + return 'Z' + digits.slice(-1) + } + if (head === 'A') { + return null + } + const h = String.fromCharCode(head.charCodeAt(0) - 1) + if (h < 'Z') { + digs.push(digits.slice(-1)) + } else { + digs.pop() + } + return h + digs.join('') + } else { + return head + digs.join('') + } +} + +// `a` is an order key or null (START). +// `b` is an order key or null (END). +// `a < b` lexicographically if both are non-null. +// digits is a string such as '0123456789' for base 10. Digits must be in +// ascending character code order! +/** + * @param {string | null | undefined} a + * @param {string | null | undefined} b + * @param {string=} digits + * @return {string} + */ +export function generateKeyBetween(a, b, digits = BASE_62_DIGITS) { + if (a != null) { + validateOrderKey(a, digits) + } + if (b != null) { + validateOrderKey(b, digits) + } + if (a != null && b != null && a >= b) { + throw new Error(a + ' >= ' + b) + } + if (a == null) { + if (b == null) { + return 'a' + digits[0] + } + + const ib = getIntegerPart(b) + const fb = b.slice(ib.length) + if (ib === 'A' + digits[0].repeat(26)) { + return ib + midpoint('', fb, digits) + } + if (ib < b) { + return ib + } + const res = decrementInteger(ib, digits) + 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, digits) + return i == null ? ia + midpoint(fa, null, digits) : 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, digits) + } + const i = incrementInteger(ia, digits) + if (i == null) { + throw new Error('cannot increment any more') + } + if (i < b) { + return i + } + return ia + midpoint(fa, null, digits) +} + +/** + * same preconditions as generateKeysBetween. + * n >= 0. + * Returns an array of n distinct keys in sorted order. + * If a and b are both null, returns [a0, a1, ...] + * If one or the other is null, returns consecutive "integer" + * keys. Otherwise, returns relatively short keys between + * a and b. + * @param {string | null | undefined} a + * @param {string | null | undefined} b + * @param {number} n + * @param {string} digits + * @return {string[]} + */ +export function generateNKeysBetween(a, b, n, digits = BASE_62_DIGITS) { + if (n === 0) { + return [] + } + if (n === 1) { + return [generateKeyBetween(a, b, digits)] + } + if (b == null) { + let c = generateKeyBetween(a, b, digits) + const result = [c] + for (let i = 0; i < n - 1; i++) { + c = generateKeyBetween(c, b, digits) + result.push(c) + } + return result + } + if (a == null) { + let c = generateKeyBetween(a, b, digits) + const result = [c] + for (let i = 0; i < n - 1; i++) { + c = generateKeyBetween(a, c, digits) + result.push(c) + } + result.reverse() + return result + } + const mid = Math.floor(n / 2) + const c = generateKeyBetween(a, b, digits) + return [ + ...generateNKeysBetween(a, c, mid, digits), + c, + ...generateNKeysBetween(c, b, n - mid - 1, digits), + ] +} diff --git a/packages/payload/src/config/orderable/index.ts b/packages/payload/src/config/orderable/index.ts new file mode 100644 index 0000000000..742f3e4fd8 --- /dev/null +++ b/packages/payload/src/config/orderable/index.ts @@ -0,0 +1,278 @@ +import type { BeforeChangeHook, CollectionConfig } from '../../collections/config/types.js' +import type { Field } from '../../fields/config/types.js' +import type { Endpoint, PayloadHandler, SanitizedConfig } from '../types.js' + +import executeAccess from '../../auth/executeAccess.js' +import { traverseFields } from '../../utilities/traverseFields.js' +import { generateKeyBetween, generateNKeysBetween } from './fractional-indexing.js' + +/** + * This function creates: + * - N fields per collection, named `_order` or `___order` + * - 1 hook per collection + * - 1 endpoint per app + * + * Also, if collection.defaultSort or joinField.defaultSort is not set, it will be set to the orderable field. + */ +export const setupOrderable = (config: SanitizedConfig) => { + const fieldsToAdd = new Map() + + config.collections.forEach((collection) => { + if (collection.orderable) { + const currentFields = fieldsToAdd.get(collection) || [] + fieldsToAdd.set(collection, [...currentFields, '_order']) + collection.defaultSort = collection.defaultSort ?? '_order' + } + + traverseFields({ + callback: ({ field, parentRef, ref }) => { + if (field.type === 'array' || field.type === 'blocks') { + return false + } + if (field.type === 'group' || field.type === 'tab') { + // @ts-expect-error ref is untyped + const parentPrefix = parentRef?.prefix ? `${parentRef.prefix}_` : '' + // @ts-expect-error ref is untyped + ref.prefix = `${parentPrefix}${field.name}` + } + if (field.type === 'join' && field.orderable === true) { + if (Array.isArray(field.collection)) { + throw new Error('Orderable joins must target a single collection') + } + const relationshipCollection = config.collections.find((c) => c.slug === field.collection) + if (!relationshipCollection) { + return false + } + field.defaultSort = field.defaultSort ?? `_${field.collection}_${field.name}_order` + const currentFields = fieldsToAdd.get(relationshipCollection) || [] + // @ts-expect-error ref is untyped + const prefix = parentRef?.prefix ? `${parentRef.prefix}_` : '' + fieldsToAdd.set(relationshipCollection, [ + ...currentFields, + `_${field.collection}_${prefix}${field.name}_order`, + ]) + } + }, + fields: collection.fields, + }) + }) + + Array.from(fieldsToAdd.entries()).forEach(([collection, orderableFields]) => { + addOrderableFieldsAndHook(collection, orderableFields) + }) + + if (fieldsToAdd.size > 0) { + addOrderableEndpoint(config) + } +} + +export const addOrderableFieldsAndHook = ( + collection: CollectionConfig, + orderableFieldNames: string[], +) => { + // 1. Add field + orderableFieldNames.forEach((orderableFieldName) => { + const orderField: Field = { + name: orderableFieldName, + type: 'text', + admin: { + disableBulkEdit: true, + disabled: true, + disableListColumn: true, + disableListFilter: true, + hidden: true, + readOnly: true, + }, + index: true, + required: true, + // override the schema to make order fields optional for payload.create() + typescriptSchema: [ + () => ({ + type: 'string', + required: false, + }), + ], + unique: true, + } + + collection.fields.unshift(orderField) + }) + + // 2. Add hook + if (!collection.hooks) { + collection.hooks = {} + } + if (!collection.hooks.beforeChange) { + collection.hooks.beforeChange = [] + } + + const orderBeforeChangeHook: BeforeChangeHook = async ({ data, operation, req }) => { + // Only set _order on create, not on update (unless explicitly provided) + if (operation === 'create') { + for (const orderableFieldName of orderableFieldNames) { + if (!data[orderableFieldName]) { + const lastDoc = await req.payload.find({ + collection: collection.slug, + depth: 0, + limit: 1, + pagination: false, + req, + select: { [orderableFieldName]: true }, + sort: `-${orderableFieldName}`, + }) + + const lastOrderValue = lastDoc.docs[0]?.[orderableFieldName] || null + data[orderableFieldName] = generateKeyBetween(lastOrderValue, null) + } + } + } + + return data + } + + collection.hooks.beforeChange.push(orderBeforeChangeHook) +} + +/** + * The body of the reorder endpoint. + * @internal + */ +export type OrderableEndpointBody = { + collectionSlug: string + docsToMove: string[] + newKeyWillBe: 'greater' | 'less' + orderableFieldName: string + target: { + id: string + key: string + } +} + +export const addOrderableEndpoint = (config: SanitizedConfig) => { + // 3. Add endpoint + const reorderHandler: PayloadHandler = async (req) => { + const body = (await req.json?.()) as OrderableEndpointBody + + const { collectionSlug, docsToMove, newKeyWillBe, orderableFieldName, target } = body + + if (!Array.isArray(docsToMove) || docsToMove.length === 0) { + return new Response(JSON.stringify({ error: 'docsToMove must be a non-empty array' }), { + headers: { 'Content-Type': 'application/json' }, + status: 400, + }) + } + if ( + typeof target !== 'object' || + typeof target.id !== 'string' || + typeof target.key !== 'string' + ) { + return new Response(JSON.stringify({ error: 'target must be an object with id and key' }), { + headers: { 'Content-Type': 'application/json' }, + status: 400, + }) + } + if (newKeyWillBe !== 'greater' && newKeyWillBe !== 'less') { + return new Response(JSON.stringify({ error: 'newKeyWillBe must be "greater" or "less"' }), { + headers: { 'Content-Type': 'application/json' }, + status: 400, + }) + } + const collection = config.collections.find((c) => c.slug === collectionSlug) + if (!collection) { + return new Response(JSON.stringify({ error: `Collection ${collectionSlug} not found` }), { + headers: { 'Content-Type': 'application/json' }, + status: 400, + }) + } + if (typeof orderableFieldName !== 'string') { + return new Response(JSON.stringify({ error: 'orderableFieldName must be a string' }), { + headers: { 'Content-Type': 'application/json' }, + status: 400, + }) + } + + // Prevent reordering if user doesn't have editing permissions + if (collection.access?.update) { + await executeAccess( + { + // Currently only one doc can be moved at a time. We should review this if we want to allow + // multiple docs to be moved at once in the future. + id: docsToMove[0], + data: {}, + req, + }, + collection.access.update, + ) + } + + const targetId = target.id + let targetKey = target.key + + // If targetKey = pending, we need to find its current key. + // This can only happen if the user reorders rows quickly with a slow connection. + if (targetKey === 'pending') { + const beforeDoc = await req.payload.findByID({ + id: targetId, + collection: collection.slug, + depth: 0, + select: { [orderableFieldName]: true }, + }) + targetKey = beforeDoc?.[orderableFieldName] || null + } + + // The reason the endpoint does not receive this docId as an argument is that there + // are situations where the user may not see or know what the next or previous one is. For + // example, access control restrictions, if docBefore is the last one on the page, etc. + const adjacentDoc = await req.payload.find({ + collection: collection.slug, + depth: 0, + limit: 1, + pagination: false, + select: { [orderableFieldName]: true }, + sort: newKeyWillBe === 'greater' ? orderableFieldName : `-${orderableFieldName}`, + where: { + [orderableFieldName]: { + [newKeyWillBe === 'greater' ? 'greater_than' : 'less_than']: targetKey, + }, + }, + }) + const adjacentDocKey = adjacentDoc.docs?.[0]?.[orderableFieldName] || null + + // Currently N (= docsToMove.length) is always 1. Maybe in the future we will + // allow dragging and reordering multiple documents at once via the UI. + const orderValues = + newKeyWillBe === 'greater' + ? generateNKeysBetween(targetKey, adjacentDocKey, docsToMove.length) + : generateNKeysBetween(adjacentDocKey, targetKey, docsToMove.length) + + // Update each document with its new order value + for (const [index, id] of docsToMove.entries()) { + await req.payload.update({ + id, + collection: collection.slug, + data: { + [orderableFieldName]: orderValues[index], + }, + depth: 0, + req, + select: { id: true }, + }) + } + + return new Response(JSON.stringify({ orderValues, success: true }), { + headers: { 'Content-Type': 'application/json' }, + status: 200, + }) + } + + const reorderEndpoint: Endpoint = { + handler: reorderHandler, + method: 'post', + path: '/reorder', + } + + if (!config.endpoints) { + config.endpoints = [] + } + config.endpoints.push(reorderEndpoint) +} diff --git a/packages/payload/src/config/sanitize.ts b/packages/payload/src/config/sanitize.ts index 033618a852..2d6346a301 100644 --- a/packages/payload/src/config/sanitize.ts +++ b/packages/payload/src/config/sanitize.ts @@ -36,6 +36,7 @@ import { getDefaultJobsCollection, jobsCollectionSlug } from '../queues/config/i import { flattenBlock } from '../utilities/flattenAllFields.js' import { getSchedulePublishTask } from '../versions/schedule/job.js' import { addDefaultsToConfig } from './defaults.js' +import { setupOrderable } from './orderable/index.js' const sanitizeAdminConfig = (configToSanitize: Config): Partial => { const sanitizedConfig = { ...configToSanitize } @@ -108,6 +109,9 @@ export const sanitizeConfig = async (incomingConfig: Config): Promise = sanitizeAdminConfig(configWithDefaults) + // Add orderable fields + setupOrderable(config as SanitizedConfig) + if (!config.endpoints) { config.endpoints = [] } diff --git a/packages/payload/src/fields/config/types.ts b/packages/payload/src/fields/config/types.ts index cb8df97e57..2119b85683 100644 --- a/packages/payload/src/fields/config/types.ts +++ b/packages/payload/src/fields/config/types.ts @@ -1549,6 +1549,17 @@ export type JoinField = { * A string for the field in the collection being joined to. */ on: string + /** + * If true, enables custom ordering for the collection with the relationship, and joined documents can be reordered via drag and drop. + * New documents are inserted at the end of the list according to this parameter. + * + * Under the hood, a field with {@link https://observablehq.com/@dgreensp/implementing-fractional-indexing|fractional indexing} is used to optimize inserts and reorderings. + * + * @default false + * + * @experimental There may be frequent breaking changes to this API + */ + orderable?: boolean sanitizedMany?: JoinField[] type: 'join' validate?: never @@ -1562,7 +1573,15 @@ export type JoinFieldClient = { } & { targetField: Pick } & FieldBaseClient & Pick< JoinField, - 'collection' | 'defaultLimit' | 'defaultSort' | 'index' | 'maxDepth' | 'on' | 'type' | 'where' + | 'collection' + | 'defaultLimit' + | 'defaultSort' + | 'index' + | 'maxDepth' + | 'on' + | 'orderable' + | 'type' + | 'where' > export type FlattenedBlock = { diff --git a/packages/payload/src/index.ts b/packages/payload/src/index.ts index 39139fbf89..61b537e687 100644 --- a/packages/payload/src/index.ts +++ b/packages/payload/src/index.ts @@ -1090,6 +1090,7 @@ export { } from './config/client.js' export { defaults } from './config/defaults.js' +export { type OrderableEndpointBody } from './config/orderable/index.js' export { sanitizeConfig } from './config/sanitize.js' export type * from './config/types.js' export { combineQueries } from './database/combineQueries.js' diff --git a/packages/payload/src/utilities/flattenAllFields.ts b/packages/payload/src/utilities/flattenAllFields.ts index f1a09c4d2d..97fa0b5dd3 100644 --- a/packages/payload/src/utilities/flattenAllFields.ts +++ b/packages/payload/src/utilities/flattenAllFields.ts @@ -18,6 +18,11 @@ export const flattenBlock = ({ block }: { block: Block }): FlattenedBlock => { const flattenedFieldsCache = new Map() +/** + * Flattens all fields in a collection, preserving the nested field structure. + * @param cache + * @param fields + */ export const flattenAllFields = ({ cache, fields, diff --git a/packages/ui/src/elements/RelationshipTable/index.tsx b/packages/ui/src/elements/RelationshipTable/index.tsx index a6588961cf..3247dd3c94 100644 --- a/packages/ui/src/elements/RelationshipTable/index.tsx +++ b/packages/ui/src/elements/RelationshipTable/index.tsx @@ -139,6 +139,10 @@ export const RelationshipTable: React.FC = (pro columns: transformColumnsToPreferences(query?.columns) || defaultColumns, docs, enableRowSelections: false, + orderableFieldName: + !field.orderable || Array.isArray(field.collection) + ? undefined + : `_${field.collection}_${field.name}_order`, parent, query: newQuery, renderRowTypes: true, @@ -153,6 +157,10 @@ export const RelationshipTable: React.FC = (pro [ field.defaultLimit, field.defaultSort, + field.admin.defaultColumns, + field.collection, + field.name, + field.orderable, collectionConfig?.admin?.pagination?.defaultLimit, collectionConfig?.defaultSort, query, @@ -329,6 +337,11 @@ export const RelationshipTable: React.FC = (pro } modifySearchParams={false} onQueryChange={setQuery} + orderableFieldName={ + !field.orderable || Array.isArray(field.collection) + ? undefined + : `_${field.collection}_${field.name}_order` + } > (querySort === `-${orderableFieldName}` ? 'desc' : 'asc') + const isActive = querySort === `-${orderableFieldName}` || querySort === orderableFieldName + + // This is necessary if you initialize the page without sort url param + // but your preferences are to sort by -_order. + // Since sort isn't updated, the arrow would incorrectly point upward. + useEffect(() => { + if (!isActive) { + return + } + sort.current = querySort === `-${orderableFieldName}` ? 'desc' : 'asc' + }, [orderableFieldName, querySort, isActive]) + + const handleSortPress = () => { + // If it's already sorted by the "_order" field, toggle between "asc" and "desc" + if (isActive) { + void handleSortChange(sort.current === 'asc' ? `-${orderableFieldName}` : orderableFieldName) + sort.current = sort.current === 'asc' ? 'desc' : 'asc' + return + } + // If NOT sorted by the "_order" field, sort by that field but do not toggle the current value of "asc" or "desc". + void handleSortChange(sort.current === 'asc' ? orderableFieldName : `-${orderableFieldName}`) + } + + return { handleSortPress, isActive, sort } +} + +export const SortHeader: React.FC = (props) => { + const { appearance } = props + const { handleSortPress, isActive, sort } = useSort() + const { t } = useTranslation() + + return ( +
+
+ {sort.current === 'desc' ? ( + + ) : ( + + )} +
+
+ ) +} diff --git a/packages/ui/src/elements/SortRow/index.scss b/packages/ui/src/elements/SortRow/index.scss new file mode 100644 index 0000000000..ea56b3252d --- /dev/null +++ b/packages/ui/src/elements/SortRow/index.scss @@ -0,0 +1,22 @@ +@import '../../scss/styles.scss'; + +@layer payload-default { + .sort-row { + opacity: 0.3; + cursor: not-allowed; + + &.active { + cursor: grab; + opacity: 1; + } + + &__icon { + height: 22px; + width: 22px; + margin-left: -2px; + margin-top: -2px; + display: block; + width: min-content; + } + } +} diff --git a/packages/ui/src/elements/SortRow/index.tsx b/packages/ui/src/elements/SortRow/index.tsx new file mode 100644 index 0000000000..cfbe034014 --- /dev/null +++ b/packages/ui/src/elements/SortRow/index.tsx @@ -0,0 +1,20 @@ +'use client' + +import React from 'react' + +import { DragHandleIcon } from '../../icons/DragHandle/index.js' +import './index.scss' +import { useListQuery } from '../../providers/ListQuery/index.js' + +const baseClass = 'sort-row' + +export const SortRow = () => { + const { orderableFieldName, query } = useListQuery() + const isActive = query.sort === orderableFieldName || query.sort === `-${orderableFieldName}` + + return ( +
+ +
+ ) +} diff --git a/packages/ui/src/elements/Table/OrderableTable.tsx b/packages/ui/src/elements/Table/OrderableTable.tsx new file mode 100644 index 0000000000..a25aee1476 --- /dev/null +++ b/packages/ui/src/elements/Table/OrderableTable.tsx @@ -0,0 +1,198 @@ +'use client' + +import type { ClientCollectionConfig, Column, OrderableEndpointBody } from 'payload' + +import './index.scss' + +import React, { useEffect, useState } from 'react' +import { toast } from 'sonner' + +import { useListQuery } from '../../providers/ListQuery/index.js' +import { DraggableSortableItem } from '../DraggableSortable/DraggableSortableItem/index.js' +import { DraggableSortable } from '../DraggableSortable/index.js' + +const baseClass = 'table' + +export type Props = { + readonly appearance?: 'condensed' | 'default' + readonly collection: ClientCollectionConfig + readonly columns?: Column[] + readonly data: Record[] +} + +export const OrderableTable: React.FC = ({ + appearance = 'default', + collection, + columns, + data: initialData, +}) => { + const { data: listQueryData, handleSortChange, orderableFieldName, query } = useListQuery() + // Use the data from ListQueryProvider if available, otherwise use the props + const serverData = listQueryData?.docs || initialData + + // Local state to track the current order of rows + const [localData, setLocalData] = useState(serverData) + + // id -> index for each column + const [cellMap, setCellMap] = useState>({}) + + // Update local data when server data changes + useEffect(() => { + setLocalData(serverData) + setCellMap( + Object.fromEntries(serverData.map((item, index) => [String(item.id ?? item._id), index])), + ) + }, [serverData]) + + const activeColumns = columns?.filter((col) => col?.active) + + if ( + !activeColumns || + activeColumns.filter((col) => !['_dragHandle', '_select'].includes(col.accessor)).length === 0 + ) { + return
No columns selected
+ } + + const handleDragEnd = async ({ moveFromIndex, moveToIndex }) => { + if (query.sort !== orderableFieldName && query.sort !== `-${orderableFieldName}`) { + toast.warning('To reorder the rows you must first sort them by the "Order" column') + return + } + + if (moveFromIndex === moveToIndex) { + return + } + + const movedId = localData[moveFromIndex].id ?? localData[moveFromIndex]._id + const newBeforeRow = + moveToIndex > moveFromIndex ? localData[moveToIndex] : localData[moveToIndex - 1] + const newAfterRow = + moveToIndex > moveFromIndex ? localData[moveToIndex + 1] : localData[moveToIndex] + + // Store the original data for rollback + const previousData = [...localData] + + // Optimisitc update of local state to reorder the rows + setLocalData((currentData) => { + const newData = [...currentData] + // Update the rendered cell for the moved row to show "pending" + newData[moveFromIndex][orderableFieldName] = `pending` + // Move the item in the array + newData.splice(moveToIndex, 0, newData.splice(moveFromIndex, 1)[0]) + return newData + }) + + try { + const target: OrderableEndpointBody['target'] = newBeforeRow + ? { + id: newBeforeRow.id ?? newBeforeRow._id, + key: newBeforeRow[orderableFieldName], + } + : { + id: newAfterRow.id ?? newAfterRow._id, + key: newAfterRow[orderableFieldName], + } + + const newKeyWillBe = + (newBeforeRow && query.sort === orderableFieldName) || + (!newBeforeRow && query.sort === `-${orderableFieldName}`) + ? 'greater' + : 'less' + + const jsonBody: OrderableEndpointBody = { + collectionSlug: collection.slug, + docsToMove: [movedId], + newKeyWillBe, + orderableFieldName, + target, + } + + const response = await fetch(`/api/reorder`, { + body: JSON.stringify(jsonBody), + headers: { + 'Content-Type': 'application/json', + }, + method: 'POST', + }) + + if (response.status === 403) { + throw new Error('You do not have permission to reorder these rows') + } + + if (!response.ok) { + throw new Error( + 'Failed to reorder. This can happen if you reorder several rows too quickly. Please try again.', + ) + } + } catch (err) { + const error = err instanceof Error ? err.message : String(err) + // Rollback to previous state if the request fails + setLocalData(previousData) + toast.error(error) + } + } + + const rowIds = localData.map((row) => row.id ?? row._id) + + return ( +
+ + + + + {activeColumns.map((col, i) => ( + + ))} + + + + {localData.map((row, rowIndex) => ( + + {({ attributes, listeners, setNodeRef, transform, transition }) => ( + + {activeColumns.map((col, colIndex) => { + const { accessor } = col + + // Use the cellMap to find which index in the renderedCells to use + const cell = col.renderedCells[cellMap[row.id ?? row._id]] + + // For drag handles, wrap in div with drag attributes + if (accessor === '_dragHandle') { + return ( + + ) + } + + return ( + + ) + })} + + )} + + ))} + +
+ {col.Heading} +
+
+ {cell} +
+
+ {cell} +
+
+
+ ) +} diff --git a/packages/ui/src/icons/Sort/index.scss b/packages/ui/src/icons/Sort/index.scss new file mode 100644 index 0000000000..5ed487885e --- /dev/null +++ b/packages/ui/src/icons/Sort/index.scss @@ -0,0 +1,14 @@ +@import '../../scss/styles'; + +@layer payload-default { + .icon--sort { + height: $baseline; + width: $baseline; + + .fill { + stroke: currentColor; + stroke-width: $style-stroke-width-s; + fill: var(--theme-elevation-800); + } + } +} diff --git a/packages/ui/src/icons/Sort/index.tsx b/packages/ui/src/icons/Sort/index.tsx new file mode 100644 index 0000000000..af1aa7191f --- /dev/null +++ b/packages/ui/src/icons/Sort/index.tsx @@ -0,0 +1,41 @@ +import React from 'react' + +import './index.scss' + +export const SortDownIcon: React.FC<{ className?: string }> = ({ className }) => ( + + + +) + +export const SortUpIcon: React.FC<{ className?: string }> = ({ className }) => ( + + + +) diff --git a/packages/ui/src/providers/ListQuery/index.tsx b/packages/ui/src/providers/ListQuery/index.tsx index ad9dd0669c..62a55dac86 100644 --- a/packages/ui/src/providers/ListQuery/index.tsx +++ b/packages/ui/src/providers/ListQuery/index.tsx @@ -25,6 +25,7 @@ export const ListQueryProvider: React.FC = ({ listPreferences, modifySearchParams, onQueryChange: onQueryChangeFromProps, + orderableFieldName, }) => { 'use no memo' const router = useRouter() @@ -207,6 +208,7 @@ export const ListQueryProvider: React.FC = ({ handleSearchChange, handleSortChange, handleWhereChange, + orderableFieldName, query: currentQuery, refineListData, setModified, diff --git a/packages/ui/src/providers/ListQuery/types.ts b/packages/ui/src/providers/ListQuery/types.ts index 979b960a1f..9ccdb7e513 100644 --- a/packages/ui/src/providers/ListQuery/types.ts +++ b/packages/ui/src/providers/ListQuery/types.ts @@ -29,6 +29,7 @@ export type ListQueryProps = { readonly listPreferences?: ListPreferences readonly modifySearchParams?: boolean readonly onQueryChange?: OnListQueryChange + readonly orderableFieldName?: string /** * @deprecated */ @@ -40,6 +41,7 @@ export type IListQueryContext = { data: PaginatedDocs defaultLimit?: number defaultSort?: Sort + orderableFieldName?: string modified: boolean query: ListQuery refineListData: (args: ListQuery, setModified?: boolean) => Promise diff --git a/packages/ui/src/utilities/buildTableState.ts b/packages/ui/src/utilities/buildTableState.ts index dd00ac59d2..07a8758ea0 100644 --- a/packages/ui/src/utilities/buildTableState.ts +++ b/packages/ui/src/utilities/buildTableState.ts @@ -66,7 +66,7 @@ export const buildTableStateHandler = async ( } } -export const buildTableState = async ( +const buildTableState = async ( args: BuildTableStateArgs, ): Promise => { const { @@ -74,6 +74,7 @@ export const buildTableState = async ( columns, docs: docsFromArgs, enableRowSelections, + orderableFieldName, parent, query, renderRowTypes, @@ -233,6 +234,7 @@ export const buildTableState = async ( docs, enableRowSelections, i18n: req.i18n, + orderableFieldName, payload, renderRowTypes, tableAppearance, diff --git a/packages/ui/src/utilities/renderTable.tsx b/packages/ui/src/utilities/renderTable.tsx index 1699672972..bab2ec6c60 100644 --- a/packages/ui/src/utilities/renderTable.tsx +++ b/packages/ui/src/utilities/renderTable.tsx @@ -13,16 +13,19 @@ import type { import { getTranslation, type I18nClient } from '@payloadcms/translations' import { fieldAffectsData, fieldIsHiddenOrDisabled, flattenTopLevelFields } from 'payload/shared' +import React from 'react' // eslint-disable-next-line payload/no-imports-from-exports-dir import type { Column } from '../exports/client/index.js' import { RenderServerComponent } from '../elements/RenderServerComponent/index.js' +import { SortHeader } from '../elements/SortHeader/index.js' +import { SortRow } from '../elements/SortRow/index.js' +import { OrderableTable } from '../elements/Table/OrderableTable.js' import { buildColumnState } from '../providers/TableColumns/buildColumnState.js' import { buildPolymorphicColumnState } from '../providers/TableColumns/buildPolymorphicColumnState.js' import { filterFields } from '../providers/TableColumns/filterFields.js' import { getInitialColumns } from '../providers/TableColumns/getInitialColumns.js' - // eslint-disable-next-line payload/no-imports-from-exports-dir import { Pill, SelectAll, SelectRow, Table } from '../exports/client/index.js' @@ -62,6 +65,7 @@ export const renderTable = ({ docs, enableRowSelections, i18n, + orderableFieldName, payload, renderRowTypes, tableAppearance, @@ -78,6 +82,7 @@ export const renderTable = ({ drawerSlug?: string enableRowSelections: boolean i18n: I18nClient + orderableFieldName: string payload: Payload renderRowTypes?: boolean tableAppearance?: 'condensed' | 'default' @@ -195,9 +200,38 @@ export const renderTable = ({ } as Column) } + if (!orderableFieldName) { + return { + columnState, + // key is required since Next.js 15.2.0 to prevent React key error + Table: , + } + } + + columnsToUse.unshift({ + accessor: '_dragHandle', + active: true, + field: { + admin: { + disabled: true, + }, + hidden: true, + }, + Heading: , + renderedCells: docs.map((_, i) => ), + } as Column) + return { columnState, // key is required since Next.js 15.2.0 to prevent React key error - Table:
, + Table: ( + + ), } } diff --git a/packages/ui/src/views/List/index.scss b/packages/ui/src/views/List/index.scss index f600fffafb..f3aea24d58 100644 --- a/packages/ui/src/views/List/index.scss +++ b/packages/ui/src/views/List/index.scss @@ -49,6 +49,12 @@ display: flex; min-width: unset; } + + #heading-_dragHandle, + .cell-_dragHandle { + width: 20px; + min-width: 0; + } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f95f70fc37..53129ba489 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,7 +45,7 @@ importers: version: 1.50.0 '@sentry/nextjs': specifier: ^8.33.1 - version: 8.37.1(@opentelemetry/core@1.27.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.54.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.27.0(@opentelemetry/api@1.9.0))(next@15.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-714736e-20250131)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4))(react@19.0.0)(webpack@5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15))) + version: 8.37.1(@opentelemetry/core@1.27.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.54.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.27.0(@opentelemetry/api@1.9.0))(next@15.2.3(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4))(react@19.0.0)(webpack@5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15))) '@sentry/node': specifier: ^8.33.1 version: 8.37.1 @@ -135,7 +135,7 @@ importers: version: 10.1.3(@aws-sdk/credential-providers@3.687.0(@aws-sdk/client-sso-oidc@3.687.0(@aws-sdk/client-sts@3.687.0)))(socks@2.8.3) next: specifier: 15.2.3 - version: 15.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-714736e-20250131)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4) + version: 15.2.3(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-714736e-20250131)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4) open: specifier: ^10.1.0 version: 10.1.0 @@ -1076,7 +1076,7 @@ importers: dependencies: next: specifier: ^15.2.3 - version: 15.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-714736e-20250131)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4) + version: 15.2.3(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-714736e-20250131)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4) devDependencies: '@payloadcms/eslint-config': specifier: workspace:* @@ -1141,7 +1141,7 @@ importers: dependencies: '@sentry/nextjs': specifier: ^8.33.1 - version: 8.37.1(@opentelemetry/core@1.27.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.54.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.27.0(@opentelemetry/api@1.9.0))(next@15.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-714736e-20250131)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4))(react@19.0.0)(webpack@5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15))) + version: 8.37.1(@opentelemetry/core@1.27.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.54.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.27.0(@opentelemetry/api@1.9.0))(next@15.2.3(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4))(react@19.0.0)(webpack@5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15))) '@sentry/types': specifier: ^8.33.1 version: 8.37.1 @@ -1500,7 +1500,7 @@ importers: version: link:../plugin-cloud-storage uploadthing: specifier: 7.3.0 - version: 7.3.0(next@15.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4)) + version: 7.3.0(next@15.2.3(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4)) devDependencies: payload: specifier: workspace:* @@ -1786,7 +1786,7 @@ importers: version: link:../packages/ui '@sentry/nextjs': specifier: ^8.33.1 - version: 8.37.1(@opentelemetry/core@1.27.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.54.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.27.0(@opentelemetry/api@1.9.0))(next@15.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-714736e-20250131)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4))(react@19.0.0)(webpack@5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15))) + version: 8.37.1(@opentelemetry/core@1.27.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.54.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.27.0(@opentelemetry/api@1.9.0))(next@15.2.3(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4))(react@19.0.0)(webpack@5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15))) '@sentry/react': specifier: ^7.77.0 version: 7.119.2(react@19.0.0) @@ -1843,7 +1843,7 @@ importers: version: 8.9.5(@aws-sdk/credential-providers@3.687.0(@aws-sdk/client-sso-oidc@3.687.0(@aws-sdk/client-sts@3.687.0)))(socks@2.8.3) next: specifier: 15.2.3 - version: 15.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-714736e-20250131)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4) + version: 15.2.3(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-714736e-20250131)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4) nodemailer: specifier: 6.9.16 version: 6.9.16 @@ -13719,7 +13719,7 @@ snapshots: '@sentry/utils': 7.119.2 localforage: 1.10.0 - '@sentry/nextjs@8.37.1(@opentelemetry/core@1.27.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.54.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.27.0(@opentelemetry/api@1.9.0))(next@15.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-714736e-20250131)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4))(react@19.0.0)(webpack@5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15)))': + '@sentry/nextjs@8.37.1(@opentelemetry/core@1.27.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.54.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.27.0(@opentelemetry/api@1.9.0))(next@15.2.3(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4))(react@19.0.0)(webpack@5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15)))': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/instrumentation-http': 0.53.0(@opentelemetry/api@1.9.0) @@ -13735,7 +13735,7 @@ snapshots: '@sentry/vercel-edge': 8.37.1 '@sentry/webpack-plugin': 2.22.6(webpack@5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15))) chalk: 3.0.0 - next: 15.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-714736e-20250131)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4) + next: 15.2.3(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-714736e-20250131)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4) resolve: 1.22.8 rollup: 3.29.5 stacktrace-parser: 0.1.10 @@ -18432,35 +18432,6 @@ snapshots: - '@babel/core' - babel-plugin-macros - next@15.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-714736e-20250131)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4): - dependencies: - '@next/env': 15.2.3 - '@swc/counter': 0.1.3 - '@swc/helpers': 0.5.15 - busboy: 1.6.0 - caniuse-lite: 1.0.30001678 - postcss: 8.4.31 - react: 19.0.0 - react-dom: 19.0.0(react@19.0.0) - styled-jsx: 5.1.6(@babel/core@7.26.7)(babel-plugin-macros@3.1.0)(react@19.0.0) - optionalDependencies: - '@next/swc-darwin-arm64': 15.2.3 - '@next/swc-darwin-x64': 15.2.3 - '@next/swc-linux-arm64-gnu': 15.2.3 - '@next/swc-linux-arm64-musl': 15.2.3 - '@next/swc-linux-x64-gnu': 15.2.3 - '@next/swc-linux-x64-musl': 15.2.3 - '@next/swc-win32-arm64-msvc': 15.2.3 - '@next/swc-win32-x64-msvc': 15.2.3 - '@opentelemetry/api': 1.9.0 - '@playwright/test': 1.50.0 - babel-plugin-react-compiler: 19.0.0-beta-714736e-20250131 - sass: 1.77.4 - sharp: 0.33.5 - transitivePeerDependencies: - - '@babel/core' - - babel-plugin-macros - node-abi@3.71.0: dependencies: semver: 7.6.3 @@ -20203,14 +20174,14 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 - uploadthing@7.3.0(next@15.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4)): + uploadthing@7.3.0(next@15.2.3(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4)): dependencies: '@effect/platform': 0.69.8(effect@3.10.3) '@uploadthing/mime-types': 0.3.2 '@uploadthing/shared': 7.1.1 effect: 3.10.3 optionalDependencies: - next: 15.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-714736e-20250131)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4) + next: 15.2.3(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-714736e-20250131)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4) uri-js@4.4.1: dependencies: diff --git a/test/fields/collections/Indexed/e2e.spec.ts b/test/fields/collections/Indexed/e2e.spec.ts index cb90de6806..966e2219ba 100644 --- a/test/fields/collections/Indexed/e2e.spec.ts +++ b/test/fields/collections/Indexed/e2e.spec.ts @@ -114,7 +114,9 @@ describe('Radio', () => { // nested in a group error await page.locator('#field-group__unique').fill(uniqueText) - await wait(1000) + // TODO: used because otherwise the toast locator resolves to 2 items + // at the same time. Instead we should uniquely identify each toast. + await wait(2000) // attempt to save await page.locator('#action-save').click() diff --git a/test/sort/Seed.tsx b/test/sort/Seed.tsx new file mode 100644 index 0000000000..0028083733 --- /dev/null +++ b/test/sort/Seed.tsx @@ -0,0 +1,19 @@ +/* eslint-disable no-console */ +'use client' + +export const Seed = () => { + return ( + + ) +} diff --git a/test/sort/collections/Orderable/index.ts b/test/sort/collections/Orderable/index.ts new file mode 100644 index 0000000000..ccf42c4205 --- /dev/null +++ b/test/sort/collections/Orderable/index.ts @@ -0,0 +1,27 @@ +import type { CollectionConfig } from 'payload' + +import { orderableJoinSlug } from '../OrderableJoin/index.js' + +export const orderableSlug = 'orderable' + +export const OrderableCollection: CollectionConfig = { + slug: orderableSlug, + orderable: true, + admin: { + useAsTitle: 'title', + components: { + beforeList: ['/Seed.tsx#Seed'], + }, + }, + fields: [ + { + name: 'title', + type: 'text', + }, + { + name: 'orderableField', + type: 'relationship', + relationTo: orderableJoinSlug, + }, + ], +} diff --git a/test/sort/collections/OrderableJoin/index.ts b/test/sort/collections/OrderableJoin/index.ts new file mode 100644 index 0000000000..8e4ee728cd --- /dev/null +++ b/test/sort/collections/OrderableJoin/index.ts @@ -0,0 +1,39 @@ +import type { CollectionConfig } from 'payload' + +export const orderableJoinSlug = 'orderable-join' + +export const OrderableJoinCollection: CollectionConfig = { + slug: orderableJoinSlug, + admin: { + useAsTitle: 'title', + components: { + beforeList: ['/Seed.tsx#Seed'], + }, + }, + fields: [ + { + name: 'title', + type: 'text', + }, + { + name: 'orderableJoinField1', + type: 'join', + on: 'orderableField', + orderable: true, + collection: 'orderable', + }, + { + name: 'orderableJoinField2', + type: 'join', + on: 'orderableField', + orderable: true, + collection: 'orderable', + }, + { + name: 'nonOrderableJoinField', + type: 'join', + on: 'orderableField', + collection: 'orderable', + }, + ], +} diff --git a/test/sort/config.ts b/test/sort/config.ts index 78868504bc..5e6a1bed98 100644 --- a/test/sort/config.ts +++ b/test/sort/config.ts @@ -1,3 +1,5 @@ +import type { CollectionSlug, Payload } from 'payload' + import { fileURLToPath } from 'node:url' import path from 'path' @@ -6,17 +8,39 @@ import { devUser } from '../credentials.js' import { DefaultSortCollection } from './collections/DefaultSort/index.js' import { DraftsCollection } from './collections/Drafts/index.js' import { LocalizedCollection } from './collections/Localized/index.js' +import { OrderableCollection } from './collections/Orderable/index.js' +import { OrderableJoinCollection } from './collections/OrderableJoin/index.js' import { PostsCollection } from './collections/Posts/index.js' const filename = fileURLToPath(import.meta.url) const dirname = path.dirname(filename) export default buildConfigWithDefaults({ - collections: [PostsCollection, DraftsCollection, DefaultSortCollection, LocalizedCollection], + collections: [ + PostsCollection, + DraftsCollection, + DefaultSortCollection, + LocalizedCollection, + OrderableCollection, + OrderableJoinCollection, + ], admin: { importMap: { baseDir: path.resolve(dirname), }, }, + endpoints: [ + { + path: '/seed', + method: 'post', + handler: async (req) => { + await seedSortable(req.payload) + return new Response(JSON.stringify({ success: true }), { + headers: { 'Content-Type': 'application/json' }, + status: 200, + }) + }, + }, + ], cors: ['http://localhost:3000', 'http://localhost:3001'], localization: { locales: ['en', 'nb'], @@ -30,8 +54,41 @@ export default buildConfigWithDefaults({ password: devUser.password, }, }) + await seedSortable(payload) }, typescript: { outputFile: path.resolve(dirname, 'payload-types.ts'), }, }) + +export async function createData( + payload: Payload, + collection: CollectionSlug, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data: Record[], +) { + for (const item of data) { + await payload.create({ collection, data: item }) + } +} + +async function seedSortable(payload: Payload) { + await payload.delete({ collection: 'orderable', where: {} }) + await payload.delete({ collection: 'orderable-join', where: {} }) + + const joinA = await payload.create({ collection: 'orderable-join', data: { title: 'Join A' } }) + + await createData(payload, 'orderable', [ + { title: 'A', orderableField: joinA.id }, + { title: 'B', orderableField: joinA.id }, + { title: 'C', orderableField: joinA.id }, + { title: 'D', orderableField: joinA.id }, + ]) + + await payload.create({ collection: 'orderable-join', data: { title: 'Join B' } }) + + return new Response(JSON.stringify({ success: true }), { + headers: { 'Content-Type': 'application/json' }, + status: 200, + }) +} diff --git a/test/sort/e2e.spec.ts b/test/sort/e2e.spec.ts new file mode 100644 index 0000000000..fdc53d19dc --- /dev/null +++ b/test/sort/e2e.spec.ts @@ -0,0 +1,152 @@ +import type { BrowserContext, Page } from '@playwright/test' + +import { expect, test } from '@playwright/test' +import { RESTClient } from 'helpers/rest.js' +import path from 'path' +import { fileURLToPath } from 'url' + +import type { PayloadTestSDK } from '../helpers/sdk/index.js' +import type { Config } from './payload-types.js' + +import { ensureCompilationIsDone, initPageConsoleErrorCatch } from '../helpers.js' +import { AdminUrlUtil } from '../helpers/adminUrlUtil.js' +import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js' +import { TEST_TIMEOUT_LONG } from '../playwright.config.js' +import { orderableSlug } from './collections/Orderable/index.js' +import { orderableJoinSlug } from './collections/OrderableJoin/index.js' +const filename = fileURLToPath(import.meta.url) +const dirname = path.dirname(filename) + +const { beforeAll, describe } = test +let page: Page +// eslint-disable-next-line @typescript-eslint/no-unused-vars +let payload: PayloadTestSDK +let client: RESTClient +let serverURL: string +let context: BrowserContext + +describe('Sort functionality', () => { + beforeAll(async ({ browser }, testInfo) => { + testInfo.setTimeout(TEST_TIMEOUT_LONG) + ;({ payload, serverURL } = await initPayloadE2ENoConfig({ dirname })) + + context = await browser.newContext() + page = await context.newPage() + + initPageConsoleErrorCatch(page) + + client = new RESTClient({ defaultSlug: 'users', serverURL }) + await client.login() + + await ensureCompilationIsDone({ page, serverURL }) + }) + + // NOTES: It works for me in headed browser but not in headless, I don't know why. + // If you are debugging this test, remember to press the seed button before each attempt. + // assertRows contains expect + // eslint-disable-next-line playwright/expect-expect + test('Orderable collection', async () => { + const url = new AdminUrlUtil(serverURL, orderableSlug) + await page.goto(`${url.list}?sort=-_order`) + // SORT BY ORDER ASCENDING + await page.locator('.sort-header button').nth(0).click() + await assertRows(0, 'A', 'B', 'C', 'D') + await moveRow(2, 3) // move to middle + await assertRows(0, 'A', 'C', 'B', 'D') + await moveRow(3, 1) // move to top + await assertRows(0, 'B', 'A', 'C', 'D') + await moveRow(1, 4) // move to bottom + await assertRows(0, 'A', 'C', 'D', 'B') + + // SORT BY ORDER DESCENDING + await page.locator('.sort-header button').nth(0).click() + await page.waitForURL(/sort=-_order/, { timeout: 2000 }) + await assertRows(0, 'B', 'D', 'C', 'A') + await moveRow(1, 3) // move to middle + await assertRows(0, 'D', 'C', 'B', 'A') + await moveRow(3, 1) // move to top + await assertRows(0, 'B', 'D', 'C', 'A') + await moveRow(1, 4) // move to bottom + await assertRows(0, 'D', 'C', 'A', 'B') + + // SORT BY TITLE + await page.getByLabel('Sort by Title Ascending').click() + await page.waitForURL(/sort=title/, { timeout: 2000 }) + await moveRow(1, 3, 'warning') // warning because not sorted by order first + }) + + test('Orderable join fields', async () => { + const url = new AdminUrlUtil(serverURL, orderableJoinSlug) + await page.goto(url.list) + + await page.getByText('Join A').click() + await expect(page.locator('.sort-header button')).toHaveCount(2) + + await page.locator('.sort-header button').nth(0).click() + await assertRows(0, 'A', 'B', 'C', 'D') + await moveRow(2, 3, 'success', 0) // move to middle + await assertRows(0, 'A', 'C', 'B', 'D') + + await page.locator('.sort-header button').nth(1).click() + await assertRows(1, 'A', 'B', 'C', 'D') + await moveRow(1, 4, 'success', 1) // move to end + await assertRows(1, 'B', 'C', 'D', 'A') + + await page.reload() + await page.locator('.sort-header button').nth(0).click() + await page.locator('.sort-header button').nth(1).click() + await assertRows(0, 'A', 'C', 'B', 'D') + await assertRows(1, 'B', 'C', 'D', 'A') + }) +}) + +async function moveRow( + from: number, + to: number, + expected: 'success' | 'warning' = 'success', + nthTable = 0, +) { + // counting from 1, zero excluded + const table = page.locator(`tbody`).nth(nthTable) + const dragHandle = table.locator(`.sort-row`) + const source = dragHandle.nth(from - 1) + const target = dragHandle.nth(to - 1) + + const sourceBox = await source.boundingBox() + const targetBox = await target.boundingBox() + if (!sourceBox || !targetBox) { + throw new Error( + `Could not find elements to DnD. Probably the dndkit animation is not finished. Try increasing the timeout`, + ) + } + // steps is important: move slightly to trigger the drag sensor of DnD-kit + await page.mouse.move(sourceBox.x + sourceBox.width / 2, sourceBox.y + sourceBox.height / 2, { + steps: 10, + }) + await page.mouse.down() + await page.mouse.move(targetBox.x + targetBox.width / 2, targetBox.y + targetBox.height / 2, { + steps: 10, + }) + await page.mouse.up() + // eslint-disable-next-line playwright/no-wait-for-timeout + await page.waitForTimeout(400) // dndkit animation + + if (expected === 'warning') { + const toast = page.locator('.payload-toast-item.toast-warning') + await expect(toast).toHaveText( + 'To reorder the rows you must first sort them by the "Order" column', + ) + } +} + +async function assertRows(nthTable: number, ...expectedRows: Array) { + const table = page.locator('tbody').nth(nthTable) + const cellTitle = table.locator('.cell-title > :first-child') + + const rows = table.locator('.sort-row') + await expect.poll(() => rows.count()).toBe(expectedRows.length) + + for (let i = 0; i < expectedRows.length; i++) { + await expect(cellTitle.nth(i)).toHaveText(expectedRows[i]!) + } +} diff --git a/test/sort/int.spec.ts b/test/sort/int.spec.ts index 805370aa11..12689c3393 100644 --- a/test/sort/int.spec.ts +++ b/test/sort/int.spec.ts @@ -4,8 +4,11 @@ import path from 'path' import { fileURLToPath } from 'url' import type { NextRESTClient } from '../helpers/NextRESTClient.js' +import type { Orderable, OrderableJoin } from './payload-types.js' import { initPayloadInt } from '../helpers/initPayloadInt.js' +import { orderableSlug } from './collections/Orderable/index.js' +import { orderableJoinSlug } from './collections/OrderableJoin/index.js' let payload: Payload let restClient: NextRESTClient @@ -63,7 +66,7 @@ describe('Sort', () => { }) }) - describe('Sinlge sort field', () => { + describe('Single sort field', () => { it('should sort posts by text field', async () => { const posts = await payload.find({ collection: 'posts', @@ -326,6 +329,84 @@ describe('Sort', () => { ]) }) }) + + describe('Orderable join', () => { + let related: OrderableJoin + let orderable1: Orderable + let orderable2: Orderable + let orderable3: Orderable + + beforeAll(async () => { + related = await payload.create({ + collection: orderableJoinSlug, + data: { + title: 'test', + }, + }) + orderable1 = await payload.create({ + collection: orderableSlug, + data: { + title: 'test 1', + orderableField: related.id, + }, + }) + + orderable2 = await payload.create({ + collection: orderableSlug, + data: { + title: 'test 2', + orderableField: related.id, + }, + }) + + orderable3 = await payload.create({ + collection: orderableSlug, + data: { + title: 'test 3', + orderableField: related.id, + }, + }) + }) + + it('should set order by default', () => { + expect(orderable1._orderable_orderableJoinField1_order).toBeDefined() + }) + + it('should allow setting the order with the local API', async () => { + // create two orderableJoinSlug docs + orderable2 = await payload.update({ + collection: orderableSlug, + id: orderable2.id, + data: { + title: 'test', + orderableField: related.id, + _orderable_orderableJoinField1_order: 'e4', + }, + }) + const orderable4 = await payload.create({ + collection: orderableSlug, + data: { + title: 'test', + orderableField: related.id, + _orderable_orderableJoinField1_order: 'e2', + }, + }) + expect(orderable2._orderable_orderableJoinField1_order).toBe('e4') + expect(orderable4._orderable_orderableJoinField1_order).toBe('e2') + }) + it('should sort join docs in the correct', async () => { + related = await payload.findByID({ + collection: orderableJoinSlug, + id: related.id, + depth: 1, + }) + const orders = (related.orderableJoinField1 as { docs: Orderable[] }).docs.map((doc) => + parseInt(doc._orderable_orderableJoinField1_order, 16), + ) as [number, number, number] + expect(orders[0]).toBeLessThan(orders[1]) + expect(orders[1]).toBeLessThan(orders[2]) + }) + }) }) describe('REST API', () => { @@ -344,7 +425,7 @@ describe('Sort', () => { await payload.delete({ collection: 'posts', where: {} }) }) - describe('Sinlge sort field', () => { + describe('Single sort field', () => { it('should sort posts by text field', async () => { const res = await restClient .GET(`/posts`, { diff --git a/test/sort/payload-types.ts b/test/sort/payload-types.ts index 375215f7d4..887eb15457 100644 --- a/test/sort/payload-types.ts +++ b/test/sort/payload-types.ts @@ -54,6 +54,7 @@ export type SupportedTimezones = | 'Asia/Singapore' | 'Asia/Tokyo' | 'Asia/Seoul' + | 'Australia/Brisbane' | 'Australia/Sydney' | 'Pacific/Guam' | 'Pacific/Noumea' @@ -70,17 +71,27 @@ export interface Config { drafts: Draft; 'default-sort': DefaultSort; localized: Localized; + orderable: Orderable; + 'orderable-join': OrderableJoin; users: User; 'payload-locked-documents': PayloadLockedDocument; 'payload-preferences': PayloadPreference; 'payload-migrations': PayloadMigration; }; - collectionsJoins: {}; + collectionsJoins: { + 'orderable-join': { + orderableJoinField1: 'orderable'; + orderableJoinField2: 'orderable'; + nonOrderableJoinField: 'orderable'; + }; + }; collectionsSelect: { posts: PostsSelect | PostsSelect; drafts: DraftsSelect | DraftsSelect; 'default-sort': DefaultSortSelect | DefaultSortSelect; localized: LocalizedSelect | LocalizedSelect; + orderable: OrderableSelect | OrderableSelect; + 'orderable-join': OrderableJoinSelect | OrderableJoinSelect; users: UsersSelect | UsersSelect; 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; 'payload-preferences': PayloadPreferencesSelect | PayloadPreferencesSelect; @@ -174,6 +185,45 @@ export interface Localized { updatedAt: string; createdAt: string; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "orderable". + */ +export interface Orderable { + id: string; + _orderable_orderableJoinField2_order?: string; + _orderable_orderableJoinField1_order?: string; + _order?: string; + title?: string | null; + orderableField?: (string | null) | OrderableJoin; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "orderable-join". + */ +export interface OrderableJoin { + id: string; + title?: string | null; + orderableJoinField1?: { + docs?: (string | Orderable)[]; + hasNextPage?: boolean; + totalDocs?: number; + }; + orderableJoinField2?: { + docs?: (string | Orderable)[]; + hasNextPage?: boolean; + totalDocs?: number; + }; + nonOrderableJoinField?: { + docs?: (string | Orderable)[]; + hasNextPage?: boolean; + totalDocs?: number; + }; + updatedAt: string; + createdAt: string; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "users". @@ -214,6 +264,14 @@ export interface PayloadLockedDocument { relationTo: 'localized'; value: string | Localized; } | null) + | ({ + relationTo: 'orderable'; + value: string | Orderable; + } | null) + | ({ + relationTo: 'orderable-join'; + value: string | OrderableJoin; + } | null) | ({ relationTo: 'users'; value: string | User; @@ -316,6 +374,31 @@ export interface LocalizedSelect { updatedAt?: T; createdAt?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "orderable_select". + */ +export interface OrderableSelect { + _orderable_orderableJoinField2_order?: T; + _orderable_orderableJoinField1_order?: T; + _order?: T; + title?: T; + orderableField?: T; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "orderable-join_select". + */ +export interface OrderableJoinSelect { + title?: T; + orderableJoinField1?: T; + orderableJoinField2?: T; + nonOrderableJoinField?: T; + updatedAt?: T; + createdAt?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "users_select".