feat: orderable collections (#11452)
Closes https://github.com/payloadcms/payload/discussions/1413 ### What? Introduces a new `orderable` boolean property on collections that allows dragging and dropping rows to reorder them: https://github.com/user-attachments/assets/8ee85cf0-add1-48e5-a0a2-f73ad66aa24a ### Why? [One of the most requested features](https://github.com/payloadcms/payload/discussions/1413). Additionally, poorly implemented it can be very costly in terms of performance. This can be especially useful for implementing custom views like kanban. ### How? We are using fractional indexing. In its simplest form, it consists of calculating the order of an item to be inserted as the average of its two adjacent elements. There is [a famous article by David Greenspan](https://observablehq.com/@dgreensp/implementing-fractional-indexing) that solves the problem of running out of keys after several partitions. We are using his algorithm, implemented [in this library](https://github.com/rocicorp/fractional-indexing). This means that if you insert, delete or move documents in the collection, you do not have to modify the order of the rest of the documents, making the operation more performant. --------- Co-authored-by: Dan Ribbens <dan.ribbens@gmail.com>
This commit is contained in:
19
test/sort/Seed.tsx
Normal file
19
test/sort/Seed.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
/* eslint-disable no-console */
|
||||
'use client'
|
||||
|
||||
export const Seed = () => {
|
||||
return (
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await fetch('/api/seed', { method: 'POST' })
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Seed
|
||||
</button>
|
||||
)
|
||||
}
|
||||
27
test/sort/collections/Orderable/index.ts
Normal file
27
test/sort/collections/Orderable/index.ts
Normal file
@@ -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,
|
||||
},
|
||||
],
|
||||
}
|
||||
39
test/sort/collections/OrderableJoin/index.ts
Normal file
39
test/sort/collections/OrderableJoin/index.ts
Normal file
@@ -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',
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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<string, any>[],
|
||||
) {
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
152
test/sort/e2e.spec.ts
Normal file
152
test/sort/e2e.spec.ts
Normal file
@@ -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<Config>
|
||||
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<Config>({ 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<string>) {
|
||||
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]!)
|
||||
}
|
||||
}
|
||||
@@ -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`, {
|
||||
|
||||
@@ -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<false> | PostsSelect<true>;
|
||||
drafts: DraftsSelect<false> | DraftsSelect<true>;
|
||||
'default-sort': DefaultSortSelect<false> | DefaultSortSelect<true>;
|
||||
localized: LocalizedSelect<false> | LocalizedSelect<true>;
|
||||
orderable: OrderableSelect<false> | OrderableSelect<true>;
|
||||
'orderable-join': OrderableJoinSelect<false> | OrderableJoinSelect<true>;
|
||||
users: UsersSelect<false> | UsersSelect<true>;
|
||||
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
|
||||
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
|
||||
@@ -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<T extends boolean = true> {
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "orderable_select".
|
||||
*/
|
||||
export interface OrderableSelect<T extends boolean = true> {
|
||||
_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<T extends boolean = true> {
|
||||
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".
|
||||
|
||||
Reference in New Issue
Block a user