feat!: on demand rsc (#8364)

Currently, Payload renders all custom components on initial compile of
the admin panel. This is problematic for two key reasons:
1. Custom components do not receive contextual data, i.e. fields do not
receive their field data, edit views do not receive their document data,
etc.
2. Components are unnecessarily rendered before they are used

This was initially required to support React Server Components within
the Payload Admin Panel for two key reasons:
1. Fields can be dynamically rendered within arrays, blocks, etc.
2. Documents can be recursively rendered within a "drawer" UI, i.e.
relationship fields
3. Payload supports server/client component composition 

In order to achieve this, components need to be rendered on the server
and passed as "slots" to the client. Currently, the pattern for this is
to render custom server components in the "client config". Then when a
view or field is needed to be rendered, we first check the client config
for a "pre-rendered" component, otherwise render our client-side
fallback component.

But for the reasons listed above, this pattern doesn't exactly make
custom server components very useful within the Payload Admin Panel,
which is where this PR comes in. Now, instead of pre-rendering all
components on initial compile, we're able to render custom components
_on demand_, only as they are needed.

To achieve this, we've established [this
pattern](https://github.com/payloadcms/payload/pull/8481) of React
Server Functions in the Payload Admin Panel. With Server Functions, we
can iterate the Payload Config and return JSX through React's
`text/x-component` content-type. This means we're able to pass
contextual props to custom components, such as data for fields and
views.

## Breaking Changes

1. Add the following to your root layout file, typically located at
`(app)/(payload)/layout.tsx`:

    ```diff
    /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
    /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
    + import type { ServerFunctionClient } from 'payload'

    import config from '@payload-config'
    import { RootLayout } from '@payloadcms/next/layouts'
    import { handleServerFunctions } from '@payloadcms/next/utilities'
    import React from 'react'

    import { importMap } from './admin/importMap.js'
    import './custom.scss'

    type Args = {
      children: React.ReactNode
    }

+ const serverFunctions: ServerFunctionClient = async function (args) {
    +  'use server'
    +  return handleServerFunctions({
    +    ...args,
    +    config,
    +    importMap,
    +  })
    + }

    const Layout = ({ children }: Args) => (
      <RootLayout
        config={config}
        importMap={importMap}
    +  serverFunctions={serverFunctions}
      >
        {children}
      </RootLayout>
    )

    export default Layout
    ```

2. If you were previously posting to the `/api/form-state` endpoint, it
no longer exists. Instead, you'll need to invoke the `form-state` Server
Function, which can be done through the _new_ `getFormState` utility:

    ```diff
    - import { getFormState } from '@payloadcms/ui'
    - const { state } = await getFormState({
    -   apiRoute: '',
    -   body: {
    -     // ...
    -   },
    -   serverURL: ''
    - })

    + const { getFormState } = useServerFunctions()
    +
    + const { state } = await getFormState({
    +   // ...
    + })
    ```

## Breaking Changes

```diff
- useFieldProps()
- useCellProps()
```

More details coming soon.

---------

Co-authored-by: Alessio Gravili <alessio@gravili.de>
Co-authored-by: Jarrod Flesch <jarrodmflesch@gmail.com>
Co-authored-by: James <james@trbl.design>
This commit is contained in:
Jacob Fletcher
2024-11-11 13:59:05 -05:00
committed by GitHub
parent 3e954f45c7
commit c96fa613bc
657 changed files with 34245 additions and 21057 deletions

View File

@@ -43,17 +43,13 @@ describe('Array', () => {
const context = await browser.newContext()
page = await context.newPage()
initPageConsoleErrorCatch(page)
await reInitializeDB({
serverURL,
snapshotKey: 'fieldsArrayTest',
uploadsDir: path.resolve(dirname, './collections/Upload/uploads'),
})
await ensureCompilationIsDone({ page, serverURL })
})
beforeEach(async () => {
await reInitializeDB({
serverURL,
snapshotKey: 'fieldsArrayTest',
snapshotKey: 'fieldsTest',
uploadsDir: path.resolve(dirname, './collections/Upload/uploads'),
})

View File

@@ -27,6 +27,15 @@ const ArrayFields: CollectionConfig = {
name: 'anotherText',
type: 'text',
},
{
name: 'uiField',
type: 'ui',
admin: {
components: {
Field: './collections/Array/LabelComponent.js#ArrayRowLabel',
},
},
},
{
name: 'localizedText',
type: 'text',

View File

@@ -3,29 +3,38 @@
import { useField, useForm } from '@payloadcms/ui'
import * as React from 'react'
import { blockFieldsSlug } from '../../../../slugs.js'
import './index.scss'
const baseClass = 'custom-blocks-field-management'
const blocksPath = 'customBlocks'
export const AddCustomBlocks: React.FC = () => {
export const AddCustomBlocks: React.FC<any> = (props) => {
const { addFieldRow, replaceFieldRow } = useForm()
const { value } = useField<number>({ path: blocksPath })
const field = useField<number>({ path: blocksPath })
const { value } = field
const schemaPath = props.schemaPath.replace(`.${props.field.name}`, `.${blocksPath}`)
return (
<div className={baseClass}>
<div className={`${baseClass}__blocks-grid`}>
<button
className={`${baseClass}__block-button`}
onClick={() =>
onClick={() => {
addFieldRow({
data: { block1Title: 'Block 1: Prefilled Title', blockType: 'block-1' },
blockType: 'block-1',
path: blocksPath,
schemaPath: `${blockFieldsSlug}.${blocksPath}.block-1`,
schemaPath,
subFieldState: {
block1Title: {
initialValue: 'Block 1: Prefilled Title',
valid: true,
value: 'Block 1: Prefilled Title',
},
},
})
}
}}
type="button"
>
Add Block 1
@@ -33,13 +42,20 @@ export const AddCustomBlocks: React.FC = () => {
<button
className={`${baseClass}__block-button`}
onClick={() =>
onClick={() => {
addFieldRow({
data: { block2Title: 'Block 2: Prefilled Title', blockType: 'block-2' },
blockType: 'block-2',
path: blocksPath,
schemaPath: `${blockFieldsSlug}.${blocksPath}.block-2`,
schemaPath,
subFieldState: {
block2Title: {
initialValue: 'Block 2: Prefilled Title',
valid: true,
value: 'Block 2: Prefilled Title',
},
},
})
}
}}
type="button"
>
Add Block 2
@@ -51,10 +67,17 @@ export const AddCustomBlocks: React.FC = () => {
className={`${baseClass}__block-button ${baseClass}__replace-block-button`}
onClick={() =>
replaceFieldRow({
data: { block1Title: 'REPLACED BLOCK', blockType: 'block-1' },
blockType: 'block-1',
path: blocksPath,
rowIndex: value - 1,
schemaPath: `${blockFieldsSlug}.${blocksPath}.block-1`,
rowIndex: value,
schemaPath,
subFieldState: {
block1Title: {
initialValue: 'REPLACED BLOCK',
valid: true,
value: 'REPLACED BLOCK',
},
},
})
}
type="button"

View File

@@ -38,17 +38,13 @@ describe('Block fields', () => {
const context = await browser.newContext()
page = await context.newPage()
initPageConsoleErrorCatch(page)
await reInitializeDB({
serverURL,
snapshotKey: 'blockFieldsTest',
uploadsDir: path.resolve(dirname, './collections/Upload/uploads'),
})
await ensureCompilationIsDone({ page, serverURL })
})
beforeEach(async () => {
await reInitializeDB({
serverURL,
snapshotKey: 'blockFieldsTest',
snapshotKey: 'fieldsTest',
uploadsDir: path.resolve(dirname, './collections/Upload/uploads'),
})

View File

@@ -1,11 +1,11 @@
import type { BlockField, CollectionConfig } from 'payload'
import type { BlocksField, CollectionConfig } from 'payload'
import { slateEditor } from '@payloadcms/richtext-slate'
import { blockFieldsSlug, textFieldsSlug } from '../../slugs.js'
import { getBlocksFieldSeedData } from './shared.js'
export const getBlocksField = (prefix?: string): BlockField => ({
export const getBlocksField = (prefix?: string): BlocksField => ({
name: 'blocks',
type: 'blocks',
blocks: [

View File

@@ -32,11 +32,6 @@ export const getBlocksFieldSeedData = (prefix?: string): any => [
},
],
},
{
blockName: 'I18n Block',
blockType: 'i18n-text',
text: 'first block',
},
]
export const blocksDoc: Partial<BlockField> = {

View File

@@ -1,4 +1,4 @@
import type { RowLabelComponent } from 'payload'
import type { CollapsibleField } from 'payload'
import type React from 'react'
export const getCustomLabel = ({
@@ -9,7 +9,7 @@ export const getCustomLabel = ({
fallback?: string
path: string
style: React.CSSProperties
}): RowLabelComponent => {
}): CollapsibleField['admin']['components']['Label'] => {
return {
clientProps: {
fallback,

View File

@@ -82,7 +82,7 @@ const CollapsibleFields: CollectionConfig = {
description: 'Collapsible label rendered from a function.',
initCollapsed: true,
components: {
RowLabel: getCustomLabel({
Label: getCustomLabel({
path: 'functionTitleField',
fallback: 'Custom Collapsible Label',
style: {},
@@ -101,7 +101,7 @@ const CollapsibleFields: CollectionConfig = {
admin: {
description: 'Collapsible label rendered as a react component.',
components: {
RowLabel: getCustomLabel({ path: 'componentTitleField', style: {} }),
Label: getCustomLabel({ path: 'componentTitleField', style: {} }),
},
},
fields: [
@@ -113,7 +113,7 @@ const CollapsibleFields: CollectionConfig = {
type: 'collapsible',
admin: {
components: {
RowLabel: getCustomLabel({
Label: getCustomLabel({
path: 'nestedTitle',
fallback: 'Nested Collapsible',
style: {},
@@ -134,12 +134,12 @@ const CollapsibleFields: CollectionConfig = {
type: 'array',
fields: [
{
type: 'collapsible',
admin: {
components: {
RowLabel: '/collections/Collapsible/NestedCustomLabel/index.js#NestedCustomLabel',
Label: '/collections/Collapsible/NestedCustomLabel/index.js#NestedCustomLabel',
},
},
type: 'collapsible',
fields: [
{
name: 'innerCollapsible',

View File

@@ -45,17 +45,13 @@ describe('Date', () => {
const context = await browser.newContext()
page = await context.newPage()
initPageConsoleErrorCatch(page)
await reInitializeDB({
serverURL,
snapshotKey: 'fieldsDateTest',
uploadsDir: path.resolve(dirname, './collections/Upload/uploads'),
})
await ensureCompilationIsDone({ page, serverURL })
})
beforeEach(async () => {
await reInitializeDB({
serverURL,
snapshotKey: 'fieldsDateTest',
snapshotKey: 'fieldsTest',
uploadsDir: path.resolve(dirname, './collections/Upload/uploads'),
})

View File

@@ -2,16 +2,11 @@
import type { EmailFieldClientComponent } from 'payload'
import { useFieldProps } from '@payloadcms/ui'
import React from 'react'
export const CustomLabel: EmailFieldClientComponent = ({ field }) => {
const { path: pathFromContext } = useFieldProps()
const path = pathFromContext ?? field?._schemaPath // pathFromContext will be undefined in list view
export const CustomLabel: EmailFieldClientComponent = ({ path }) => {
return (
<label className="custom-label" htmlFor={`field-${path.replace(/\./g, '__')}`}>
<label className="custom-label" htmlFor={`field-${path?.replace(/\./g, '__')}`}>
#label
</label>
)

View File

@@ -48,17 +48,13 @@ describe('Email', () => {
const context = await browser.newContext()
page = await context.newPage()
initPageConsoleErrorCatch(page)
await reInitializeDB({
serverURL,
snapshotKey: 'fieldsEmailTest',
uploadsDir: path.resolve(dirname, './collections/Upload/uploads'),
})
await ensureCompilationIsDone({ page, serverURL })
})
beforeEach(async () => {
await reInitializeDB({
serverURL,
snapshotKey: 'fieldsEmailTest',
snapshotKey: 'fieldsTest',
uploadsDir: path.resolve(dirname, './collections/Upload/uploads'),
})

View File

@@ -69,11 +69,7 @@ describe('lexicalBlocks', () => {
page = await context.newPage()
initPageConsoleErrorCatch(page)
await reInitializeDB({
serverURL,
snapshotKey: 'fieldsLexicalBlocksTest',
uploadsDir: path.resolve(dirname, './collections/Upload/uploads'),
})
await ensureCompilationIsDone({ page, serverURL })
})
beforeEach(async () => {
@@ -84,7 +80,7 @@ describe('lexicalBlocks', () => {
})*/
await reInitializeDB({
serverURL,
snapshotKey: 'fieldsLexicalBlocksTest',
snapshotKey: 'fieldsTest',
uploadsDir: [
path.resolve(dirname, './collections/Upload/uploads'),
path.resolve(dirname, './collections/Upload2/uploads2'),
@@ -104,6 +100,8 @@ describe('lexicalBlocks', () => {
const richTextField = page.locator('.rich-text-lexical').nth(2) // second
await richTextField.scrollIntoViewIfNeeded()
await expect(richTextField).toBeVisible()
// Wait until there at least 10 blocks visible in that richtext field - thus wait for it to be fully loaded
await expect(richTextField.locator('.lexical-block')).toHaveCount(10)
const lexicalBlock = richTextField.locator('.lexical-block').nth(2) // third: "Block Node, with RichText Field, with Relationship Node"
await lexicalBlock.scrollIntoViewIfNeeded()
@@ -160,6 +158,8 @@ describe('lexicalBlocks', () => {
const richTextField = page.locator('.rich-text-lexical').nth(2) // second
await richTextField.scrollIntoViewIfNeeded()
await expect(richTextField).toBeVisible()
// Wait until there at least 10 blocks visible in that richtext field - thus wait for it to be fully loaded
await expect(richTextField.locator('.lexical-block')).toHaveCount(10)
const lexicalBlock = richTextField.locator('.lexical-block').nth(2) // third: "Block Node, with RichText Field, with Relationship Node"
await lexicalBlock.scrollIntoViewIfNeeded()
@@ -242,6 +242,8 @@ describe('lexicalBlocks', () => {
const richTextField = page.locator('.rich-text-lexical').nth(2) // second
await richTextField.scrollIntoViewIfNeeded()
await expect(richTextField).toBeVisible()
// Wait until there at least 10 blocks visible in that richtext field - thus wait for it to be fully loaded
await expect(richTextField.locator('.lexical-block')).toHaveCount(10)
// Find span in contentEditable with text "Some text below relationship node"
const spanInEditor = richTextField.locator('span').getByText('Upload Node:').first()
@@ -272,7 +274,8 @@ describe('lexicalBlocks', () => {
const urlField = drawerContent.locator('input#field-url').first()
await expect(urlField).toBeVisible()
// Fill with https://www.payloadcms.com
await expect(urlField).toHaveValue('https://')
await wait(1000)
await urlField.fill('https://www.payloadcms.com')
await expect(urlField).toHaveValue('https://www.payloadcms.com')
await drawerContent.locator('.form-submit button').click({ delay: 100 })
@@ -326,6 +329,8 @@ describe('lexicalBlocks', () => {
const richTextField = page.locator('.rich-text-lexical').nth(2) // second
await richTextField.scrollIntoViewIfNeeded()
await expect(richTextField).toBeVisible()
// Wait until there at least 10 blocks visible in that richtext field - thus wait for it to be fully loaded
await expect(richTextField.locator('.lexical-block')).toHaveCount(10)
const lexicalBlock = richTextField.locator('.lexical-block').nth(2) // third: "Block Node, with RichText Field, with Relationship Node"
await lexicalBlock.scrollIntoViewIfNeeded()
@@ -399,6 +404,8 @@ describe('lexicalBlocks', () => {
const richTextField = page.locator('.rich-text-lexical').nth(2) // second
await richTextField.scrollIntoViewIfNeeded()
await expect(richTextField).toBeVisible()
// Wait until there at least 10 blocks visible in that richtext field - thus wait for it to be fully loaded
await expect(richTextField.locator('.lexical-block')).toHaveCount(10)
const lexicalBlock = richTextField.locator('.lexical-block').nth(3) // third: "Block Node, with Blocks Field, With RichText Field, With Relationship Node"
await lexicalBlock.scrollIntoViewIfNeeded()
@@ -474,6 +481,8 @@ describe('lexicalBlocks', () => {
const richTextField = page.locator('.rich-text-lexical').nth(2) // second
await richTextField.scrollIntoViewIfNeeded()
await expect(richTextField).toBeVisible()
// Wait until there at least 10 blocks visible in that richtext field - thus wait for it to be fully loaded
await expect(richTextField.locator('.lexical-block')).toHaveCount(10)
const lastParagraph = richTextField.locator('p').last()
await lastParagraph.scrollIntoViewIfNeeded()
@@ -693,6 +702,9 @@ describe('lexicalBlocks', () => {
const richTextField = page.locator('.rich-text-lexical').nth(2) // second
await richTextField.scrollIntoViewIfNeeded()
await expect(richTextField).toBeVisible()
// Wait until there at least 10 blocks visible in that richtext field - thus wait for it to be fully loaded
await expect(richTextField.locator('.lexical-block')).toHaveCount(10)
await wait(1000) // Wait for form state requests to be done, to reduce flakes
const radioButtonBlock1 = richTextField.locator('.lexical-block').nth(5)
@@ -765,6 +777,8 @@ describe('lexicalBlocks', () => {
const richTextField = page.locator('.rich-text-lexical').nth(2) // second
await richTextField.scrollIntoViewIfNeeded()
await expect(richTextField).toBeVisible()
// Wait until there at least 10 blocks visible in that richtext field - thus wait for it to be fully loaded
await expect(richTextField.locator('.lexical-block')).toHaveCount(10)
/**
* 1. Focus parent editor
@@ -805,6 +819,8 @@ describe('lexicalBlocks', () => {
const richTextField = page.locator('.rich-text-lexical').nth(2) // second
await richTextField.scrollIntoViewIfNeeded()
await expect(richTextField).toBeVisible()
await expect(richTextField.locator('.lexical-block')).toHaveCount(10)
await wait(1000) // Wait for form state requests to be done, to reduce flakes
const conditionalArrayBlock = richTextField.locator('.lexical-block').nth(7)
@@ -862,6 +878,9 @@ describe('lexicalBlocks', () => {
const richTextField = page.locator('.rich-text-lexical').nth(2) // second
await richTextField.scrollIntoViewIfNeeded()
await expect(richTextField).toBeVisible()
// Wait until there at least 10 blocks visible in that richtext field - thus wait for it to be fully loaded
await expect(richTextField.locator('.lexical-block')).toHaveCount(10)
await wait(1000) // Wait for form state requests to be done, to reduce flakes
const conditionalArrayBlock = richTextField.locator('.lexical-block').nth(7)
@@ -885,6 +904,8 @@ describe('lexicalBlocks', () => {
const richTextField = page.locator('.rich-text-lexical').nth(2) // second
await richTextField.scrollIntoViewIfNeeded()
await expect(richTextField).toBeVisible()
// Wait until there at least 10 blocks visible in that richtext field - thus wait for it to be fully loaded
await expect(richTextField.locator('.lexical-block')).toHaveCount(10)
const uploadBlock = richTextField.locator('.ContentEditable__root > div').first() // Check for the first div, as we wanna make sure it's the first div in the editor (1. node is a paragraph, second node is a div which is the upload node)
await uploadBlock.scrollIntoViewIfNeeded()
@@ -898,9 +919,11 @@ describe('lexicalBlocks', () => {
test('should respect required error state in deeply nested text field', async () => {
await navigateToLexicalFields()
const richTextField = page.locator('.rich-text-lexical').nth(2) // second
await richTextField.scrollIntoViewIfNeeded()
await expect(richTextField).toBeVisible()
// Wait until there at least 10 blocks visible in that richtext field - thus wait for it to be fully loaded
await expect(richTextField.locator('.lexical-block')).toHaveCount(10)
await wait(300)
const conditionalArrayBlock = richTextField.locator('.lexical-block').nth(7)
@@ -950,6 +973,8 @@ describe('lexicalBlocks', () => {
await richTextField.scrollIntoViewIfNeeded()
await expect(richTextField).toBeVisible()
// Wait until there at least 10 blocks visible in that richtext field - thus wait for it to be fully loaded
await expect(richTextField.locator('.lexical-block')).toHaveCount(10)
const tabsBlock = richTextField.locator('.lexical-block').nth(8)
await wait(300)

View File

@@ -14,6 +14,7 @@ import {
ensureCompilationIsDone,
initPageConsoleErrorCatch,
saveDocAndAssert,
throttleTest,
} from '../../../../../helpers.js'
import { AdminUrlUtil } from '../../../../../helpers/adminUrlUtil.js'
import { initPayloadE2ENoConfig } from '../../../../../helpers/initPayloadE2ENoConfig.js'
@@ -69,22 +70,18 @@ describe('lexicalMain', () => {
page = await context.newPage()
initPageConsoleErrorCatch(page)
await reInitializeDB({
serverURL,
snapshotKey: 'fieldsLexicalMainTest',
uploadsDir: path.resolve(dirname, './collections/Upload/uploads'),
})
await ensureCompilationIsDone({ page, serverURL })
})
beforeEach(async () => {
/*await throttleTest({
page,
context,
delay: 'Slow 4G',
delay: 'Fast 4G',
})*/
await reInitializeDB({
serverURL,
snapshotKey: 'fieldsLexicalMainTest',
snapshotKey: 'fieldsTest',
uploadsDir: [
path.resolve(dirname, './collections/Upload/uploads'),
path.resolve(dirname, './collections/Upload2/uploads2'),
@@ -117,6 +114,10 @@ describe('lexicalMain', () => {
test('should not warn about unsaved changes when navigating to lexical editor with blocks node and then leaving the page after making a change and saving', async () => {
// Relevant issue: https://github.com/payloadcms/payload/issues/4115
await navigateToLexicalFields()
await expect(page.locator('.rich-text-lexical').nth(2).locator('.lexical-block')).toHaveCount(
10,
)
await expect(page.locator('.shimmer-effect')).toHaveCount(0)
const thirdBlock = page.locator('.rich-text-lexical').nth(2).locator('.lexical-block').nth(2)
await thirdBlock.scrollIntoViewIfNeeded()
await expect(thirdBlock).toBeVisible()
@@ -140,6 +141,10 @@ describe('lexicalMain', () => {
// Save
await saveDocAndAssert(page)
await expect(page.locator('.rich-text-lexical').nth(2).locator('.lexical-block')).toHaveCount(
10,
)
await expect(page.locator('.shimmer-effect')).toHaveCount(0)
await expect(newSpanInBlock).toHaveText('Some text below rmoretextelationship node 1')
// Navigate to some different page, away from the current document
@@ -154,6 +159,9 @@ describe('lexicalMain', () => {
const richTextField = page.locator('.rich-text-lexical').nth(2) // second
await richTextField.scrollIntoViewIfNeeded()
await expect(richTextField).toBeVisible()
// Wait until there at least 10 blocks visible in that richtext field - thus wait for it to be fully loaded
await expect(richTextField.locator('.lexical-block')).toHaveCount(10)
await expect(page.locator('.shimmer-effect')).toHaveCount(0)
const spanInEditor = richTextField.locator('span').getByText('Upload Node:').first()
await expect(spanInEditor).toBeVisible()
@@ -198,6 +206,9 @@ describe('lexicalMain', () => {
const richTextField = page.locator('.rich-text-lexical').nth(2) // second
await richTextField.scrollIntoViewIfNeeded()
await expect(richTextField).toBeVisible()
// Wait until there at least 10 blocks visible in that richtext field - thus wait for it to be fully loaded
await expect(richTextField.locator('.lexical-block')).toHaveCount(10)
await expect(page.locator('.shimmer-effect')).toHaveCount(0)
const spanInEditor = richTextField.locator('span').getByText('Upload Node:').first()
await expect(spanInEditor).toBeVisible()
@@ -303,6 +314,11 @@ describe('lexicalMain', () => {
const richTextField = page.locator('.rich-text-lexical').nth(1)
await richTextField.scrollIntoViewIfNeeded()
await expect(richTextField).toBeVisible()
// Wait until there at least 10 blocks visible in that richtext field - thus wait for it to be fully loaded
await expect(page.locator('.rich-text-lexical').nth(2).locator('.lexical-block')).toHaveCount(
10,
)
await expect(page.locator('.shimmer-effect')).toHaveCount(0)
// Find span in contentEditable with text "Some text below relationship node"
const contentEditable = richTextField.locator('.ContentEditable__root').first()
@@ -364,6 +380,9 @@ describe('lexicalMain', () => {
const richTextField = page.locator('.rich-text-lexical').nth(2) // second
await richTextField.scrollIntoViewIfNeeded()
await expect(richTextField).toBeVisible()
// Wait until there at least 10 blocks visible in that richtext field - thus wait for it to be fully loaded
await expect(richTextField.locator('.lexical-block')).toHaveCount(10)
await expect(page.locator('.shimmer-effect')).toHaveCount(0)
const lastParagraph = richTextField.locator('p').last()
await lastParagraph.scrollIntoViewIfNeeded()
@@ -391,6 +410,7 @@ describe('lexicalMain', () => {
const uploadListDrawer = page.locator('dialog[id^=list-drawer_1_]').first() // IDs starting with list-drawer_1_ (there's some other symbol after the underscore)
await expect(uploadListDrawer).toBeVisible()
await wait(500)
await expect(page.locator('.shimmer-effect')).toHaveCount(0)
await uploadListDrawer.locator('.rs__control .value-container').first().click()
await wait(500)
@@ -423,7 +443,10 @@ describe('lexicalMain', () => {
await expect(uploadListDrawer).toBeHidden()
await wait(500)
await saveDocAndAssert(page)
await expect(page.locator('.rich-text-lexical').nth(2).locator('.lexical-block')).toHaveCount(
10,
)
await expect(page.locator('.shimmer-effect')).toHaveCount(0)
// second one should be the newly created one
const secondUploadNode = richTextField.locator('.lexical-upload').nth(1)
await secondUploadNode.scrollIntoViewIfNeeded()
@@ -446,6 +469,7 @@ describe('lexicalMain', () => {
// Wait until there at least 10 blocks visible in that richtext field - thus wait for it to be fully loaded
await expect(richTextField.locator('.lexical-block')).toHaveCount(10)
await expect(page.locator('.shimmer-effect')).toHaveCount(0)
const lastParagraph = richTextField.locator('p').last()
await lastParagraph.scrollIntoViewIfNeeded()
@@ -502,9 +526,17 @@ describe('lexicalMain', () => {
await expect(uploadExtraFieldsDrawer).toBeHidden()
await wait(500)
await saveDocAndAssert(page)
await wait(500)
await expect(page.locator('.rich-text-lexical').nth(2).locator('.lexical-block')).toHaveCount(
10,
)
await expect(page.locator('.shimmer-effect')).toHaveCount(0)
// Reload page, open the extra fields drawer again and check if the text is still there
await page.reload()
await wait(300)
await expect(richTextField.locator('.lexical-block')).toHaveCount(10)
await expect(page.locator('.shimmer-effect')).toHaveCount(0)
const reloadedUploadNode = page
.locator('.rich-text-lexical')
.nth(2)
@@ -570,9 +602,15 @@ describe('lexicalMain', () => {
*/
test('ensure lexical editor within drawer within relationship within lexical field has fully-functioning inline toolbar', async () => {
await navigateToLexicalFields()
await wait(500)
const richTextField = page.locator('.rich-text-lexical').first()
await richTextField.scrollIntoViewIfNeeded()
await expect(richTextField).toBeVisible()
// Wait until there at least 10 blocks visible in that richtext field - thus wait for it to be fully loaded
await expect(page.locator('.rich-text-lexical').nth(2).locator('.lexical-block')).toHaveCount(
10,
)
await expect(page.locator('.shimmer-effect')).toHaveCount(0)
const paragraph = richTextField.locator('.LexicalEditorTheme__paragraph').first()
await paragraph.scrollIntoViewIfNeeded()
@@ -622,7 +660,6 @@ describe('lexicalMain', () => {
await wait(500)
const docRichTextField = docDrawer.locator('.rich-text-lexical').first()
await docRichTextField.scrollIntoViewIfNeeded()
await expect(docRichTextField).toBeVisible()
const docParagraph = docRichTextField.locator('.LexicalEditorTheme__paragraph').first()
@@ -690,6 +727,11 @@ describe('lexicalMain', () => {
const richTextField = page.locator('.rich-text-lexical').first()
await richTextField.scrollIntoViewIfNeeded()
await expect(richTextField).toBeVisible()
// Wait until there at least 10 blocks visible in that richtext field - thus wait for it to be fully loaded
await expect(page.locator('.rich-text-lexical').nth(2).locator('.lexical-block')).toHaveCount(
10,
)
await expect(page.locator('.shimmer-effect')).toHaveCount(0)
const paragraph = richTextField.locator('.LexicalEditorTheme__paragraph').first()
await paragraph.scrollIntoViewIfNeeded()
@@ -788,6 +830,11 @@ describe('lexicalMain', () => {
const richTextField = page.locator('.rich-text-lexical').first()
await richTextField.scrollIntoViewIfNeeded()
await expect(richTextField).toBeVisible()
// Wait until there at least 10 blocks visible in that richtext field - thus wait for it to be fully loaded
await expect(page.locator('.rich-text-lexical').nth(2).locator('.lexical-block')).toHaveCount(
10,
)
await expect(page.locator('.shimmer-effect')).toHaveCount(0)
const paragraph = richTextField.locator('.LexicalEditorTheme__paragraph').first()
await paragraph.scrollIntoViewIfNeeded()
@@ -811,13 +858,14 @@ describe('lexicalMain', () => {
const uploadSelectButton = slashMenuPopover.locator('button').first()
await expect(uploadSelectButton).toBeVisible()
await expect(uploadSelectButton).toContainText('Upload')
await wait(1000)
await uploadSelectButton.click()
await expect(slashMenuPopover).toBeHidden()
await wait(500) // wait for drawer form state to initialize (it's a flake)
const uploadListDrawer = page.locator('dialog[id^=list-drawer_1_]').first() // IDs starting with list-drawer_1_ (there's some other symbol after the underscore)
await expect(uploadListDrawer).toBeVisible()
await wait(500)
await wait(1000)
await uploadListDrawer.locator('button').getByText('payload.png').first().click()
await expect(uploadListDrawer).toBeHidden()
@@ -826,15 +874,23 @@ describe('lexicalMain', () => {
await newUploadNode.scrollIntoViewIfNeeded()
await expect(newUploadNode).toBeVisible()
await expect(slashMenuPopover).toBeHidden()
await expect(newUploadNode.locator('.lexical-upload__bottomRow')).toContainText('payload.png')
await page.keyboard.press('Enter') // floating toolbar needs to appear with enough distance to the upload node, otherwise clicking may fail
await page.keyboard.press('ArrowLeft')
await page.keyboard.press('ArrowLeft')
// Select "there" by pressing shift + arrow left
for (let i = 0; i < 4; i++) {
await page.keyboard.press('Shift+ArrowLeft')
}
await newUploadNode.locator('.lexical-upload__swap-drawer-toggler').first().click()
const swapDrawerButton = newUploadNode.locator('.lexical-upload__swap-drawer-toggler').first()
await expect(swapDrawerButton).toBeVisible()
await swapDrawerButton.click()
const uploadSwapDrawer = page.locator('dialog[id^=list-drawer_1_]').first()
await expect(uploadSwapDrawer).toBeVisible()
@@ -879,7 +935,9 @@ describe('lexicalMain', () => {
.children[0] as SerializedParagraphNode
const secondParagraph: SerializedParagraphNode = lexicalField.root
.children[1] as SerializedParagraphNode
const uploadNode: SerializedUploadNode = lexicalField.root.children[2] as SerializedUploadNode
const thirdParagraph: SerializedParagraphNode = lexicalField.root
.children[2] as SerializedParagraphNode
const uploadNode: SerializedUploadNode = lexicalField.root.children[3] as SerializedUploadNode
expect(firstParagraph.children).toHaveLength(2)
expect((firstParagraph.children[0] as SerializedTextNode).text).toBe('Some ')
@@ -888,6 +946,7 @@ describe('lexicalMain', () => {
expect((firstParagraph.children[1] as SerializedTextNode).format).toBe(1)
expect(secondParagraph.children).toHaveLength(0)
expect(thirdParagraph.children).toHaveLength(0)
expect(uploadNode.relationTo).toBe('uploads')
}).toPass({

View File

@@ -181,7 +181,12 @@ export const LexicalFields: CollectionConfig = {
const yourEditorState: SerializedEditorState = siblingData.lexicalWithBlocks
try {
headlessEditor.setEditorState(headlessEditor.parseEditorState(yourEditorState))
headlessEditor.update(
() => {
headlessEditor.setEditorState(headlessEditor.parseEditorState(yourEditorState))
},
{ discrete: true },
)
} catch (e) {
/* empty */
}

View File

@@ -46,18 +46,14 @@ describe('Number', () => {
const context = await browser.newContext()
page = await context.newPage()
initPageConsoleErrorCatch(page)
await reInitializeDB({
serverURL,
snapshotKey: 'fieldsNumberTest',
uploadsDir: path.resolve(dirname, './collections/Upload/uploads'),
})
await ensureCompilationIsDone({ page, serverURL })
})
beforeEach(async () => {
await reInitializeDB({
serverURL,
snapshotKey: 'fieldsNumberTest',
snapshotKey: 'fieldsTest',
uploadsDir: path.resolve(dirname, './collections/Upload/uploads'),
})
if (client) {

View File

@@ -46,17 +46,13 @@ describe('Point', () => {
const context = await browser.newContext()
page = await context.newPage()
initPageConsoleErrorCatch(page)
await reInitializeDB({
serverURL,
snapshotKey: 'fieldsPointTest',
uploadsDir: path.resolve(dirname, './collections/Upload/uploads'),
})
await ensureCompilationIsDone({ page, serverURL })
})
beforeEach(async () => {
await reInitializeDB({
serverURL,
snapshotKey: 'fieldsPointTest',
snapshotKey: 'fieldsTest',
uploadsDir: path.resolve(dirname, './collections/Upload/uploads'),
})

View File

@@ -48,17 +48,13 @@ describe('relationship', () => {
const context = await browser.newContext()
page = await context.newPage()
initPageConsoleErrorCatch(page)
await reInitializeDB({
serverURL,
snapshotKey: 'fieldsRelationshipTest',
uploadsDir: path.resolve(dirname, './collections/Upload/uploads'),
})
await ensureCompilationIsDone({ page, serverURL })
})
beforeEach(async () => {
await reInitializeDB({
serverURL,
snapshotKey: 'fieldsRelationshipTest',
snapshotKey: 'fieldsTest',
uploadsDir: path.resolve(dirname, './collections/Upload/uploads'),
})

View File

@@ -38,17 +38,13 @@ describe('Rich Text', () => {
const context = await browser.newContext()
page = await context.newPage()
initPageConsoleErrorCatch(page)
await reInitializeDB({
serverURL,
snapshotKey: 'fieldsRichTextTest',
uploadsDir: path.resolve(dirname, './collections/Upload/uploads'),
})
await ensureCompilationIsDone({ page, serverURL })
})
beforeEach(async () => {
await reInitializeDB({
serverURL,
snapshotKey: 'fieldsRichTextTest',
snapshotKey: 'fieldsTest',
uploadsDir: path.resolve(dirname, './collections/Upload/uploads'),
})
@@ -189,6 +185,7 @@ describe('Rich Text', () => {
test('should only list RTE enabled upload collections in drawer', async () => {
await navigateToRichTextFields()
await wait(1000)
// Open link drawer
await page
@@ -196,6 +193,9 @@ describe('Rich Text', () => {
.first()
.click()
const drawer = page.locator('[id^=list-drawer_1_]')
await expect(drawer).toBeVisible()
// open the list select menu
await page.locator('.list-drawer__select-collection-wrap .rs__control').click()
@@ -225,7 +225,7 @@ describe('Rich Text', () => {
)
// change the selected collection to `array-fields`
await page.locator('.list-drawer__select-collection-wrap .rs__control').click()
await page.locator('.list-drawer_select-collection-wrap .rs__control').click()
const menu = page.locator('.list-drawer__select-collection-wrap .rs__menu')
await menu.locator('.rs__option').getByText('Array Field').click()
@@ -318,6 +318,7 @@ describe('Rich Text', () => {
describe('editor', () => {
test('should populate url link', async () => {
await navigateToRichTextFields()
await wait(500)
// Open link popup
await page.locator('#field-richText span >> text="render links"').click()
@@ -334,6 +335,7 @@ describe('Rich Text', () => {
const textField = editLinkModal.locator('#field-text')
await expect(textField).toHaveValue('render links')
await wait(1000)
// Close the drawer
await editLinkModal.locator('button[type="submit"]').click()
await expect(editLinkModal).toBeHidden()
@@ -402,6 +404,7 @@ describe('Rich Text', () => {
test('should populate new links', async () => {
await navigateToRichTextFields()
await wait(1000)
// Highlight existing text
const headingElement = page.locator(
@@ -409,6 +412,8 @@ describe('Rich Text', () => {
)
await headingElement.selectText()
await wait(500)
// click the toolbar link button
await page.locator('.rich-text__toolbar button:not([disabled]) .link').first().click()
@@ -423,12 +428,15 @@ describe('Rich Text', () => {
await page.locator('#field-blocks').scrollIntoViewIfNeeded()
await expect(page.locator('#field-blocks__0__text')).toBeVisible()
await expect(page.locator('#field-blocks__0__text')).toHaveValue('Regular text')
await wait(500)
const editBlock = page.locator('#blocks-row-0 .popup-button')
await editBlock.click()
const removeButton = page.locator('#blocks-row-0').getByRole('button', { name: 'Remove' })
await expect(removeButton).toBeVisible()
await wait(500)
await removeButton.click()
const richTextField = page.locator('#field-blocks__0__text')
await expect(richTextField).toBeVisible()
const richTextValue = await richTextField.innerText()
expect(richTextValue).toContain('Rich text')
})

View File

@@ -48,17 +48,13 @@ describe('Tabs', () => {
const context = await browser.newContext()
page = await context.newPage()
initPageConsoleErrorCatch(page)
await reInitializeDB({
serverURL,
snapshotKey: 'fieldsTabsTest',
uploadsDir: path.resolve(dirname, './collections/Upload/uploads'),
})
await ensureCompilationIsDone({ page, serverURL })
})
beforeEach(async () => {
await reInitializeDB({
serverURL,
snapshotKey: 'fieldsTabsTest',
snapshotKey: 'fieldsTest',
uploadsDir: path.resolve(dirname, './collections/Upload/uploads'),
})

View File

@@ -0,0 +1,5 @@
import React from 'react'
export default function CustomDescription() {
return <div>Custom Description</div>
}

View File

@@ -48,17 +48,13 @@ describe('Text', () => {
const context = await browser.newContext()
page = await context.newPage()
initPageConsoleErrorCatch(page)
await reInitializeDB({
serverURL,
snapshotKey: 'fieldsTextTest',
uploadsDir: path.resolve(dirname, './collections/Upload/uploads'),
})
await ensureCompilationIsDone({ page, serverURL })
})
beforeEach(async () => {
await reInitializeDB({
serverURL,
snapshotKey: 'fieldsTextTest',
snapshotKey: 'fieldsTest',
uploadsDir: path.resolve(dirname, './collections/Upload/uploads'),
})

View File

@@ -1,9 +1,13 @@
'use client'
import { useFieldProps } from '@payloadcms/ui'
import type { TextFieldClientComponent } from 'payload'
import React from 'react'
export const UICustomClient: React.FC = () => {
const { custom, path } = useFieldProps()
return <div id={path}>{custom?.customValue}</div>
export const UICustomClient: TextFieldClientComponent = ({
field: {
name,
admin: { custom },
},
}) => {
return <div id={name}>{custom?.customValue}</div>
}

View File

@@ -47,17 +47,13 @@ describe('Upload', () => {
const context = await browser.newContext()
page = await context.newPage()
initPageConsoleErrorCatch(page)
await reInitializeDB({
serverURL,
snapshotKey: 'fieldsUploadTest',
uploadsDir: path.resolve(dirname, './collections/Upload/uploads'),
})
await ensureCompilationIsDone({ page, serverURL })
})
beforeEach(async () => {
await reInitializeDB({
serverURL,
snapshotKey: 'fieldsUploadTest',
snapshotKey: 'fieldsTest',
uploadsDir: path.resolve(dirname, './collections/Upload/uploads'),
})

View File

@@ -24,10 +24,10 @@ const Uploads: CollectionConfig = {
},
relationTo: uploadsSlug,
},
{
name: 'richText',
type: 'richText',
},
// {
// name: 'richText',
// type: 'richText',
// },
],
upload: {
staticDir: path.resolve(dirname, './uploads'),

View File

@@ -42,17 +42,13 @@ describe('Upload with restrictions', () => {
const context = await browser.newContext()
page = await context.newPage()
initPageConsoleErrorCatch(page)
await reInitializeDB({
serverURL,
snapshotKey: 'fieldsUploadRestrictedTest',
uploadsDir: path.resolve(dirname, './collections/Upload/uploads'),
})
await ensureCompilationIsDone({ page, serverURL })
})
beforeEach(async () => {
await reInitializeDB({
serverURL,
snapshotKey: 'fieldsUploadRestrictedTest',
snapshotKey: 'fieldsTest',
uploadsDir: path.resolve(dirname, './collections/Upload/uploads'),
})

View File

@@ -25,10 +25,10 @@ const Uploads3: CollectionConfig = {
name: 'media',
relationTo: uploads3Slug,
},
{
type: 'richText',
name: 'richText',
},
// {
// type: 'richText',
// name: 'richText',
// },
],
}

View File

@@ -1,4 +1,4 @@
import type { Locator, Page } from '@playwright/test'
import type { Page } from '@playwright/test'
import { expect, test } from '@playwright/test'
import path from 'path'
@@ -45,11 +45,7 @@ describe('fields', () => {
const context = await browser.newContext()
page = await context.newPage()
initPageConsoleErrorCatch(page)
await reInitializeDB({
serverURL,
snapshotKey: 'fieldsTest',
uploadsDir: path.resolve(dirname, './collections/Upload/uploads'),
})
await ensureCompilationIsDone({ page, serverURL })
})
beforeEach(async () => {
@@ -243,7 +239,7 @@ describe('fields', () => {
test('should render collapsible as collapsed if initCollapsed is true', async () => {
await page.goto(url.create)
const collapsedCollapsible = page.locator(
'#field-collapsible-1 .collapsible__toggle--collapsed',
'#field-collapsible-_index-1 .collapsible__toggle--collapsed',
)
await expect(collapsedCollapsible).toBeVisible()
})
@@ -251,10 +247,10 @@ describe('fields', () => {
test('should render CollapsibleLabel using a function', async () => {
const label = 'custom row label'
await page.goto(url.create)
await page.locator('#field-collapsible-3__1 #field-nestedTitle').fill(label)
await page.locator('#field-collapsible-_index-3-1 #field-nestedTitle').fill(label)
await wait(100)
const customCollapsibleLabel = page.locator(
`#field-collapsible-3__1 .collapsible-field__row-label-wrap :text("${label}")`,
`#field-collapsible-_index-3-1 .collapsible-field__row-label-wrap :text("${label}")`,
)
await expect(customCollapsibleLabel).toContainText(label)
})
@@ -271,7 +267,7 @@ describe('fields', () => {
await page
.locator(
'#arrayWithCollapsibles-row-0 #field-collapsible-4__0-arrayWithCollapsibles__0 #field-arrayWithCollapsibles__0__innerCollapsible',
'#arrayWithCollapsibles-row-0 #field-collapsible-arrayWithCollapsibles__0___index-0 #field-arrayWithCollapsibles__0__innerCollapsible',
)
.fill(label)
await wait(100)

View File

@@ -999,7 +999,9 @@ describe('Fields', () => {
})
it('should read', async () => {
if (payload.db.name === 'sqlite') {return}
if (payload.db.name === 'sqlite') {
return
}
const find = await payload.find({
collection: 'point-fields',
pagination: false,
@@ -1013,7 +1015,9 @@ describe('Fields', () => {
})
it('should create', async () => {
if (payload.db.name === 'sqlite') {return}
if (payload.db.name === 'sqlite') {
return
}
doc = await payload.create({
collection: 'point-fields',
data: {
@@ -1029,7 +1033,9 @@ describe('Fields', () => {
})
it('should not create duplicate point when unique', async () => {
if (payload.db.name === 'sqlite') {return}
if (payload.db.name === 'sqlite') {
return
}
// first create the point field
doc = await payload.create({
collection: 'point-fields',
@@ -1099,7 +1105,7 @@ describe('Fields', () => {
uniqueRelationship: textDoc.id,
},
})
// Skip mongodb uniuqe error because it threats localizedUniqueRequriedText.es as undefined
// Skip mongodb unique error because it threats localizedUniqueRequriedText.es as undefined
.then((doc) =>
payload.update({
locale: 'es',
@@ -1135,7 +1141,7 @@ describe('Fields', () => {
uniqueHasManyRelationship: [textDoc.id],
},
})
// Skip mongodb uniuqe error because it threats localizedUniqueRequriedText.es as undefined
// Skip mongodb unique error because it threats localizedUniqueRequriedText.es as undefined
.then((doc) =>
payload.update({
locale: 'es',
@@ -1156,7 +1162,7 @@ describe('Fields', () => {
uniqueHasManyRelationship_2: [textDoc.id],
},
})
// Skip mongodb uniuqe error because it threats localizedUniqueRequriedText.es as undefined
// Skip mongodb unique error because it threats localizedUniqueRequriedText.es as undefined
.then((doc) =>
payload.update({
locale: 'es',
@@ -1179,7 +1185,7 @@ describe('Fields', () => {
).rejects.toBeTruthy()
})
it('should throw validation error saving on unique relationship fields polymorphic', async () => {
it('should throw validation error saving on unique relationship fields polymorphic not hasMany', async () => {
const textDoc = await payload.create({ collection: 'text-fields', data: { text: 'asd' } })
await payload
@@ -1192,7 +1198,7 @@ describe('Fields', () => {
uniquePolymorphicRelationship: { relationTo: 'text-fields', value: textDoc.id },
},
})
// Skip mongodb uniuqe error because it threats localizedUniqueRequriedText.es as undefined
// Skip mongodb unique error because it threats localizedUniqueRequriedText.es as undefined
.then((doc) =>
payload.update({
locale: 'es',
@@ -1213,7 +1219,7 @@ describe('Fields', () => {
uniquePolymorphicRelationship_2: { relationTo: 'text-fields', value: textDoc.id },
},
})
// Skip mongodb uniuqe error because it threats localizedUniqueRequriedText.es as undefined
// Skip mongodb unique error because it threats localizedUniqueRequriedText.es as undefined
.then((doc) =>
payload.update({
locale: 'es',
@@ -1273,7 +1279,7 @@ describe('Fields', () => {
],
},
})
// Skip mongodb uniuqe error because it threats localizedUniqueRequriedText.es as undefined
// Skip mongodb unique error because it threats localizedUniqueRequriedText.es as undefined
.then((doc) =>
payload.update({
locale: 'es',

File diff suppressed because it is too large Load Diff

View File

@@ -63,21 +63,6 @@ const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export const seed = async (_payload: Payload) => {
if (_payload.db.name === 'mongoose') {
await Promise.all(
_payload.config.collections.map(async (coll) => {
await new Promise((resolve, reject) => {
_payload.db?.collections[coll.slug]?.ensureIndexes(function (err) {
if (err) {
reject(err)
}
resolve(true)
})
})
}),
)
}
const jpgPath = path.resolve(dirname, './collections/Upload/payload.jpg')
const pngPath = path.resolve(dirname, './uploads/payload.png')

View File

@@ -35,31 +35,31 @@ export const uiSlug = 'ui-fields'
export const collectionSlugs = [
usersSlug,
arrayFieldsSlug,
blockFieldsSlug,
checkboxFieldsSlug,
codeFieldsSlug,
collapsibleFieldsSlug,
conditionalLogicSlug,
dateFieldsSlug,
groupFieldsSlug,
indexedFieldsSlug,
jsonFieldsSlug,
lexicalFieldsSlug,
lexicalMigrateFieldsSlug,
lexicalRelationshipFieldsSlug,
numberFieldsSlug,
pointFieldsSlug,
radioFieldsSlug,
relationshipFieldsSlug,
richTextFieldsSlug,
rowFieldsSlug,
selectFieldsSlug,
tabsFieldsSlug,
tabsFields2Slug,
textFieldsSlug,
uploadsSlug,
uploads2Slug,
uploads3Slug,
uiSlug,
// arrayFieldsSlug,
// blockFieldsSlug,
// checkboxFieldsSlug,
// codeFieldsSlug,
// collapsibleFieldsSlug,
// conditionalLogicSlug,
// dateFieldsSlug,
// groupFieldsSlug,
// indexedFieldsSlug,
// jsonFieldsSlug,
// lexicalFieldsSlug,
// lexicalMigrateFieldsSlug,
// lexicalRelationshipFieldsSlug,
// numberFieldsSlug,
// pointFieldsSlug,
// radioFieldsSlug,
// relationshipFieldsSlug,
// richTextFieldsSlug,
// rowFieldsSlug,
// selectFieldsSlug,
// tabsFieldsSlug,
// tabsFields2Slug,
// textFieldsSlug,
// uploadsSlug,
// uploads2Slug,
// uploads3Slug,
// uiSlug,
]