feat!: beta-next (#7620)

This PR makes three major changes to the codebase:

1. [Component Paths](#component-paths)
Instead of importing custom components into your config directly, they
are now defined as file paths and rendered only when needed. That way
the Payload config will be significantly more lightweight, and ensures
that the Payload config is 100% server-only and Node-safe. Related
discussion: https://github.com/payloadcms/payload/discussions/6938

2. [Client Config](#client-config)
Deprecates the component map by merging its logic into the client
config. The main goal of this change is for performance and
simplification. There was no need to deeply iterate over the Payload
config twice, once for the component map, and another for the client
config. Instead, we can do everything in the client config one time.
This has also dramatically simplified the client side prop drilling
through the UI library. Now, all components can share the same client
config which matches the exact shape of their Payload config (with the
exception of non-serializable props and mapped custom components).

3. [Custom client component are no longer
server-rendered](#custom-client-components-are-no-longer-server-rendered)
Previously, custom components would be server-rendered, no matter if
they are server or client components. Now, only server components are
rendered on the server. Client components are automatically detected,
and simply get passed through as `MappedComponent` to be rendered fully
client-side.

## Component Paths

Instead of importing custom components into your config directly, they
are now defined as file paths and rendered only when needed. That way
the Payload config will be significantly more lightweight, and ensures
that the Payload config is 100% server-only and Node-safe. Related
discussion: https://github.com/payloadcms/payload/discussions/6938

In order to reference any custom components in the Payload config, you
now have to specify a string path to the component instead of importing
it.

Old:

```ts
import { MyComponent2} from './MyComponent2.js'

admin: {
  components: {
    Label: MyComponent2
  },
},
```

New:

```ts
admin: {
  components: {
    Label: '/collections/Posts/MyComponent2.js#MyComponent2', // <= has to be a relative path based on a baseDir configured in the Payload config - NOT relative based on the importing file
  },
},
```

### Local API within Next.js routes

Previously, if you used the Payload Local API within Next.js pages, all
the client-side modules are being added to the bundle for that specific
page, even if you only need server-side functionality.

This `/test` route, which uses the Payload local API, was previously 460
kb. It is now down to 91 kb and does not bundle the Payload client-side
admin panel anymore.

All tests done
[here](https://github.com/payloadcms/payload-3.0-demo/tree/feat/path-test)
with beta.67/PR, db-mongodb and default richtext-lexical:

**dev /admin before:**
![CleanShot 2024-07-29 at 22 49
12@2x](https://github.com/user-attachments/assets/4428e766-b368-4bcf-8c18-d0187ab64f3e)

**dev /admin after:**
![CleanShot 2024-07-29 at 22 50
49@2x](https://github.com/user-attachments/assets/f494c848-7247-4b02-a650-a3fab4000de6)

---

**dev /test before:**
![CleanShot 2024-07-29 at 22 56
18@2x](https://github.com/user-attachments/assets/1a7e9500-b859-4761-bf63-abbcdac6f8d6)

**dev /test after:**
![CleanShot 2024-07-29 at 22 47
45@2x](https://github.com/user-attachments/assets/f89aa76d-f2d5-4572-9753-2267f034a45a)

---

**build before:**
![CleanShot 2024-07-29 at 22 57
14@2x](https://github.com/user-attachments/assets/5f8f7281-2a4a-40a5-a788-c30ddcdd51b5)

**build after::**
![CleanShot 2024-07-29 at 22 56
39@2x](https://github.com/user-attachments/assets/ea8772fd-512f-4db0-9a81-4b014715a1b7)

### Usage of the Payload Local API / config outside of Next.js

This will make it a lot easier to use the Payload config / local API in
other, server-side contexts. Previously, you might encounter errors due
to client files (like .scss files) not being allowed to be imported.

## Client Config

Deprecates the component map by merging its logic into the client
config. The main goal of this change is for performance and
simplification. There was no need to deeply iterate over the Payload
config twice, once for the component map, and another for the client
config. Instead, we can do everything in the client config one time.
This has also dramatically simplified the client side prop drilling
through the UI library. Now, all components can share the same client
config which matches the exact shape of their Payload config (with the
exception of non-serializable props and mapped custom components).

This is breaking change. The `useComponentMap` hook no longer exists,
and most component props have changed (for the better):

```ts
const { componentMap } = useComponentMap() // old
const { config } = useConfig() // new
```

The `useConfig` hook has also changed in shape, `config` is now a
property _within_ the context obj:

```ts
const config = useConfig() // old
const { config } = useConfig() // new
```

## Custom Client Components are no longer server rendered

Previously, custom components would be server-rendered, no matter if
they are server or client components. Now, only server components are
rendered on the server. Client components are automatically detected,
and simply get passed through as `MappedComponent` to be rendered fully
client-side.

The benefit of this change:

Custom client components can now receive props. Previously, the only way
for them to receive dynamic props from a parent client component was to
use hooks, e.g. `useFieldProps()`. Now, we do have the option of passing
in props to the custom components directly, if they are client
components. This will be simpler than having to look for the correct
hook.

This makes rendering them on the client a little bit more complex, as
you now have to check if that component is a server component (=>
already has been rendered) or a client component (=> not rendered yet,
has to be rendered here). However, this added complexity has been
alleviated through the easy-to-use `<RenderMappedComponent />` helper.

This helper now also handles rendering arrays of custom components (e.g.
beforeList, beforeLogin ...), which actually makes rendering custom
components easier in some cases.

## Misc improvements

This PR includes misc, breaking changes. For example, we previously
allowed unions between components and config object for the same
property. E.g. for the custom view property, you were allowed to pass in
a custom component or an object with other properties, alongside a
custom component.

Those union types are now gone. You can now either pass an object, or a
component. The previous `{ View: MyViewComponent}` is now `{ View: {
Component: MyViewComponent} }` or `{ View: { Default: { Component:
MyViewComponent} } }`.

This dramatically simplifies the way we read & process those properties,
especially in buildComponentMap. We can now simply check for the
existence of one specific property, which always has to be a component,
instead of running cursed runtime checks on a shared union property
which could contain a component, but could also contain functions or
objects.

![CleanShot 2024-07-29 at 23 07
07@2x](https://github.com/user-attachments/assets/1e75aa4c-7a4c-419f-9070-216bb7b9a5e5)

![CleanShot 2024-07-29 at 23 09
40@2x](https://github.com/user-attachments/assets/b4c96450-6b7e-496c-a4f7-59126bfd0991)

- [x] I have read and understand the
[CONTRIBUTING.md](https://github.com/payloadcms/payload/blob/main/CONTRIBUTING.md)
document in this repository.

---------

Co-authored-by: PatrikKozak <patrik@payloadcms.com>
Co-authored-by: Paul <paul@payloadcms.com>
Co-authored-by: Paul Popus <paul@nouance.io>
Co-authored-by: Jacob Fletcher <jacobsfletch@gmail.com>
Co-authored-by: James <james@trbl.design>
This commit is contained in:
Alessio Gravili
2024-08-13 12:54:33 -04:00
committed by GitHub
parent 9cb84c48b9
commit 90b7b20699
874 changed files with 12208 additions and 9403 deletions

6
.gitignore vendored
View File

@@ -5,6 +5,7 @@ dist
!/.idea/runConfigurations
!/.idea/payload.iml
test-results
.devcontainer
.localstack
@@ -300,3 +301,8 @@ $RECYCLE.BIN/
/build
.swc
app/(payload)/admin/importMap.js
test/live-preview/app/(payload)/admin/importMap.js
/test/live-preview/app/(payload)/admin/importMap.js
test/admin-root/app/(payload)/admin/importMap.js
/test/admin-root/app/(payload)/admin/importMap.js

View File

@@ -1 +0,0 @@
pnpm run lint-staged --quiet

1
.idea/payload.iml generated
View File

@@ -26,6 +26,7 @@
<excludeFolder url="file://$MODULE_DIR$/packages/live-preview/dist" />
<excludeFolder url="file://$MODULE_DIR$/packages/next/.swc" />
<excludeFolder url="file://$MODULE_DIR$/packages/next/.turbo" />
<excludeFolder url="file://$MODULE_DIR$/packages/next/dist" />
<excludeFolder url="file://$MODULE_DIR$/packages/payload/fields" />
<excludeFolder url="file://$MODULE_DIR$/packages/plugin-cloud-storage/.turbo" />
<excludeFolder url="file://$MODULE_DIR$/packages/plugin-cloud-storage/dist" />

View File

@@ -1,8 +1,13 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Run Dev Fields" type="NodeJSConfigurationType" application-parameters="--no-deprecation fields" path-to-js-file="test/dev.js" working-dir="$PROJECT_DIR$">
<envs>
<env name="NODE_OPTIONS" value="--no-deprecation" />
</envs>
<configuration default="false" name="Run Dev Fields" type="js.build_tools.npm">
<package-json value="$PROJECT_DIR$/package.json" />
<command value="run" />
<scripts>
<script value="dev" />
</scripts>
<arguments value="fields" />
<node-interpreter value="project" />
<envs />
<method v="2" />
</configuration>
</component>

View File

@@ -1,8 +1,13 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Run Dev _community" type="NodeJSConfigurationType" application-parameters="--no-deprecation _community" path-to-js-file="test/dev.js" working-dir="$PROJECT_DIR$">
<envs>
<env name="NODE_OPTIONS" value="--no-deprecation" />
</envs>
<configuration default="false" name="Run Dev _community" type="js.build_tools.npm">
<package-json value="$PROJECT_DIR$/package.json" />
<command value="run" />
<scripts>
<script value="dev" />
</scripts>
<arguments value="_community" />
<node-interpreter value="project" />
<envs />
<method v="2" />
</configuration>
</component>

View File

@@ -0,0 +1,13 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Run Dev admin" type="js.build_tools.npm">
<package-json value="$PROJECT_DIR$/package.json" />
<command value="run" />
<scripts>
<script value="dev" />
</scripts>
<arguments value="admin" />
<node-interpreter value="project" />
<envs />
<method v="2" />
</configuration>
</component>

View File

@@ -1,7 +1,7 @@
<component name="ProjectRunConfigurationManager">
<configuration default="true" type="JavaScriptTestRunnerJest">
<node-interpreter value="project" />
<node-options value="--experimental-vm-modules --no-deprecation" />
<node-options value="--no-deprecation" />
<envs />
<scope-kind value="ALL" />
<method v="2" />

View File

@@ -42,8 +42,8 @@
}
},
"files.insertFinalNewline": true,
"jestrunner.jestCommand": "pnpm exec cross-env NODE_OPTIONS=\"--experimental-vm-modules --no-deprecation\" node 'node_modules/jest/bin/jest.js'",
"jestrunner.jestCommand": "pnpm exec cross-env NODE_OPTIONS=\"--no-deprecation\" node 'node_modules/jest/bin/jest.js'",
"jestrunner.debugOptions": {
"runtimeArgs": ["--experimental-vm-modules", "--no-deprecation"]
"runtimeArgs": ["--no-deprecation"]
}
}

14
app/(app)/layout.tsx Normal file
View File

@@ -0,0 +1,14 @@
import React from 'react'
export const metadata = {
description: 'Generated by Next.js',
title: 'Next.js',
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}

11
app/(app)/test/page.tsx Normal file
View File

@@ -0,0 +1,11 @@
import configPromise from '@payload-config'
import { getPayloadHMR } from '@payloadcms/next/utilities'
export const Page = async ({ params, searchParams }) => {
const payload = await getPayloadHMR({
config: configPromise,
})
return <div>test ${payload?.config?.collections?.length}</div>
}
export default Page

View File

@@ -5,6 +5,8 @@ import config from '@payload-config'
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import { NotFoundPage, generatePageMetadata } from '@payloadcms/next/views'
import { importMap } from '../importMap.js'
type Args = {
params: {
segments: string[]
@@ -17,6 +19,7 @@ type Args = {
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
generatePageMetadata({ config, params, searchParams })
const NotFound = ({ params, searchParams }: Args) => NotFoundPage({ config, params, searchParams })
const NotFound = ({ params, searchParams }: Args) =>
NotFoundPage({ config, importMap, params, searchParams })
export default NotFound

View File

@@ -5,6 +5,8 @@ import config from '@payload-config'
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import { RootPage, generatePageMetadata } from '@payloadcms/next/views'
import { importMap } from '../importMap.js'
type Args = {
params: {
segments: string[]
@@ -17,6 +19,7 @@ type Args = {
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
generatePageMetadata({ config, params, searchParams })
const Page = ({ params, searchParams }: Args) => RootPage({ config, params, searchParams })
const Page = ({ params, searchParams }: Args) =>
RootPage({ config, importMap, params, searchParams })
export default Page

View File

@@ -1 +0,0 @@
export const importMap = {}

View File

@@ -1,6 +1,9 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
import configPromise from '@payload-config'
import { RootLayout } from '@payloadcms/next/layouts'
import { importMap } from './admin/importMap.js'
// import '@payloadcms/ui/styles.css' // Uncomment this line if `@payloadcms/ui` in `tsconfig.json` points to `/ui/dist` instead of `/ui/src`
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import React from 'react'
@@ -11,6 +14,10 @@ type Args = {
children: React.ReactNode
}
const Layout = ({ children }: Args) => <RootLayout config={configPromise}>{children}</RootLayout>
const Layout = ({ children }: Args) => (
<RootLayout config={configPromise} importMap={importMap}>
{children}
</RootLayout>
)
export default Layout

View File

@@ -51,7 +51,6 @@ To override Collection Components, use the `admin.components` property in your [
```ts
import type { SanitizedCollectionConfig } from 'payload'
import { CustomSaveButton } from './CustomSaveButton'
export const MyCollection: SanitizedCollectionConfig = {
// ...

View File

@@ -33,8 +33,6 @@ To override Root Components, use the `admin.components` property in your [Payloa
```ts
import { buildConfig } from 'payload'
import { MyCustomLogo } from './MyCustomLogo'
export default buildConfig({
// ...
admin: {
@@ -81,13 +79,11 @@ To add a Custom Provider, use the `admin.components.providers` property in your
```ts
import { buildConfig } from 'payload'
import { MyProvider } from './MyProvider'
export default buildConfig({
// ...
admin: {
components: {
providers: [MyProvider], // highlight-line
providers: ['/path/to/MyProvider'], // highlight-line
},
},
})
@@ -207,7 +203,7 @@ import React from 'react'
import { useConfig } from '@payloadcms/ui'
export const MyClientComponent: React.FC = () => {
const { serverURL } = useConfig() // highlight-line
const { config: { serverURL } } = useConfig() // highlight-line
return (
<Link href={serverURL}>
@@ -221,6 +217,22 @@ export const MyClientComponent: React.FC = () => {
See [Using Hooks](#using-hooks) for more details.
</Banner>
All [Field Components](./fields) automatically receive their respective Client Field Config through a common [`field`](./fields#the-field-prop) prop:
```tsx
'use client'
import React from 'react'
import type { TextFieldProps } from 'payload'
export const MyClientFieldComponent: TextFieldProps = ({ field: { name } }) => {
return (
<p>
{`This field's name is ${name}`}
</p>
)
}
```
### Using Hooks
To make it easier to [build your Custom Components](#building-custom-components), you can use [Payload's built-in React Hooks](./hooks) in any Client Component. For example, you might want to interact with one of Payload's many React Contexts:

View File

@@ -117,7 +117,7 @@ export const CollectionConfig: CollectionConfig = {
// ...
admin: {
components: {
Field: MyFieldComponent, // highlight-line
Field: '/path/to/MyFieldComponent', // highlight-line
},
},
}
@@ -135,32 +135,12 @@ All Field Components receive the following props:
| Property | Description |
| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`AfterInput`** | The rendered result of the `admin.components.afterInput` property. [More details](#afterinput-and-beforeinput). |
| **`BeforeInput`** | The rendered result of the `admin.components.beforeInput` property. [More details](#afterinput-and-beforeinput). |
| **`CustomDescription`** | The rendered result of the `admin.components.Description` property. [More details](#the-description-component). |
| **`CustomError`** | The rendered result of the `admin.components.Error` property. [More details](#the-error-component). |
| **`CustomLabel`** | The rendered result of the `admin.components.Label` property. [More details](#the-label-component).
| **`path`** | The static path of the field at render time. [More details](./hooks#usefieldprops). |
| **`disabled`** | The `admin.disabled` property defined in the [Field Config](../fields/overview). |
| **`required`** | The `admin.required` property defined in the [Field Config](../fields/overview). |
| **`className`** | The `admin.className` property defined in the [Field Config](../fields/overview). |
| **`style`** | The `admin.style` property defined in the [Field Config](../fields/overview). |
| **`custom`** | The `admin.custom` property defined in the [Field Config](../fields/overview).
| **`placeholder`** | The `admin.placeholder` property defined in the [Field Config](../fields/overview). |
| **`descriptionProps`** | An object that contains the props for the `FieldDescription` component. |
| **`labelProps`** | An object that contains the props needed for the `FieldLabel` component. |
| **`errorProps`** | An object that contains the props for the `FieldError` component. |
| **`docPreferences`** | An object that contains the preferences for the document. |
| **`label`** | The label value provided in the field, it can be used with i18n. |
| **`docPreferences`** | An object that contains the [Preferences](./preferences) for the document.
| **`field`** | The sanitized, client-friendly version of the field's config. [More details](#the-field-prop) |
| **`locale`** | The locale of the field. [More details](../configuration/localization). |
| **`localized`** | A boolean value that represents if the field is localized or not. [More details](../fields/localized). |
| **`readOnly`** | A boolean value that represents if the field is read-only or not. |
| **`rtl`** | A boolean value that represents if the field should be rendered right-to-left or not. [More details](../configuration/i18n). |
| **`user`** | The currently authenticated user. [More details](../authentication/overview). |
| **`validate`** | A function that can be used to validate the field. |
| **`hasMany`** | If a [`relationship`](../fields/relationship) field, the `hasMany` property defined in the [Field Config](../fields/overview). |
| **`maxLength`** | If a [`text`](../fields/text) field, the `maxLength` property defined in the [Field Config](../fields/overview). |
| **`minLength`** | If a [`text`](../fields/text) field, the `minLength` property defined in the [Field Config](../fields/overview). |
<Banner type="success">
<strong>Reminder:</strong>
@@ -193,6 +173,99 @@ export const CustomTextField: React.FC = () => {
For a complete list of all available React hooks, see the [Payload React Hooks](./hooks) documentation. For additional help, see [Building Custom Components](./components#building-custom-components).
</Banner>
#### TypeScript
When building Custom Field Components, you can import the component props to ensure type safety in your component. There is an explicit type for the Field Component, one for every [Field Type](../fields/overview). The convention is to append `Props` to the type of field, i.e. `TextFieldProps`.
```tsx
import type {
ArrayFieldProps,
BlocksFieldProps,
CheckboxFieldProps,
CodeFieldProps,
CollapsibleFieldProps,
DateFieldProps,
EmailFieldProps,
GroupFieldProps,
HiddenFieldProps,
JSONFieldProps,
NumberFieldProps,
PointFieldProps,
RadioFieldProps,
RelationshipFieldProps,
RichTextFieldProps,
RowFieldProps,
SelectFieldProps,
TabsFieldProps,
TextFieldProps,
TextareaFieldProps,
UploadFieldProps
} from 'payload'
```
### The `field` Prop
All Field Components are passed a client-friendly version of their Field Config through a common `field` prop. Since the raw Field Config is [non-serializable](https://react.dev/reference/rsc/use-client#serializable-types), Payload sanitized it into a [Client Config](./components#accessing-the-payload-config) that is safe to pass into Client Components.
The exact shape of this prop is unique to the specific [Field Type](../fields/overview) being rendered, minus all non-serializable properties. Any [Custom Components](../components) are also resolved into a "mapped component" that is safe to pass.
```tsx
'use client'
import React from 'react'
import type { TextFieldProps } from 'payload'
export const MyClientFieldComponent: TextFieldProps = ({ field: { name } }) => {
return (
<p>
{`This field's name is ${name}`}
</p>
)
}
```
The following additional properties are also provided to the `field` prop:
| Property | Description |
| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`_isPresentational`** | A boolean indicating that the field is purely visual and does not directly affect data or change data shape, i.e. the [UI Field](../fields/ui). |
| **`_path`** | A string representing the direct, dynamic path to the field at runtime, i.e. `myGroup.myArray[0].myField`. |
| **`_schemaPath`** | A string representing the direct, static path to the [Field Config](../fields/overview), i.e. `myGroup.myArray.myField` |
<Banner type="info">
<strong>Note:</strong>
These properties are underscored to denote that they are not part of the original Field Config, and instead are attached during client sanitization to make fields easier to work with on the front-end.
</Banner>
#### TypeScript
When building Custom Field Components, you can import the client field props to ensure type safety in your component. There is an explicit type for the Field Component, one for every [Field Type](../fields/overview). The convention is to append `Client` to the type of field, i.e. `TextFieldClient`.
```tsx
import type {
ArrayFieldClient,
BlocksFieldClient,
CheckboxFieldClient,
CodeFieldClient,
CollapsibleFieldClient,
DateFieldClient,
EmailFieldClient,
GroupFieldClient,
HiddenFieldClient,
JSONFieldClient,
NumberFieldClient,
PointFieldClient,
RadioFieldClient,
RelationshipFieldClient,
RichTextFieldClient,
RowFieldClient,
SelectFieldClient,
TabsFieldClient,
TextFieldClient,
TextareaFieldClient,
UploadFieldClient
} from 'payload'
```
### The Cell Component
The Cell Component is rendered in the table of the List View. It represents the value of the field when displayed in a table cell.
@@ -207,7 +280,7 @@ export const myField: Field = {
type: 'text',
admin: {
components: {
Cell: MyCustomCell, // highlight-line
Cell: '/path/to/MyCustomCellComponent', // highlight-line
},
},
}
@@ -219,20 +292,9 @@ All Cell Components receive the following props:
| Property | Description |
| ---------------- | ----------------------------------------------------------------- |
| **`name`** | The name of the field. |
| **`className`** | The `admin.className` property defined in the [Field Config](../fields/overview). |
| **`fieldType`** | The `type` property defined in the [Field Config](../fields/overview). |
| **`schemaPath`** | The path to the field in the schema. Similar to `path`, but without dynamic indices. |
| **`isFieldAffectingData`** | A boolean value that represents if the field is affecting the data or not. |
| **`label`** | The label value provided in the field, it can be used with i18n. |
| **`labels`** | An object that contains the labels for the field. |
| **`field`** | The sanitized, client-friendly version of the field's config. [More details](#the-field-prop) |
| **`link`** | A boolean representing whether this cell should be wrapped in a link. |
| **`onClick`** | A function that is called when the cell is clicked. |
| **`dateDisplayFormat`** | If a [`date`](../fields/date) field, the `admin.dateDisplayFormat` property defined in the [Field Config](../fields/overview). |
| **`options`** | If a [`select`](../fields/select) field, this is an array of options defined in the [Field Config](../fields/overview). [More details](../fields/select). |
| **`relationTo`** | If a [`relationship`](../fields/relationship). or [`upload`](../fields/upload) field, this is the collection(s) the field is related to. |
| **`richTextComponentMap`** | If a [`richText`](../fields/rich-text) field, this is an object that maps the rich text components. [More details](../fields/rich-text). |
| **`blocks`** | If a [`blocks`](../fields/blocks) field, this is an array of labels and slugs representing the blocks defined in the [Field Config](../fields/overview). [More details](../fields/blocks). |
<Banner type="info">
<strong>Tip:</strong>
@@ -258,7 +320,7 @@ export const myField: Field = {
type: 'text',
admin: {
components: {
Label: MyCustomLabel, // highlight-line
Label: '/path/to/MyCustomLabelComponent', // highlight-line
},
},
}
@@ -270,7 +332,7 @@ Custom Label Components receive all [Field Component](#the-field-component) prop
| Property | Description |
| -------------- | ---------------------------------------------------------------- |
| **`schemaPath`** | The path to the field in the schema. Similar to `path`, but without dynamic indices. |
| **`field`** | The sanitized, client-friendly version of the field's config. [More details](#the-field-prop) |
<Banner type="success">
<strong>Reminder:</strong>
@@ -279,7 +341,7 @@ Custom Label Components receive all [Field Component](#the-field-component) prop
#### TypeScript
When building Custom Error Components, you can import the component props to ensure type safety in your component. There is an explicit type for the Error Component, one for every [Field Type](../fields/overview). The convention is to append `ErrorComponent` to the type of field, i.e. `TextFieldErrorComponent`.
When building Custom Label Components, you can import the component props to ensure type safety in your component. There is an explicit type for the Label Component, one for every [Field Type](../fields/overview). The convention is to append `LabelComponent` to the type of field, i.e. `TextFieldLabelComponent`.
```tsx
import type {
@@ -321,7 +383,7 @@ export const myField: Field = {
type: 'text',
admin: {
components: {
Error: MyCustomError, // highlight-line
Error: '/path/to/MyCustomErrorComponent', // highlight-line
},
},
}
@@ -333,7 +395,7 @@ Custom Error Components receive all [Field Component](#the-field-component) prop
| Property | Description |
| --------------- | ------------------------------------------------------------- |
| **`path`*** | The static path of the field at render time. [More details](./hooks#usefieldprops). |
| **`field`** | The sanitized, client-friendly version of the field's config. [More details](#the-field-prop) |
<Banner type="success">
<strong>Reminder:</strong>
@@ -443,7 +505,6 @@ To easily add a Description Component to a field, use the `admin.components.Desc
```ts
import type { SanitizedCollectionConfig } from 'payload'
import { MyCustomDescription } from './MyCustomDescription'
export const MyCollectionConfig: SanitizedCollectionConfig = {
// ...
@@ -454,7 +515,7 @@ export const MyCollectionConfig: SanitizedCollectionConfig = {
type: 'text',
admin: {
components: {
Description: MyCustomDescription, // highlight-line
Description: '/path/to/MyCustomDescriptionComponent', // highlight-line
}
}
}
@@ -468,7 +529,7 @@ Custom Description Components receive all [Field Component](#the-field-component
| Property | Description |
| -------------- | ---------------------------------------------------------------- |
| **`description`** | The `description` property defined in the [Field Config](../fields/overview). |
| **`field`** | The sanitized, client-friendly version of the field's config. [More details](#the-field-prop) |
<Banner type="success">
<strong>Reminder:</strong>
@@ -524,8 +585,8 @@ export const MyCollectionConfig: SanitizedCollectionConfig = {
admin: {
components: {
// highlight-start
beforeInput: [MyCustomComponent],
afterInput: [MyOtherCustomComponent],
beforeInput: ['/path/to/MyCustomComponent'],
afterInput: ['/path/to/MyOtherCustomComponent'],
// highlight-end
}
}

View File

@@ -43,7 +43,6 @@ To override Global Components, use the `admin.components` property in your [Glob
```ts
import type { SanitizedGlobalConfig } from 'payload'
import { CustomSaveButton } from './CustomSaveButton'
export const MyGlobal: SanitizedGlobalConfig = {
// ...

View File

@@ -52,7 +52,7 @@ The `useField` hook accepts the following arguments:
The `useField` hook returns the following object:
```ts
type FieldResult<T> = {
type FieldType<T> = {
errorMessage?: string
errorPaths?: string[]
filterOptions?: FilterOptionsResult
@@ -65,7 +65,7 @@ type FieldResult<T> = {
readOnly?: boolean
rows?: Row[]
schemaPath: string
setValue: (val: unknown, disableModifyingForm?: boolean) => voi
setValue: (val: unknown, disableModifyingForm?: boolean) => void
showError: boolean
valid?: boolean
value: T
@@ -463,7 +463,7 @@ export const CustomArrayManager = () => {
name: "customArrayManager",
admin: {
components: {
Field: CustomArrayManager,
Field: '/path/to/CustomArrayManagerField',
},
},
},
@@ -560,7 +560,7 @@ export const CustomArrayManager = () => {
name: "customArrayManager",
admin: {
components: {
Field: CustomArrayManager,
Field: '/path/to/CustomArrayManagerField',
},
},
},
@@ -670,7 +670,7 @@ export const CustomArrayManager = () => {
name: "customArrayManager",
admin: {
components: {
Field: CustomArrayManager,
Field: '/path/to/CustomArrayManagerField',
},
},
},
@@ -818,7 +818,7 @@ import { useConfig } from '@payloadcms/ui'
const MyComponent: React.FC = () => {
// highlight-start
const config = useConfig()
const { config } = useConfig()
// highlight-end
return <span>{config.serverURL}</span>

View File

@@ -69,6 +69,128 @@ All auto-generated files will contain the following comments at the top of each
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
```
## Defining Custom Components in the Payload Config
In the Payload Config, you can define custom React Components to enhance the admin interface. However, these components should not be imported directly into the server-only Payload Config to avoid including client-side code. Instead, you specify the path to the component. Heres how you can do it:
src/components/Logout.tsx
```tsx
'use client'
import React from 'react'
export const MyComponent = () => {
return (
<button>Click me!</button>
)
}
```
payload.config.ts:
```ts
import { buildConfig } from 'payload'
const config = buildConfig({
// ...
admin: { // highlight-line
components: {
logout: {
Button: '/src/components/Logout#MyComponent'
}
}
},
})
```
In the path `/src/components/Logout#MyComponent`, `/src/components/Logout` is the file path, and `MyComponent` is the named export. If the component is the default export, the export name can be omitted. Path and export name are separated by a `#`.
### Configuring the Base Directory
Component paths, by default, are relative to your working directory - this is usually where your Next.js config lies. To simplify component paths, you have the option to configure the *base directory* using the `admin.baseDir.baseDir` property:
```ts
import { buildConfig } from 'payload'
import { fileURLToPath } from 'node:url'
import path from 'path'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
const config = buildConfig({
// ...
admin: { // highlight-line
importMap: {
baseDir: path.resolve(dirname, 'src'),
},
components: {
logout: {
Button: '/components/Logout#MyComponent'
}
}
},
})
```
In this example, we set the base directory to the `src` directory - thus we can omit the `/src/` part of our component path string.
### Passing Props
Each React Component in the Payload Config is typed as `PayloadComponent`. This usually is a string, but can also be an object containing the following properties:
| Property | Description |
|---------------|-------------------------------------------------------------------------------------------------------------------------------|
| `clientProps` | Props to be passed to the React Component if it's a Client Component |
| `exportName` | Instead of declaring named exports using `#` in the component path, you can also omit them from `path` and pass them in here. |
| `path` | Path to the React Component. Named exports can be appended to the end of the path, separated by a `#` |
| `serverProps` | Props to be passed to the React Component if it's a Server Component |
To pass in props from the config, you can use the `clientProps` and/or `serverProps` properties. This alleviates the need to use an HOC (Higher-Order-Component) to declare a React Component with props passed in.
Here is an example:
src/components/Logout.tsx
```tsx
'use client'
import React from 'react'
export const MyComponent = ({ text }: { text: string }) => {
return (
<button>Click me! {text}</button>
)
}
```
payload.config.ts:
```ts
import { buildConfig } from 'payload'
const config = buildConfig({
// ...
admin: { // highlight-line
components: {
logout: {
Button: {
path: '/src/components/Logout',
clientProps: {
text: 'Some Text.'
},
exportName: 'MyComponent'
}
}
}
},
})
```
### Import Maps
It's essential to understand how `PayloadComponent` paths function behind the scenes. Directly importing React Components into your Payload Config using import statements can introduce client-only modules like CSS into your server-only config. This could error when attempting to load the Payload Config in server-only environments and unnecessarily increase the size of the Payload Config, which should remain streamlined and efficient for server use.
Instead, we utilize component paths to reference React Components. This method enhances the Payload Config with actual React Component imports on the client side, without affecting server-side usage. A script is deployed to scan the Payload Config, collecting all component paths and creating an `importMap.js`. This file, located in app/(payload)/admin/importMap.js, must be statically imported by your Next.js root page and layout. The script imports all the React Components from the specified paths into a Map, associating them with their respective paths (the ones you defined).
When constructing the `ClientConfig`, Payload uses the component paths as keys to fetch the corresponding React Component imports from the Import Map. It then substitutes the `PayloadComponent` with a `MappedComponent`. A `MappedComponent` includes the React Component and additional metadata, such as whether it's a server or a client component and which props it should receive. These components are then rendered through the `<RenderComponent />` component within the Payload Admin Panel.
Import maps are regenerated whenever you modify any element related to component paths. This regeneration occurs at startup and whenever Hot Module Replacement (HMR) runs. If the import maps fail to regenerate during HMR, you can restart your application and execute the `payload generate:importmap` command to manually create a new import map.
## Admin Options
All options for the Admin Panel are defined in your [Payload Config](../configuration/overview) under the `admin` property:
@@ -167,12 +289,12 @@ const config = buildConfig({
The following options are available:
| Option | Default route | Description |
| ------------------ | ----------------------- | ------------------------------------- |
| `admin` | `/admin` | The Admin Panel itself. |
| `api` | `/api` | The [REST API](../rest-api/overview) base path. |
| `graphQL` | `/graphql` | The [GraphQL API](../graphql/overview) base path. |
| `graphQLPlayground`| `/graphql-playground` | The GraphQL Playground. |
| Option | Default route | Description |
|---------------------|-----------------------|---------------------------------------------------|
| `admin` | `/admin` | The Admin Panel itself. |
| `api` | `/api` | The [REST API](../rest-api/overview) base path. |
| `graphQL` | `/graphql` | The [GraphQL API](../graphql/overview) base path. |
| `graphQLPlayground` | `/graphql-playground` | The GraphQL Playground. |
<Banner type="success">
<strong>Tip:</strong>

View File

@@ -31,7 +31,9 @@ const config = buildConfig({
admin: {
components: {
views: {
Dashboard: MyCustomDashboardView, // highlight-line
dashboard: {
Component: '/path/to/MyCustomDashboardView#MyCustomDashboardViewComponent', // highlight-line
}
},
},
},
@@ -44,8 +46,8 @@ The following options are available:
| Property | Description |
| --------------- | ----------------------------------------------------------------------------- |
| **`Account`** | The Account view is used to show the currently logged in user's Account page. |
| **`Dashboard`** | The main landing page of the [Admin Panel](./overview). |
| **`account`** | The Account view is used to show the currently logged in user's Account page. |
| **`dashboard`** | The main landing page of the [Admin Panel](./overview). |
For more granular control, pass a configuration object instead. Payload exposes the following properties for each view:
@@ -72,9 +74,9 @@ const config = buildConfig({
components: {
views: {
// highlight-start
MyCustomView: {
myCustomView: {
// highlight-end
Component: MyCustomView,
Component: '/path/to/MyCustomView#MyCustomViewComponent',
path: '/my-custom-view',
},
},
@@ -108,7 +110,9 @@ export const MyCollectionConfig: SanitizedCollectionConfig = {
admin: {
components: {
views: {
Edit: MyCustomEditView, // highlight-line
edit: {
Component: '/path/to/MyCustomEditView', // highlight-line
}
},
},
},
@@ -126,8 +130,8 @@ The following options are available:
| Property | Description |
| ---------- | ----------------------------------------------------------------------------------------------------------------- |
| **`Edit`** | The Edit View is used to edit a single document for any given Collection. [More details](#document-views). |
| **`List`** | The List View is used to show a list of documents for any given Collection. |
| **`edit`** | The Edit View is used to edit a single document for any given Collection. [More details](#document-views). |
| **`list`** | The List View is used to show a list of documents for any given Collection. |
<Banner type="success">
<strong>Note:</strong>
@@ -148,7 +152,7 @@ export const MyGlobalConfig: SanitizedGlobalConfig = {
admin: {
components: {
views: {
Edit: MyCustomEditView, // highlight-line
edit: '/path/to/MyCustomEditView', // highlight-line
},
},
},
@@ -166,7 +170,7 @@ The following options are available:
| Property | Description |
| ---------- | ------------------------------------------------------------------- |
| **`Edit`** | The Edit View is used to edit a single document for any given Global. [More details](#document-views). |
| **`edit`** | The Edit View is used to edit a single document for any given Global. [More details](#document-views). |
<Banner type="success">
<strong>Note:</strong>
@@ -187,9 +191,9 @@ export const MyCollectionOrGlobalConfig: SanitizedCollectionConfig = {
admin: {
components: {
views: {
Edit: {
API: {
Component: MyCustomAPIView, // highlight-line
edit: {
api: {
Component: '/path/to/MyCustomAPIViewComponent', // highlight-line
},
},
},
@@ -209,15 +213,15 @@ The following options are available:
| Property | Description |
| ----------------- | --------------------------------------------------------------------------------------------------------------------------- |
| **`Default`** | The Default view is the primary view in which your document is edited. |
| **`Versions`** | The Versions view is used to view the version history of a single document. [More details](../versions). |
| **`Version`** | The Version view is used to view a single version of a single document for a given collection. [More details](../versions). |
| **`API`** | The API view is used to display the REST API JSON response for a given document. |
| **`LivePreview`** | The LivePreview view is used to display the Live Preview interface. [More details](../live-preview). |
| **`default`** | The Default view is the primary view in which your document is edited. |
| **`versions`** | The Versions view is used to view the version history of a single document. [More details](../versions). |
| **`version`** | The Version view is used to view a single version of a single document for a given collection. [More details](../versions). |
| **`api`** | The API view is used to display the REST API JSON response for a given document. |
| **`livePreview`** | The LivePreview view is used to display the Live Preview interface. [More details](../live-preview). |
### Document Tabs
Each Document View can be given a new tab in the Edit View, if desired. Tabs are highly configurable, from as simple as changing the label to swapping out the entire component, they can be modified in any way. To add or customize tabs in the Edit View, use the `Component.Tab` key:
Each Document View can be given a new tab in the Edit View, if desired. Tabs are highly configurable, from as simple as changing the label to swapping out the entire component, they can be modified in any way. To add or customize tabs in the Edit View, use the `tab` key:
```ts
import type { SanitizedCollectionConfig } from 'payload'
@@ -227,17 +231,19 @@ export const MyCollection: SanitizedCollectionConfig = {
admin: {
components: {
views: {
Edit: {
MyCustomTab: {
Component: MyCustomTab,
edit: {
myCustomTab: {
Component: '/path/to/MyCustomTab',
path: '/my-custom-tab',
Tab: MyCustomTab // highlight-line
tab: {
Component: '/path/to/MyCustomTabComponent' // highlight-line
}
},
AnotherCustomView: {
Component: AnotherCustomView,
anotherCustomTab: {
Component: '/path/to/AnotherCustomView',
path: '/another-custom-view',
// highlight-start
Tab: {
tab: {
label: 'Another Custom View',
href: '/another-custom-view',
}
@@ -261,14 +267,15 @@ Custom Views are just [Custom Components](./components) rendered at the page-lev
```ts
import type { SanitizedCollectionConfig } from 'payload'
import { MyCustomView } from './MyCustomView'
export const MyCollectionConfig: SanitizedCollectionConfig = {
// ...
admin: {
components: {
views: {
Edit: MyCustomView, // highlight-line
edit: {
Component: '/path/to/MyCustomView' // highlight-line
}
},
},
},

View File

@@ -102,5 +102,5 @@ You can import types from Payload to help make writing your Collection configs e
The `CollectionConfig` type represents a raw Collection Config in its full form, where only the bare minimum properties are marked as required. The `SanitizedCollectionConfig` type represents a Collection Config after it has been fully sanitized. Generally, this is only used internally by Payload.
```ts
import { CollectionConfig, SanitizedCollectionConfig } from 'payload'
import type { CollectionConfig, SanitizedCollectionConfig } from 'payload'
```

View File

@@ -106,5 +106,5 @@ You can import types from Payload to help make writing your Global configs easie
The `GlobalConfig` type represents a raw Global Config in its full form, where only the bare minimum properties are marked as required. The `SanitizedGlobalConfig` type represents a Global Config after it has been fully sanitized. Generally, this is only used internally by Payload.
```ts
import { GlobalConfig, SanitizedGlobalConfig } from 'payload'
import type { GlobalConfig, SanitizedGlobalConfig } from 'payload'
```

View File

@@ -246,5 +246,11 @@ You can import types from Payload to help make writing your config easier and ty
The `Config` type represents a raw Payload Config in its full form. Only the bare minimum properties are marked as required. The `SanitizedConfig` type represents a Payload Config after it has been fully sanitized. Generally, this is only used internally by Payload.
```ts
import { Config, SanitizedConfig } from 'payload'
import type { Config, SanitizedConfig } from 'payload'
```
## Server vs. Client
The Payload Config only lives on the server and is not allowed to contain any client-side code. That way, you can load up the Payload Config in any server environment or standalone script, without having to use Bundlers or Node.js loaders to handle importing client-only modules (e.g. scss files or React Components) without any errors.
Behind the curtains, the Next.js-based Admin Panel generates a ClientConfig, which strips away any server-only code and enriches the config with React Components.

View File

@@ -2,11 +2,11 @@
title: Using Payload outside Next.js
label: Outside Next.js
order: 20
desc: Payload can be used outside of Next.js within standalone scripts or in other frameworks like Remix, Sveltekit, Nuxt, and similar.
desc: Payload can be used outside of Next.js within standalone scripts or in other frameworks like Remix, SvelteKit, Nuxt, and similar.
keywords: local api, config, configuration, documentation, Content Management System, cms, headless, javascript, node, react, express
---
Payload can be used completely outside of Next.js which is helpful in cases like running scripts, using Payload in a separate backend service, or using Payload's Local API to fetch your data directly from your database in other frontend frameworks like Sveltekit, Remix, Nuxt, and similar.
Payload can be used completely outside of Next.js which is helpful in cases like running scripts, using Payload in a separate backend service, or using Payload's Local API to fetch your data directly from your database in other frontend frameworks like SvelteKit, Remix, Nuxt, and similar.
<Banner>
<strong>Note:</strong>
@@ -16,38 +16,16 @@ Payload can be used completely outside of Next.js which is helpful in cases like
## Importing the Payload Config outside of Next.js
Your Payload Config likely has imports which need to be handled properly, such as CSS imports and similar. If you were to try and import your config without any Node support for SCSS / CSS files, you'll see errors that arise accordingly.
Payload provides a convenient way to run standalone scripts, which can be useful for tasks like seeding your database or performing one-off operations.
This is especially relevant if you are importing your Payload Config outside of a bundler context, such as in standalone Node scripts.
For these cases, you can use Payload's `importConfig` function to handle importing your config safely. It will handle everything you need to be able to load and use your Payload Config, without any client-side files present.
Here's an example of a seed script that creates a few documents for local development / testing purposes, using Payload's `importConfig` function to safely import Payload, and the `getPayload` function to retrieve an initialized copy of Payload.
In standalone scripts, can simply import the Payload Config and use it right away. If you need an initialized copy of Payload, you can then use the `getPayload` function. This can be useful for tasks like seeding your database or performing other one-off operations.
```ts
// We are importing `getPayload` because we don't need HMR
// for a standalone script. For usage of Payload inside Next.js,
// you should always use `import { getPayloadHMR } from '@payloadcms/next/utilities'` instead.
import { getPayload } from 'payload'
// This is a helper function that will make sure we can safely load the Payload Config
// and all of its client-side files, such as CSS, SCSS, etc.
import { importConfig } from 'payload/node'
import path from 'path'
import { fileURLToPath } from 'node:url'
import dotenv from 'dotenv'
// In ESM, you can create the "dirname" variable
// like this. We'll use this with `dotenv` to load our `.env` file, if necessary.
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
// If you don't need to load your .env file,
// then you can skip this part!
dotenv.config({
path: path.resolve(dirname, '../.env'),
})
import config from '@payload-config'
const seed = async () => {
// Get a local copy of Payload by passing your config
@@ -71,6 +49,26 @@ const seed = async () => {
}
// Call the function here to run your seed script
seed()
await seed()
```
You can then execute the script using `payload run`. Example: if you placed this standalone script in `src/seed.ts`, you would execute it like this:
```sh
payload run src/seed.ts
```
The `payload run` command does two things for you:
1. It loads the environment variables the same way Next.js loads them, eliminating the need for additional dependencies like `dotenv`. The usage of `dotenv` is not recommended, as Next.js loads environment variables differently. By using `payload run`, you ensure consistent environment variable handling across your Payload and Next.js setup.
2. It initializes swc, allowing direct execution of TypeScript files without requiring tools like tsx or ts-node.
### Troubleshooting
If you encounter import-related errors, try running the script in TSX mode:
```sh
payload run src/seed.ts --use-tsx
```
Note: Install tsx in your project first. Be aware that TSX mode is slower than the default swc mode, so only use it if necessary.

View File

@@ -159,7 +159,6 @@ import {
useAllFormFields,
useAuth,
useClientFunctions,
useComponentMap,
useConfig,
useDebounce,
useDebouncedCallback,
@@ -212,7 +211,6 @@ import {
ActionsProvider,
AuthProvider,
ClientFunctionProvider,
ComponentMapProvider,
ConfigProvider,
DocumentEventsProvider,
DocumentInfoProvider,
@@ -299,14 +297,10 @@ import {
fieldBaseClass,
// TS Types
ActionMap,
CollectionComponentMap,
ColumnPreferences,
ConfigComponentMapBase,
DocumentInfoContext,
DocumentInfoProps,
FieldType,
FieldComponentProps,
FormProps,
RowLabelProps,
SelectFieldProps,
@@ -323,7 +317,6 @@ import {
AppHeader,
BlocksDrawer,
Column,
ComponentMap,
DefaultBlockImage,
DeleteMany,
DocumentControls,
@@ -338,7 +331,6 @@ import {
FormLoadingOverlayToggle,
FormSubmit,
GenerateConfirmation,
GlobalComponentMap,
HydrateClientUser,
ListControls,
ListSelection,
@@ -349,7 +341,8 @@ import {
PublishMany,
ReactSelect,
ReactSelectOption,
ReducedBlock,
ClientField,
ClientBlock,
RenderFields,
SectionTitle,
Select,

View File

@@ -28,6 +28,7 @@ export const rootParserOptions = {
EXPERIMENTAL_useSourceOfProjectReferenceRedirect: true,
EXPERIMENTAL_useProjectService: {
allowDefaultProjectForFiles: ['./src/*.ts', './src/*.tsx'],
maximumDefaultProjectFileMatchCount_THIS_WILL_SLOW_DOWN_LINTING: 100,
},
sourceType: 'module',
ecmaVersion: 'latest',

View File

@@ -134,7 +134,7 @@ describe('Users', () => {
```json
"scripts": {
"test": "NODE_OPTIONS=--experimental-vm-modules jest --forceExit --detectOpenHandles"
"test": "jest --forceExit --detectOpenHandles"
}
```

View File

@@ -29,6 +29,6 @@
"build:server": "tsc",
"build": "yarn build:server && yarn build:payload",
"serve": "PAYLOAD_CONFIG_PATH=dist/payload.config.js NODE_ENV=production node dist/server.js",
"test": "NODE_OPTIONS=--experimental-vm-modules jest --forceExit --detectOpenHandles"
"test": "jest --forceExit --detectOpenHandles"
}
}

View File

@@ -1,8 +1,28 @@
const esModules = [
// file-type and all dependencies: https://github.com/sindresorhus/file-type
'file-type',
'strtok3',
'readable-web-to-node-stream',
'token-types',
'peek-readable',
'find-up',
'locate-path',
'p-locate',
'p-limit',
'yocto-queue',
'unicorn-magic',
'path-exists',
'qs-esm',
].join('|')
/** @type {import('jest').Config} */
const baseJestConfig = {
extensionsToTreatAsEsm: ['.ts', '.tsx'],
setupFiles: ['<rootDir>/test/jest.setup.env.js'],
setupFilesAfterEnv: ['<rootDir>/test/jest.setup.js'],
transformIgnorePatterns: [
`/node_modules/(?!.pnpm)(?!(${esModules})/)`,
`/node_modules/.pnpm/(?!(${esModules.replace(/\//g, '\\+')})@)`,
],
moduleNameMapper: {
'\\.(css|scss)$': '<rootDir>/test/helpers/mocks/emptyModule.js',
'\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$':

View File

@@ -1,6 +1,11 @@
import bundleAnalyzer from '@next/bundle-analyzer'
import withPayload from './packages/next/src/withPayload.js'
import path from 'path'
import { fileURLToPath } from 'url'
const __filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(__filename)
const withBundleAnalyzer = bundleAnalyzer({
enabled: process.env.ANALYZE === 'true',
@@ -15,6 +20,10 @@ export default withBundleAnalyzer(
typescript: {
ignoreBuildErrors: true,
},
env: {
PAYLOAD_CORE_DEV: 'true',
ROOT_DIR: path.resolve(dirname),
},
async redirects() {
return [
{

View File

@@ -20,6 +20,7 @@
"build:email-nodemailer": "turbo build --filter email-nodemailer",
"build:email-resend": "turbo build --filter email-resend",
"build:eslint-config": "turbo build --filter eslint-config",
"build:essentials:force": "pnpm clean:build && turbo build --filter=\"payload...\" --filter=\"@payloadcms/ui\" --filter=\"@payloadcms/next\" --filter=\"@payloadcms/db-mongodb\" --filter=\"@payloadcms/db-postgres\" --filter=\"@payloadcms/richtext-lexical\" --filter=\"@payloadcms/translations\" --filter=\"@payloadcms/plugin-cloud\" --filter=\"@payloadcms/graphql\" --no-cache --force",
"build:force": "pnpm run build:core:force",
"build:graphql": "turbo build --filter graphql",
"build:live-preview": "turbo build --filter live-preview",
@@ -52,10 +53,12 @@
"clean:all": "node ./scripts/delete-recursively.js '@node_modules' 'media/*' '**/dist/' '**/.cache/*' '**/.next/*' '**/.turbo/*' '**/tsconfig.tsbuildinfo' '**/payload*.tgz' '**/meta_*.json'",
"clean:build": "node ./scripts/delete-recursively.js 'media/' '**/dist/' '**/.cache/' '**/.next/' '**/.turbo/' '**/tsconfig.tsbuildinfo' '**/payload*.tgz' '**/meta_*.json'",
"clean:cache": "node ./scripts/delete-recursively.js node_modules/.cache! packages/payload/node_modules/.cache! .next/*",
"dev": "cross-env NODE_OPTIONS=--no-deprecation node ./test/dev.js",
"dev:generate-graphql-schema": "cross-env NODE_OPTIONS=--no-deprecation tsx ./test/generateGraphQLSchema.ts",
"dev:generate-types": "cross-env NODE_OPTIONS=--no-deprecation tsx ./test/generateTypes.ts",
"dev:postgres": "cross-env NODE_OPTIONS=--no-deprecation PAYLOAD_DATABASE=postgres node ./test/dev.js",
"dev": "pnpm runts ./test/dev.ts",
"runts": "node --no-deprecation --import @swc-node/register/esm-register",
"dev:generate-graphql-schema": "pnpm runts ./test/generateGraphQLSchema.ts",
"dev:generate-importmap": "pnpm runts ./test/generateImportMap.ts",
"dev:generate-types": "pnpm runts ./test/generateTypes.ts",
"dev:postgres": "pnpm runts ./test/dev.ts",
"devsafe": "node ./scripts/delete-recursively.js '**/.next' && pnpm dev",
"docker:restart": "pnpm docker:stop --remove-orphans && pnpm docker:start",
"docker:start": "docker compose -f packages/plugin-cloud-storage/docker-compose.yml up -d",
@@ -67,20 +70,20 @@
"obliterate-playwright-cache-macos": "rm -rf ~/Library/Caches/ms-playwright && find /System/Volumes/Data/private/var/folders -type d -name 'playwright*' -exec rm -rf {} +",
"prepare": "husky",
"reinstall": "pnpm clean:all && pnpm install",
"release:alpha": "tsx ./scripts/release.ts --bump prerelease --tag alpha",
"release:beta": "tsx ./scripts/release.ts --bump prerelease --tag beta",
"script:gen-templates": "tsx ./scripts/generate-template-variations.ts",
"script:list-published": "tsx scripts/lib/getPackageRegistryVersions.ts",
"script:pack": "tsx scripts/pack-all-to-dest.ts",
"release:alpha": "pnpm runts ./scripts/release.ts --bump prerelease --tag alpha",
"release:beta": "pnpm runts ./scripts/release.ts --bump prerelease --tag beta",
"script:gen-templates": "pnpm runts ./scripts/generate-template-variations.ts",
"script:list-published": "pnpm runts scripts/lib/getPackageRegistryVersions.ts",
"script:pack": "pnpm runts scripts/pack-all-to-dest.ts",
"pretest": "pnpm build",
"test": "pnpm test:int && pnpm test:components && pnpm test:e2e",
"test:components": "cross-env NODE_OPTIONS=\"--experimental-vm-modules --no-deprecation\" jest --config=jest.components.config.js",
"test:e2e": "cross-env NODE_OPTIONS=--no-deprecation NODE_NO_WARNINGS=1 tsx ./test/runE2E.ts",
"test:components": "cross-env NODE_OPTIONS=\" --no-deprecation\" jest --config=jest.components.config.js",
"test:e2e": "pnpm runts ./test/runE2E.ts",
"test:e2e:debug": "cross-env NODE_OPTIONS=--no-deprecation NODE_NO_WARNINGS=1 PWDEBUG=1 DISABLE_LOGGING=true playwright test",
"test:e2e:headed": "cross-env NODE_OPTIONS=--no-deprecation NODE_NO_WARNINGS=1 DISABLE_LOGGING=true playwright test --headed",
"test:int": "cross-env NODE_OPTIONS=\"--experimental-vm-modules --no-deprecation\" NODE_NO_WARNINGS=1 DISABLE_LOGGING=true jest --forceExit --detectOpenHandles --config=test/jest.config.js --runInBand",
"test:int:postgres": "cross-env NODE_OPTIONS=\"--experimental-vm-modules --no-deprecation\" NODE_NO_WARNINGS=1 PAYLOAD_DATABASE=postgres DISABLE_LOGGING=true jest --forceExit --detectOpenHandles --config=test/jest.config.js --runInBand",
"test:unit": "cross-env NODE_OPTIONS=\"--experimental-vm-modules --no-deprecation\" NODE_NO_WARNINGS=1 DISABLE_LOGGING=true jest --forceExit --detectOpenHandles --config=jest.config.js --runInBand",
"test:int": "cross-env NODE_OPTIONS=\"--no-deprecation\" NODE_NO_WARNINGS=1 DISABLE_LOGGING=true jest --forceExit --detectOpenHandles --config=test/jest.config.js --runInBand",
"test:int:postgres": "cross-env NODE_OPTIONS=\"--no-deprecation\" NODE_NO_WARNINGS=1 PAYLOAD_DATABASE=postgres DISABLE_LOGGING=true jest --forceExit --detectOpenHandles --config=test/jest.config.js --runInBand",
"test:unit": "cross-env NODE_OPTIONS=\"--no-deprecation\" NODE_NO_WARNINGS=1 DISABLE_LOGGING=true jest --forceExit --detectOpenHandles --config=jest.config.js --runInBand",
"translateNewKeys": "pnpm --filter payload run translateNewKeys"
},
"lint-staged": {
@@ -100,8 +103,9 @@
"@payloadcms/eslint-config": "workspace:*",
"@payloadcms/eslint-plugin": "workspace:*",
"@payloadcms/live-preview-react": "workspace:*",
"@playwright/test": "1.43.0",
"@swc/cli": "0.3.12",
"@playwright/test": "1.46.0",
"@swc-node/register": "1.10.9",
"@swc/cli": "0.4.0",
"@swc/jest": "0.2.36",
"@types/fs-extra": "^11.0.2",
"@types/jest": "29.5.12",
@@ -134,8 +138,8 @@
"next": "15.0.0-canary.104",
"open": "^10.1.0",
"p-limit": "^5.0.0",
"playwright": "1.43.0",
"playwright-core": "1.43.0",
"playwright": "1.46.0",
"playwright-core": "1.46.0",
"prettier": "3.3.2",
"prompts": "2.4.2",
"react": "^19.0.0-rc-06d0b89e-20240801",
@@ -146,9 +150,8 @@
"shelljs": "0.8.5",
"slash": "3.0.0",
"sort-package-json": "^2.10.0",
"swc-plugin-transform-remove-imports": "1.14.0",
"swc-plugin-transform-remove-imports": "1.15.0",
"tempy": "1.0.1",
"tsx": "4.16.2",
"turbo": "^1.13.3",
"typescript": "5.5.4"
},
@@ -177,9 +180,6 @@
"react": "$react",
"react-dom": "$react-dom",
"typescript": "$typescript"
},
"patchedDependencies": {
"playwright@1.43.0": "patches/playwright@1.43.0.patch"
}
},
"overrides": {

View File

@@ -22,7 +22,7 @@ const customJestConfig = {
},
testEnvironment: 'node',
testMatch: ['<rootDir>/**/*spec.ts'],
testTimeout: 90000,
testTimeout: 160000,
transform: {
'^.+\\.(t|j)sx?$': ['@swc/jest'],
},

View File

@@ -42,7 +42,7 @@
"build": "pnpm pack-template-files && pnpm typecheck && pnpm build:swc",
"build:swc": "swc ./src -d ./dist --config-file .swcrc --strip-leading-paths",
"clean": "rimraf {dist,*.tsbuildinfo}",
"pack-template-files": "tsx src/scripts/pack-template-files.ts",
"pack-template-files": "node --no-deprecation --import @swc-node/register/esm-register src/scripts/pack-template-files.ts",
"prepublishOnly": "pnpm clean && pnpm build",
"test": "jest",
"typecheck": "tsc"
@@ -50,7 +50,7 @@
"dependencies": {
"@clack/prompts": "^0.7.0",
"@sindresorhus/slugify": "^1.1.0",
"@swc/core": "^1.6.13",
"@swc/core": "1.7.10",
"arg": "^5.0.0",
"chalk": "^4.1.0",
"comment-json": "^4.2.3",

View File

@@ -1,6 +1,6 @@
import type { ExportDefaultExpression, ModuleItem } from '@swc/core'
import swc from '@swc/core'
import { parse } from '@swc/core'
import chalk from 'chalk'
import { Syntax, parseModule } from 'esprima-next'
import fs from 'fs'
@@ -281,10 +281,10 @@ async function compileTypeScriptFileToAST(
* https://github.com/swc-project/swc/issues/1366
*/
if (process.env.NODE_ENV === 'test') {
parseOffset = (await swc.parse('')).span.end
parseOffset = (await parse('')).span.end
}
const module = await swc.parse(fileContent, {
const module = await parse(fileContent, {
syntax: 'typescript',
})

View File

@@ -67,31 +67,6 @@ export type FieldGenerator<TSchema, TField> = {
schema: TSchema
}
/**
* Field config types that need representation in the database
*/
type FieldType =
| 'array'
| 'blocks'
| 'checkbox'
| 'code'
| 'collapsible'
| 'date'
| 'email'
| 'group'
| 'json'
| 'number'
| 'point'
| 'radio'
| 'relationship'
| 'richText'
| 'row'
| 'select'
| 'tabs'
| 'text'
| 'textarea'
| 'upload'
export type FieldGeneratorFunction<TSchema, TField extends Field> = (
args: FieldGenerator<TSchema, TField>,
) => void

View File

@@ -42,7 +42,7 @@
"clean": "rimraf {dist,*.tsbuildinfo}",
"prepack": "pnpm clean && pnpm turbo build",
"prepublishOnly": "pnpm clean && pnpm turbo build",
"renamePredefinedMigrations": "tsx ./scripts/renamePredefinedMigrations.ts"
"renamePredefinedMigrations": "node --no-deprecation --import @swc-node/register/esm-register ./scripts/renamePredefinedMigrations.ts"
},
"dependencies": {
"@payloadcms/drizzle": "workspace:*",

View File

@@ -1,5 +1,21 @@
#!/usr/bin/env node
import { bin } from './dist/bin/index.js'
import { register } from 'node:module'
import path from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
bin()
// Allow disabling SWC for debugging
if (process.env.DISABLE_SWC !== 'true') {
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
const url = pathToFileURL(dirname).toString() + '/'
register('@swc-node/register/esm', url)
}
const start = async () => {
const { bin } = await import('./dist/bin/index.js')
await bin()
}
void start()

View File

@@ -43,6 +43,7 @@
"dependencies": {
"graphql-scalars": "1.22.2",
"pluralize": "8.0.0",
"@swc-node/register": "1.10.9",
"ts-essentials": "7.0.3"
},
"devDependencies": {

View File

@@ -1,13 +1,13 @@
/* eslint-disable no-console */
import minimist from 'minimist'
import { findConfig, importConfig, loadEnv } from 'payload/node'
import { findConfig, loadEnv } from 'payload/node'
import { generateSchema } from './generateSchema.js'
export const bin = async () => {
loadEnv()
const configPath = findConfig()
const config = await importConfig(configPath)
const config = await (await import(configPath)).default
const args = minimist(process.argv.slice(2))
const script = (typeof args._[0] === 'string' ? args._[0] : '').toLowerCase()

View File

@@ -90,7 +90,7 @@
"esbuild": "0.23.0",
"esbuild-sass-plugin": "3.3.1",
"payload": "workspace:*",
"swc-plugin-transform-remove-imports": "1.14.0"
"swc-plugin-transform-remove-imports": "1.15.0"
},
"peerDependencies": {
"graphql": "^16.8.1",

View File

@@ -1,5 +1,6 @@
import type { DocumentTabConfig, DocumentTabProps } from 'payload'
import { RenderComponent, getCreateMappedComponent } from '@payloadcms/ui/shared'
import React, { Fragment } from 'react'
import { DocumentTabLink } from './TabLink.js'
@@ -7,22 +8,25 @@ import './index.scss'
export const baseClass = 'doc-tab'
export const DocumentTab: React.FC<DocumentTabConfig & DocumentTabProps> = (props) => {
export const DocumentTab: React.FC<
{ readonly Pill_Component?: React.FC } & DocumentTabConfig & DocumentTabProps
> = (props) => {
const {
Pill,
Pill_Component,
apiURL,
collectionConfig,
condition,
config,
globalConfig,
href: tabHref,
i18n,
isActive: tabIsActive,
label,
newTab,
payload,
permissions,
} = props
const { config } = payload
const { routes } = config
let href = typeof tabHref === 'string' ? tabHref : ''
@@ -55,6 +59,17 @@ export const DocumentTab: React.FC<DocumentTabConfig & DocumentTabProps> = (prop
})
: label
const createMappedComponent = getCreateMappedComponent({
importMap: payload.importMap,
serverProps: {
i18n,
payload,
permissions,
},
})
const mappedPin = createMappedComponent(Pill, undefined, Pill_Component, 'Pill')
return (
<DocumentTabLink
adminRoute={routes.admin}
@@ -67,10 +82,10 @@ export const DocumentTab: React.FC<DocumentTabConfig & DocumentTabProps> = (prop
>
<span className={`${baseClass}__label`}>
{labelToRender}
{Pill && (
{mappedPin && (
<Fragment>
&nbsp;
<Pill />
<RenderComponent mappedComponent={mappedPin} />
</Fragment>
)}
</span>

View File

@@ -12,9 +12,9 @@ export const getCustomViews = (args: {
if (collectionConfig) {
const collectionViewsConfig =
typeof collectionConfig?.admin?.components?.views?.Edit === 'object' &&
typeof collectionConfig?.admin?.components?.views?.Edit !== 'function'
? collectionConfig?.admin?.components?.views?.Edit
typeof collectionConfig?.admin?.components?.views?.edit === 'object' &&
typeof collectionConfig?.admin?.components?.views?.edit !== 'function'
? collectionConfig?.admin?.components?.views?.edit
: undefined
customViews = Object.entries(collectionViewsConfig || {}).reduce((prev, [key, view]) => {
@@ -28,9 +28,9 @@ export const getCustomViews = (args: {
if (globalConfig) {
const globalViewsConfig =
typeof globalConfig?.admin?.components?.views?.Edit === 'object' &&
typeof globalConfig?.admin?.components?.views?.Edit !== 'function'
? globalConfig?.admin?.components?.views?.Edit
typeof globalConfig?.admin?.components?.views?.edit === 'object' &&
typeof globalConfig?.admin?.components?.views?.edit !== 'function'
? globalConfig?.admin?.components?.views?.edit
: undefined
customViews = Object.entries(globalViewsConfig || {}).reduce((prev, [key, view]) => {

View File

@@ -9,9 +9,9 @@ export const getViewConfig = (args: {
if (collectionConfig) {
const collectionConfigViewsConfig =
typeof collectionConfig?.admin?.components?.views?.Edit === 'object' &&
typeof collectionConfig?.admin?.components?.views?.Edit !== 'function'
? collectionConfig?.admin?.components?.views?.Edit
typeof collectionConfig?.admin?.components?.views?.edit === 'object' &&
typeof collectionConfig?.admin?.components?.views?.edit !== 'function'
? collectionConfig?.admin?.components?.views?.edit
: undefined
return collectionConfigViewsConfig?.[name]
@@ -19,9 +19,9 @@ export const getViewConfig = (args: {
if (globalConfig) {
const globalConfigViewsConfig =
typeof globalConfig?.admin?.components?.views?.Edit === 'object' &&
typeof globalConfig?.admin?.components?.views?.Edit !== 'function'
? globalConfig?.admin?.components?.views?.Edit
typeof globalConfig?.admin?.components?.views?.edit === 'object' &&
typeof globalConfig?.admin?.components?.views?.edit !== 'function'
? globalConfig?.admin?.components?.views?.edit
: undefined
return globalConfigViewsConfig?.[name]

View File

@@ -1,11 +1,12 @@
import type { I18n } from '@payloadcms/translations'
import type {
Payload,
Permissions,
SanitizedCollectionConfig,
SanitizedConfig,
SanitizedGlobalConfig,
} from 'payload'
import { RenderComponent, getCreateMappedComponent } from '@payloadcms/ui/shared'
import { isPlainObject } from 'payload'
import React from 'react'
@@ -20,12 +21,13 @@ const baseClass = 'doc-tabs'
export const DocumentTabs: React.FC<{
collectionConfig: SanitizedCollectionConfig
config: SanitizedConfig
globalConfig: SanitizedGlobalConfig
i18n: I18n
payload: Payload
permissions: Permissions
}> = (props) => {
const { collectionConfig, config, globalConfig, permissions } = props
const { collectionConfig, globalConfig, i18n, payload, permissions } = props
const { config } = payload
const customViews = getCustomViews({ collectionConfig, globalConfig })
@@ -46,10 +48,9 @@ export const DocumentTabs: React.FC<{
})
?.map(([name, tab], index) => {
const viewConfig = getViewConfig({ name, collectionConfig, globalConfig })
const tabFromConfig = viewConfig && 'Tab' in viewConfig ? viewConfig.Tab : undefined
const tabConfig = typeof tabFromConfig === 'object' ? tabFromConfig : undefined
const tabFromConfig = viewConfig && 'tab' in viewConfig ? viewConfig.tab : undefined
const { condition } = tabConfig || {}
const { condition } = tabFromConfig || {}
const meetsCondition =
!condition ||
@@ -72,17 +73,39 @@ export const DocumentTabs: React.FC<{
return null
})}
{customViews?.map((CustomView, index) => {
if ('Tab' in CustomView) {
const { Tab, path } = CustomView
if ('tab' in CustomView) {
const { path, tab } = CustomView
if (typeof Tab === 'object' && !isPlainObject(Tab)) {
throw new Error(
`Custom 'Tab' Component for path: "${path}" must be a React Server Component. To use client-side functionality, render your Client Component within a Server Component and pass it only props that are serializable. More info: https://react.dev/reference/react/use-server#serializable-parameters-and-return-values`,
if (tab.Component) {
const createMappedComponent = getCreateMappedComponent({
importMap: payload.importMap,
serverProps: {
i18n,
payload,
permissions,
...props,
key: `tab-custom-${index}`,
path,
},
})
const mappedTab = createMappedComponent(
tab.Component,
undefined,
undefined,
'tab.Component',
)
}
if (typeof Tab === 'function') {
return <Tab path={path} {...props} key={`tab-custom-${index}`} />
return (
<RenderComponent
clientProps={{
key: `tab-custom-${index}`,
path,
}}
key={`tab-custom-${index}`}
mappedComponent={mappedTab}
/>
)
}
return (
@@ -90,7 +113,7 @@ export const DocumentTabs: React.FC<{
key={`tab-custom-${index}`}
{...{
...props,
...Tab,
...tab,
}}
/>
)

View File

@@ -1,15 +1,16 @@
import type { DocumentTabConfig } from 'payload'
import type React from 'react'
import { VersionsPill } from './VersionsPill/index.js'
export const documentViewKeys = [
'API',
'Default',
'LivePreview',
'References',
'Relationships',
'Version',
'Versions',
'api',
'default',
'livePreview',
'references',
'relationships',
'version',
'versions',
]
export type DocumentViewKey = (typeof documentViewKeys)[number]
@@ -17,10 +18,11 @@ export type DocumentViewKey = (typeof documentViewKeys)[number]
export const tabs: Record<
DocumentViewKey,
{
Pill_Component?: React.FC
order?: number // TODO: expose this to the globalConfig config
} & DocumentTabConfig
> = {
API: {
api: {
condition: ({ collectionConfig, globalConfig }) =>
(collectionConfig && !collectionConfig?.admin?.hideAPIURL) ||
(globalConfig && !globalConfig?.admin?.hideAPIURL),
@@ -28,14 +30,14 @@ export const tabs: Record<
label: 'API',
order: 1000,
},
Default: {
default: {
href: '',
// isActive: ({ href, location }) =>
// location.pathname === href || location.pathname === `${href}/create`,
label: ({ t }) => t('general:edit'),
order: 0,
},
LivePreview: {
livePreview: {
condition: ({ collectionConfig, config, globalConfig }) => {
if (collectionConfig) {
return Boolean(
@@ -57,17 +59,17 @@ export const tabs: Record<
label: ({ t }) => t('general:livePreview'),
order: 100,
},
References: {
references: {
condition: () => false,
},
Relationships: {
relationships: {
condition: () => false,
},
Version: {
version: {
condition: () => false,
},
Versions: {
Pill: VersionsPill,
versions: {
Pill_Component: VersionsPill,
condition: ({ collectionConfig, globalConfig, permissions }) =>
Boolean(
(collectionConfig?.versions &&

View File

@@ -1,8 +1,8 @@
import type { I18n } from '@payloadcms/translations'
import type {
Payload,
Permissions,
SanitizedCollectionConfig,
SanitizedConfig,
SanitizedGlobalConfig,
} from 'payload'
@@ -16,14 +16,14 @@ const baseClass = `doc-header`
export const DocumentHeader: React.FC<{
collectionConfig?: SanitizedCollectionConfig
config: SanitizedConfig
customHeader?: React.ReactNode
globalConfig?: SanitizedGlobalConfig
hideTabs?: boolean
i18n: I18n
payload: Payload
permissions: Permissions
}> = (props) => {
const { collectionConfig, config, customHeader, globalConfig, hideTabs, i18n, permissions } =
const { collectionConfig, customHeader, globalConfig, hideTabs, i18n, payload, permissions } =
props
return (
@@ -35,9 +35,9 @@ export const DocumentHeader: React.FC<{
{!hideTabs && (
<DocumentTabs
collectionConfig={collectionConfig}
config={config}
globalConfig={globalConfig}
i18n={i18n}
payload={payload}
permissions={permissions}
/>
)}

View File

@@ -7,7 +7,7 @@ import { email, username } from 'payload/shared'
import React from 'react'
type Props = {
loginWithUsername?: LoginWithUsernameOptions | false
readonly loginWithUsername?: LoginWithUsernameOptions | false
}
function EmailFieldComponent(props: Props) {
const { loginWithUsername } = props
@@ -21,10 +21,11 @@ function EmailFieldComponent(props: Props) {
return (
<EmailField
autoComplete="off"
label={t('general:email')}
name="email"
path="email"
required={requireEmail}
field={{
name: 'email',
label: t('general:email'),
required: requireEmail,
}}
validate={email}
/>
)
@@ -43,10 +44,11 @@ function UsernameFieldComponent(props: Props) {
if (showUsernameField) {
return (
<TextField
label={t('authentication:username')}
name="username"
path="username"
required={requireUsername}
field={{
name: 'username',
label: t('authentication:username'),
required: requireUsername,
}}
validate={username}
/>
)
@@ -70,25 +72,34 @@ export function RenderEmailAndUsernameFields(props: RenderEmailAndUsernameFields
return (
<RenderFields
className={className}
fieldMap={[
fields={[
{
name: 'email',
type: 'text',
CustomField: <EmailFieldComponent loginWithUsername={loginWithUsername} />,
cellComponentProps: null,
fieldComponentProps: { type: 'email', autoComplete: 'off', readOnly },
fieldIsPresentational: false,
isFieldAffectingData: true,
admin: {
autoComplete: 'off',
components: {
Field: {
type: 'client',
Component: null,
RenderedComponent: <EmailFieldComponent loginWithUsername={loginWithUsername} />,
},
},
},
localized: false,
},
{
name: 'username',
type: 'text',
CustomField: <UsernameFieldComponent loginWithUsername={loginWithUsername} />,
cellComponentProps: null,
fieldComponentProps: { type: 'text', readOnly },
fieldIsPresentational: false,
isFieldAffectingData: true,
admin: {
components: {
Field: {
type: 'client',
Component: null,
RenderedComponent: <UsernameFieldComponent loginWithUsername={loginWithUsername} />,
},
},
},
localized: false,
},
]}

View File

@@ -1,6 +1,6 @@
import type { ServerProps } from 'payload'
import { PayloadLogo, RenderCustomComponent } from '@payloadcms/ui/shared'
import { PayloadLogo, RenderComponent, getCreateMappedComponent } from '@payloadcms/ui/shared'
import React from 'react'
export const Logo: React.FC<ServerProps> = (props) => {
@@ -16,19 +16,20 @@ export const Logo: React.FC<ServerProps> = (props) => {
} = {},
} = payload.config
return (
<RenderCustomComponent
CustomComponent={CustomLogo}
DefaultComponent={PayloadLogo}
serverOnlyProps={{
i18n,
locale,
params,
payload,
permissions,
searchParams,
user,
}}
/>
)
const createMappedComponent = getCreateMappedComponent({
importMap: payload.importMap,
serverProps: {
i18n,
locale,
params,
payload,
permissions,
searchParams,
user,
},
})
const mappedCustomLogo = createMappedComponent(CustomLogo, undefined, PayloadLogo, 'CustomLogo')
return <RenderComponent mappedComponent={mappedCustomLogo} />
}

View File

@@ -25,9 +25,11 @@ export const DefaultNavClient: React.FC = () => {
const pathname = usePathname()
const {
collections,
globals,
routes: { admin: adminRoute },
config: {
collections,
globals,
routes: { admin: adminRoute },
},
} = useConfig()
const { i18n } = useTranslation()

View File

@@ -1,6 +1,7 @@
import type { ServerProps } from 'payload'
import { Logout } from '@payloadcms/ui'
import { RenderComponent, getCreateMappedComponent } from '@payloadcms/ui/shared'
import React from 'react'
import { NavHamburger } from './NavHamburger/index.js'
@@ -9,8 +10,6 @@ import './index.scss'
const baseClass = 'nav'
import { WithServerSideProps } from '@payloadcms/ui/shared'
import { DefaultNavClient } from './index.client.js'
export type NavProps = ServerProps
@@ -28,48 +27,38 @@ export const DefaultNav: React.FC<NavProps> = (props) => {
},
} = payload.config
const BeforeNavLinks = Array.isArray(beforeNavLinks)
? beforeNavLinks.map((Component, i) => (
<WithServerSideProps
Component={Component}
key={i}
serverOnlyProps={{
i18n,
locale,
params,
payload,
permissions,
searchParams,
user,
}}
/>
))
: null
const createMappedComponent = getCreateMappedComponent({
importMap: payload.importMap,
serverProps: {
i18n,
locale,
params,
payload,
permissions,
searchParams,
user,
},
})
const AfterNavLinks = Array.isArray(afterNavLinks)
? afterNavLinks.map((Component, i) => (
<WithServerSideProps
Component={Component}
key={i}
serverOnlyProps={{
i18n,
locale,
params,
payload,
permissions,
searchParams,
user,
}}
/>
))
: null
const mappedBeforeNavLinks = createMappedComponent(
beforeNavLinks,
undefined,
undefined,
'beforeNavLinks',
)
const mappedAfterNavLinks = createMappedComponent(
afterNavLinks,
undefined,
undefined,
'afterNavLinks',
)
return (
<NavWrapper baseClass={baseClass}>
<nav className={`${baseClass}__wrap`}>
{Array.isArray(BeforeNavLinks) && BeforeNavLinks.map((Component) => Component)}
<RenderComponent mappedComponent={mappedBeforeNavLinks} />
<DefaultNavClient />
{Array.isArray(AfterNavLinks) && AfterNavLinks.map((Component) => Component)}
<RenderComponent mappedComponent={mappedAfterNavLinks} />
<div className={`${baseClass}__controls`}>
<Logout />
</div>

View File

@@ -1,13 +1,12 @@
import type { AcceptedLanguages, I18nClient } from '@payloadcms/translations'
import type { PayloadRequest, SanitizedConfig } from 'payload'
import type { ImportMap, PayloadRequest, SanitizedConfig } from 'payload'
import { initI18n, rtlLanguages } from '@payloadcms/translations'
import { RootProvider } from '@payloadcms/ui'
import '@payloadcms/ui/scss/app.scss'
import { buildComponentMap } from '@payloadcms/ui/utilities/buildComponentMap'
import { createClientConfig } from '@payloadcms/ui/utilities/createClientConfig'
import { headers as getHeaders, cookies as nextCookies } from 'next/headers.js'
import { createClientConfig, createLocalReq, parseCookies } from 'payload'
import * as qs from 'qs-esm'
import { createLocalReq, parseCookies } from 'payload'
import React from 'react'
import { getPayloadHMR } from '../../utilities/getPayloadHMR.js'
@@ -24,9 +23,11 @@ export const metadata = {
export const RootLayout = async ({
children,
config: configPromise,
importMap,
}: {
children: React.ReactNode
config: Promise<SanitizedConfig>
readonly children: React.ReactNode
readonly config: Promise<SanitizedConfig>
readonly importMap: ImportMap
}) => {
const config = await configPromise
@@ -67,7 +68,15 @@ export const RootLayout = async ({
)
const { permissions, user } = await payload.auth({ headers, req })
const clientConfig = await createClientConfig({ config, t: i18n.t })
const { clientConfig, render } = await createClientConfig({
DefaultEditView,
DefaultListView,
children,
config,
i18n,
importMap,
payload,
})
const dir = (rtlLanguages as unknown as AcceptedLanguages[]).includes(languageCode)
? 'RTL'
@@ -97,19 +106,10 @@ export const RootLayout = async ({
})
}
const { componentMap, wrappedChildren } = buildComponentMap({
DefaultEditView,
DefaultListView,
children,
i18n,
payload,
})
return (
<html data-theme={theme} dir={dir} lang={languageCode}>
<body>
<RootProvider
componentMap={componentMap}
config={clientConfig}
dateFNSKey={i18n.dateFNSKey}
fallbackLang={clientConfig.i18n.fallbackLanguage}
@@ -121,7 +121,7 @@ export const RootLayout = async ({
translations={i18n.translations}
user={user}
>
{wrappedChildren}
{render}
</RootProvider>
<div id="portal" />
</body>

View File

@@ -20,6 +20,8 @@ export const buildFormState = async ({ req }: { req: PayloadRequest }) => {
status: httpStatus.OK,
})
} catch (err) {
req.payload.logger.error({ err, msg: `There was an error building form state` })
if (err.message === 'Could not find field schema for given path') {
return Response.json(
{
@@ -39,8 +41,6 @@ export const buildFormState = async ({ req }: { req: PayloadRequest }) => {
})
}
req.payload.logger.error({ err, msg: `There was an error building form state` })
return routeError({
config: req.payload.config,
err,

View File

@@ -1,7 +1,10 @@
import type { MappedComponent } from 'payload'
import { RenderComponent } from '@payloadcms/ui/shared'
import React from 'react'
export const OGImage: React.FC<{
Icon: React.ComponentType<any>
Icon: MappedComponent
description?: string
fontFamily?: string
leader?: string
@@ -82,7 +85,12 @@ export const OGImage: React.FC<{
width: '38px',
}}
>
<Icon fill="white" />
<RenderComponent
clientProps={{
fill: 'white',
}}
mappedComponent={Icon}
/>
</div>
</div>
)

View File

@@ -1,6 +1,6 @@
import type { PayloadRequest } from 'payload'
import { PayloadIcon } from '@payloadcms/ui/shared'
import { PayloadIcon, getCreateMappedComponent } from '@payloadcms/ui/shared'
import fs from 'fs/promises'
import { ImageResponse } from 'next/og.js'
import { NextResponse } from 'next/server.js'
@@ -32,7 +32,18 @@ export const generateOGImage = async ({ req }: { req: PayloadRequest }) => {
const hasLeader = searchParams.has('leader')
const leader = hasLeader ? searchParams.get('leader')?.slice(0, 100).replace('-', ' ') : ''
const description = searchParams.has('description') ? searchParams.get('description') : ''
const Icon = config.admin?.components?.graphics?.Icon || PayloadIcon
const createMappedComponent = getCreateMappedComponent({
importMap: req.payload.importMap,
serverProps: {},
})
const mappedIcon = createMappedComponent(
config.admin?.components?.graphics?.Icon,
undefined,
PayloadIcon,
'config.admin.components.graphics.Icon',
)
let fontData
@@ -50,7 +61,7 @@ export const generateOGImage = async ({ req }: { req: PayloadRequest }) => {
return new ImageResponse(
(
<OGImage
Icon={Icon}
Icon={mappedIcon}
description={description}
fontFamily={fontFamily}
leader={leader}

View File

@@ -1,10 +1,10 @@
import type { ServerProps, VisibleEntities } from 'payload'
import type { MappedComponent, ServerProps, VisibleEntities } from 'payload'
import { AppHeader, EntityVisibilityProvider, NavToggler } from '@payloadcms/ui'
import { RenderCustomComponent } from '@payloadcms/ui/shared'
import { RenderComponent, getCreateMappedComponent } from '@payloadcms/ui/shared'
import React from 'react'
import { DefaultNav, type NavProps } from '../../elements/Nav/index.js'
import { DefaultNav } from '../../elements/Nav/index.js'
import { NavHamburger } from './NavHamburger/index.js'
import { Wrapper } from './Wrapper/index.js'
import './index.scss'
@@ -37,15 +37,25 @@ export const DefaultTemplate: React.FC<DefaultTemplateProps> = ({
} = {},
} = payload.config || {}
const navProps: NavProps = {
i18n,
locale,
params,
payload,
permissions,
searchParams,
user,
}
const createMappedComponent = getCreateMappedComponent({
importMap: payload.importMap,
serverProps: {
i18n,
locale,
params,
payload,
permissions,
searchParams,
user,
},
})
const MappedDefaultNav: MappedComponent = createMappedComponent(
CustomNav,
undefined,
DefaultNav,
'CustomNav',
)
return (
<EntityVisibilityProvider visibleEntities={visibleEntities}>
@@ -56,20 +66,8 @@ export const DefaultTemplate: React.FC<DefaultTemplateProps> = ({
</NavToggler>
</div>
<Wrapper baseClass={baseClass} className={className}>
<RenderCustomComponent
CustomComponent={CustomNav}
DefaultComponent={DefaultNav}
componentProps={navProps}
serverOnlyProps={{
i18n,
locale,
params,
payload,
permissions,
searchParams,
user,
}}
/>
<RenderComponent mappedComponent={MappedDefaultNav} />
<div className={`${baseClass}__wrap`}>
<AppHeader />
{children}

View File

@@ -1,6 +1,6 @@
import type { InitOptions, Payload, SanitizedConfig } from 'payload'
import { BasePayload } from 'payload'
import { BasePayload, generateImportMap } from 'payload'
import WebSocket from 'ws'
let cached: {
@@ -45,6 +45,13 @@ export const reload = async (config: SanitizedConfig, payload: Payload): Promise
})
}
// Generate component map
if (config.admin?.importMap?.autoGenerate !== false) {
await generateImportMap(config, {
log: true,
})
}
await payload.db.init()
if (payload.db.connect) {
await payload.db.connect({ hotReload: true })
@@ -74,6 +81,9 @@ export const getPayloadHMR = async (options: InitOptions): Promise<Payload> => {
await cached.reload
}
if (options?.importMap) {
cached.payload.importMap = options.importMap
}
return cached.payload
}
@@ -115,5 +125,9 @@ export const getPayloadHMR = async (options: InitOptions): Promise<Payload> => {
throw e
}
if (options?.importMap) {
cached.payload.importMap = options.importMap
}
return cached.payload
}

View File

@@ -16,12 +16,13 @@ import { handleAuthRedirect } from './handleAuthRedirect.js'
export const initPage = async ({
config: configPromise,
importMap,
redirectUnauthenticatedUser = false,
route,
searchParams,
}: Args): Promise<InitPageResult> => {
const headers = getHeaders()
const payload = await getPayloadHMR({ config: configPromise })
const payload = await getPayloadHMR({ config: configPromise, importMap })
const {
collections,

View File

@@ -1,4 +1,4 @@
import type { SanitizedConfig } from 'payload'
import type { ImportMap, SanitizedConfig } from 'payload'
export type Args = {
/**
@@ -6,6 +6,7 @@ export type Args = {
* If unresolved, this function will await the promise.
*/
config: Promise<SanitizedConfig> | SanitizedConfig
importMap: ImportMap
/**
* If true, redirects unauthenticated users to the admin login page.
* If a string is provided, the user will be redirected to that specific URL.

View File

@@ -2,21 +2,23 @@ import { SelectField, useTranslation } from '@payloadcms/ui'
import React from 'react'
export const LocaleSelector: React.FC<{
localeOptions: {
readonly localeOptions: {
label: Record<string, string> | string
value: string
}[]
onChange: (value: string) => void
readonly onChange: (value: string) => void
}> = ({ localeOptions, onChange }) => {
const { t } = useTranslation()
return (
<SelectField
label={t('general:locale')}
name="locale"
field={{
name: 'locale',
_path: 'locale',
label: t('general:locale'),
options: localeOptions,
}}
onChange={(value: string) => onChange(value)}
options={localeOptions}
path="locale"
/>
)
}

View File

@@ -1,14 +1,15 @@
'use client'
import type { ClientCollectionConfig, ClientGlobalConfig } from 'payload'
import {
CheckboxField,
CopyToClipboard,
Form,
Gutter,
MinimizeMaximizeIcon,
NumberField as NumberInput,
NumberField,
SetViewActions,
useComponentMap,
useConfig,
useDocumentInfo,
useLocale,
@@ -32,22 +33,17 @@ export const APIViewClient: React.FC = () => {
const { i18n, t } = useTranslation()
const { code } = useLocale()
const { getComponentMap } = useComponentMap()
const componentMap = getComponentMap({ collectionSlug, globalSlug })
const {
collections,
globals,
localization,
routes: { api: apiRoute },
serverURL,
config: {
localization,
routes: { api: apiRoute },
serverURL,
},
getEntityConfig,
} = useConfig()
const collectionConfig =
collectionSlug && collections.find((collection) => collection.slug === collectionSlug)
const globalConfig = globalSlug && globals.find((global) => global.slug === globalSlug)
const collectionClientConfig = getEntityConfig({ collectionSlug }) as ClientCollectionConfig
const globalClientConfig = getEntityConfig({ globalSlug }) as ClientGlobalConfig
const localeOptions =
localization &&
@@ -56,13 +52,13 @@ export const APIViewClient: React.FC = () => {
let draftsEnabled: boolean = false
let docEndpoint: string = ''
if (collectionConfig) {
draftsEnabled = Boolean(collectionConfig.versions?.drafts)
if (collectionClientConfig) {
draftsEnabled = Boolean(collectionClientConfig.versions?.drafts)
docEndpoint = `/${collectionSlug}/${id}`
}
if (globalConfig) {
draftsEnabled = Boolean(globalConfig.versions?.drafts)
if (globalClientConfig) {
draftsEnabled = Boolean(globalClientConfig.versions?.drafts)
docEndpoint = `/globals/${globalSlug}`
}
@@ -97,11 +93,11 @@ export const APIViewClient: React.FC = () => {
setData(json)
} catch (error) {
toast.error('Error parsing response')
console.error(error)
console.error(error) // eslint-disable-line no-console
}
} catch (error) {
toast.error('Error making request')
console.error(error)
console.error(error) // eslint-disable-line no-console
}
}
@@ -115,14 +111,19 @@ export const APIViewClient: React.FC = () => {
>
<SetDocumentStepNav
collectionSlug={collectionSlug}
globalLabel={globalConfig?.label}
globalLabel={globalClientConfig?.label}
globalSlug={globalSlug}
id={id}
pluralLabel={collectionConfig ? collectionConfig?.labels?.plural : undefined}
useAsTitle={collectionConfig ? collectionConfig?.admin?.useAsTitle : undefined}
pluralLabel={collectionClientConfig ? collectionClientConfig?.labels?.plural : undefined}
useAsTitle={collectionClientConfig ? collectionClientConfig?.admin?.useAsTitle : undefined}
view="API"
/>
<SetViewActions actions={componentMap?.actionsMap?.Edit?.API} />
<SetViewActions
actions={
(collectionClientConfig || globalClientConfig)?.admin?.components?.views?.edit?.api
?.actions
}
/>
<div className={`${baseClass}__configuration`}>
<div className={`${baseClass}__api-url`}>
<span className={`${baseClass}__label`}>
@@ -160,28 +161,33 @@ export const APIViewClient: React.FC = () => {
<div className={`${baseClass}__filter-query-checkboxes`}>
{draftsEnabled && (
<CheckboxField
label={t('version:draft')}
name="draft"
field={{
name: 'draft',
label: t('version:draft'),
}}
onChange={() => setDraft(!draft)}
path="draft"
/>
)}
<CheckboxField
label={t('authentication:authenticated')}
name="authenticated"
field={{
name: 'authenticated',
label: t('authentication:authenticated'),
}}
onChange={() => setAuthenticated(!authenticated)}
path="authenticated"
/>
</div>
{localeOptions && <LocaleSelector localeOptions={localeOptions} onChange={setLocale} />}
<NumberInput
label={t('general:depth')}
max={10}
min={0}
name="depth"
<NumberField
field={{
name: 'depth',
admin: {
step: 1,
},
label: t('general:depth'),
max: 10,
min: 0,
}}
onChange={(value) => setDepth(value?.toString())}
path="depth"
step={1}
/>
</div>
</Form>

View File

@@ -1,9 +1,9 @@
import type { EditViewComponent } from 'payload'
import type { EditViewComponent, PayloadServerReactComponent } from 'payload'
import React from 'react'
import { APIViewClient } from './index.client.js'
export const APIView: EditViewComponent = () => {
export const APIView: PayloadServerReactComponent<EditViewComponent> = () => {
return <APIViewClient />
}

View File

@@ -16,23 +16,25 @@ export const ToggleTheme: React.FC = () => {
return (
<RadioGroupField
label={t('general:adminTheme')}
name="theme"
field={{
name: 'theme',
label: t('general:adminTheme'),
options: [
{
label: t('general:automatic'),
value: 'auto',
},
{
label: t('general:light'),
value: 'light',
},
{
label: t('general:dark'),
value: 'dark',
},
],
}}
onChange={onChange}
options={[
{
label: t('general:automatic'),
value: 'auto',
},
{
label: t('general:light'),
value: 'light',
},
{
label: t('general:dark'),
value: 'dark',
},
]}
value={autoMode ? 'auto' : theme}
/>
)

View File

@@ -1,7 +1,7 @@
import type { AdminViewProps, ServerSideEditViewProps } from 'payload'
import type { AdminViewProps } from 'payload'
import { DocumentInfoProvider, EditDepthProvider, HydrateAuthProvider } from '@payloadcms/ui'
import { RenderCustomComponent } from '@payloadcms/ui/shared'
import { DocumentInfoProvider, EditDepthProvider, HydrateAuthProvider, RenderComponent } from '@payloadcms/ui'
import { getCreateMappedComponent } from '@payloadcms/ui/shared'
import { notFound } from 'next/navigation.js'
import React from 'react'
@@ -56,12 +56,27 @@ export const Account: React.FC<AdminViewProps> = async ({
req,
})
const viewComponentProps: ServerSideEditViewProps = {
initPageResult,
params,
routeSegments: [],
searchParams,
}
const createMappedComponent = getCreateMappedComponent({
importMap: payload.importMap,
serverProps: {
i18n,
initPageResult,
locale,
params,
payload,
permissions,
routeSegments: [],
searchParams,
user,
},
})
const mappedAccountComponent = createMappedComponent(
CustomAccountComponent?.Component,
undefined,
EditView,
'CustomAccountComponent.Component',
)
return (
<DocumentInfoProvider
@@ -77,30 +92,16 @@ export const Account: React.FC<AdminViewProps> = async ({
isEditing
>
<EditDepthProvider depth={1}>
<DocumentHeader
collectionConfig={collectionConfig}
config={payload.config}
hideTabs
i18n={i18n}
permissions={permissions}
/>
<HydrateAuthProvider permissions={permissions} />
<RenderCustomComponent
CustomComponent={
typeof CustomAccountComponent === 'function' ? CustomAccountComponent : undefined
}
DefaultComponent={EditView}
componentProps={viewComponentProps}
serverOnlyProps={{
i18n,
locale,
params,
payload,
permissions,
searchParams,
user,
}}
/>
<DocumentHeader
collectionConfig={collectionConfig}
hideTabs
i18n={i18n}
payload={payload}
permissions={permissions}
/>
<HydrateAuthProvider permissions={permissions} />
<RenderComponent mappedComponent={mappedAccountComponent} />
<AccountClient />
</EditDepthProvider>
</DocumentInfoProvider>

View File

@@ -1,5 +1,5 @@
'use client'
import type { FormState, LoginWithUsernameOptions } from 'payload'
import type { ClientCollectionConfig, FormState, LoginWithUsernameOptions } from 'payload'
import {
ConfirmPasswordField,
@@ -8,7 +8,6 @@ import {
FormSubmit,
PasswordField,
RenderFields,
useComponentMap,
useConfig,
useTranslation,
} from '@payloadcms/ui'
@@ -22,16 +21,17 @@ export const CreateFirstUserClient: React.FC<{
loginWithUsername?: LoginWithUsernameOptions | false
userSlug: string
}> = ({ initialState, loginWithUsername, userSlug }) => {
const { getFieldMap } = useComponentMap()
const {
routes: { admin, api: apiRoute },
serverURL,
config: {
routes: { admin, api: apiRoute },
serverURL,
},
getEntityConfig,
} = useConfig()
const { t } = useTranslation()
const fieldMap = getFieldMap({ collectionSlug: userSlug })
const collectionConfig = getEntityConfig({ collectionSlug: userSlug }) as ClientCollectionConfig
const onChange: FormProps['onChange'][0] = React.useCallback(
async ({ formState: prevFormState }) =>
@@ -64,15 +64,16 @@ export const CreateFirstUserClient: React.FC<{
readOnly={false}
/>
<PasswordField
autoComplete="off"
label={t('authentication:newPassword')}
name="password"
path="password"
required
autoComplete={'off'}
field={{
name: 'password',
label: t('authentication:newPassword'),
required: true,
}}
/>
<ConfirmPasswordField />
<RenderFields
fieldMap={fieldMap}
fields={collectionConfig.fields}
forceRender
operation="create"
path=""

View File

@@ -3,7 +3,12 @@ import type { Permissions, ServerProps, VisibleEntities } from 'payload'
import { getTranslation } from '@payloadcms/translations'
import { Button, Card, Gutter, SetStepNav, SetViewActions } from '@payloadcms/ui'
import { EntityType, WithServerSideProps, formatAdminURL } from '@payloadcms/ui/shared'
import {
EntityType,
RenderComponent,
formatAdminURL,
getCreateMappedComponent,
} from '@payloadcms/ui/shared'
import React, { Fragment } from 'react'
import './index.scss'
@@ -39,48 +44,39 @@ export const DefaultDashboard: React.FC<DashboardProps> = (props) => {
user,
} = props
const BeforeDashboards = Array.isArray(beforeDashboard)
? beforeDashboard.map((Component, i) => (
<WithServerSideProps
Component={Component}
key={i}
serverOnlyProps={{
i18n,
locale,
params,
payload,
permissions,
searchParams,
user,
}}
/>
))
: null
const createMappedComponent = getCreateMappedComponent({
importMap: payload.importMap,
serverProps: {
i18n,
locale,
params,
payload,
permissions,
searchParams,
user,
},
})
const AfterDashboards = Array.isArray(afterDashboard)
? afterDashboard.map((Component, i) => (
<WithServerSideProps
Component={Component}
key={i}
serverOnlyProps={{
i18n,
locale,
params,
payload,
permissions,
searchParams,
user,
}}
/>
))
: null
const mappedBeforeDashboards = createMappedComponent(
beforeDashboard,
undefined,
undefined,
'beforeDashboard',
)
const mappedAfterDashboards = createMappedComponent(
afterDashboard,
undefined,
undefined,
'afterDashboard',
)
return (
<div className={baseClass}>
<SetStepNav nav={[]} />
<SetViewActions actions={[]} />
<Gutter className={`${baseClass}__wrap`}>
{Array.isArray(BeforeDashboards) && BeforeDashboards.map((Component) => Component)}
<RenderComponent mappedComponent={mappedBeforeDashboards} />
<Fragment>
<SetViewActions actions={[]} />
{!navGroups || navGroups?.length === 0 ? (
@@ -162,7 +158,7 @@ export const DefaultDashboard: React.FC<DashboardProps> = (props) => {
})
)}
</Fragment>
{Array.isArray(AfterDashboards) && AfterDashboards.map((Component) => Component)}
<RenderComponent mappedComponent={mappedAfterDashboards} />
</Gutter>
</div>
)

View File

@@ -2,12 +2,15 @@ import type { EntityToGroup } from '@payloadcms/ui/shared'
import type { AdminViewProps } from 'payload'
import { HydrateAuthProvider } from '@payloadcms/ui'
import { EntityType, RenderCustomComponent, groupNavItems } from '@payloadcms/ui/shared'
import {
EntityType,
RenderComponent,
getCreateMappedComponent,
groupNavItems,
} from '@payloadcms/ui/shared'
import LinkImport from 'next/link.js'
import React, { Fragment } from 'react'
import type { DashboardProps } from './Default/index.js'
import { DefaultDashboard } from './Default/index.js'
export { generateDashboardMetadata } from './meta.js'
@@ -64,37 +67,38 @@ export const Dashboard: React.FC<AdminViewProps> = ({ initPageResult, params, se
i18n,
)
const viewComponentProps: DashboardProps = {
Link,
i18n,
locale,
navGroups,
params,
payload,
permissions,
searchParams,
user,
visibleEntities,
}
const createMappedComponent = getCreateMappedComponent({
importMap: payload.importMap,
serverProps: {
Link,
i18n,
locale,
navGroups,
params,
payload,
permissions,
searchParams,
user,
visibleEntities,
},
})
const mappedDashboardComponent = createMappedComponent(
CustomDashboardComponent?.Component,
undefined,
DefaultDashboard,
'CustomDashboardComponent.Component',
)
return (
<Fragment>
<HydrateAuthProvider permissions={permissions} />
<RenderCustomComponent
CustomComponent={
typeof CustomDashboardComponent === 'function' ? CustomDashboardComponent : undefined
}
DefaultComponent={DefaultDashboard}
componentProps={viewComponentProps}
serverOnlyProps={{
i18n,
<RenderComponent
clientProps={{
Link,
locale,
params,
payload,
permissions,
searchParams,
user,
}}
mappedComponent={mappedDashboardComponent}
/>
</Fragment>
)

View File

@@ -1,23 +1,13 @@
import type { EditViewComponent, SanitizedCollectionConfig, SanitizedGlobalConfig } from 'payload'
import { isReactComponentOrFunction } from 'payload/shared'
export const getCustomViewByKey = (
views:
| SanitizedCollectionConfig['admin']['components']['views']
| SanitizedGlobalConfig['admin']['components']['views'],
customViewKey: string,
): EditViewComponent => {
return typeof views?.Edit === 'function'
? views?.Edit
: typeof views?.Edit === 'object' &&
views?.Edit?.[customViewKey] &&
typeof views?.Edit?.[customViewKey] === 'function'
? views?.Edit?.[customViewKey]
: views?.Edit?.[customViewKey]
? typeof views?.Edit?.[customViewKey] === 'object' &&
'Component' in views.Edit[customViewKey] &&
isReactComponentOrFunction(views?.Edit?.[customViewKey].Component) &&
views?.Edit?.[customViewKey].Component
: null
return typeof views?.edit?.[customViewKey] === 'object' &&
'Component' in views.edit[customViewKey]
? views?.edit?.[customViewKey].Component
: null
}

View File

@@ -13,8 +13,8 @@ export const getCustomViewByRoute = ({
| SanitizedCollectionConfig['admin']['components']['views']
| SanitizedGlobalConfig['admin']['components']['views']
}): EditViewComponent => {
if (typeof views?.Edit === 'object' && typeof views?.Edit !== 'function') {
const foundViewConfig = Object.entries(views.Edit).find(([, view]) => {
if (typeof views?.edit === 'object' && typeof views?.edit !== 'function') {
const foundViewConfig = Object.entries(views.edit).find(([, view]) => {
if (typeof view === 'object' && typeof view !== 'function' && 'path' in view) {
const viewPath = `${baseRoute}${view.path}`

View File

@@ -1,12 +1,14 @@
import type {
AdminViewComponent,
AdminViewProps,
CollectionPermission,
EditViewComponent,
GlobalPermission,
PayloadComponent,
SanitizedCollectionConfig,
SanitizedConfig,
SanitizedGlobalConfig,
ServerSideEditViewProps,
} from 'payload'
import type React from 'react'
import { notFound } from 'next/navigation.js'
@@ -19,6 +21,11 @@ import { VersionsView as DefaultVersionsView } from '../Versions/index.js'
import { getCustomViewByKey } from './getCustomViewByKey.js'
import { getCustomViewByRoute } from './getCustomViewByRoute.js'
export type ViewFromConfig<TProps extends object> = {
Component?: React.FC<TProps>
payloadComponent?: PayloadComponent<TProps>
}
export const getViewsFromConfig = ({
collectionConfig,
config,
@@ -28,22 +35,21 @@ export const getViewsFromConfig = ({
}: {
collectionConfig?: SanitizedCollectionConfig
config: SanitizedConfig
docPermissions: CollectionPermission | GlobalPermission
globalConfig?: SanitizedGlobalConfig
routeSegments: string[]
}): {
CustomView: EditViewComponent
DefaultView: EditViewComponent
CustomView: ViewFromConfig<ServerSideEditViewProps>
DefaultView: ViewFromConfig<ServerSideEditViewProps>
/**
* The error view to display if CustomView or DefaultView do not exist (could be either due to not found, or unauthorized). Can be null
*/
ErrorView: AdminViewComponent
ErrorView: ViewFromConfig<AdminViewProps>
} | null => {
// Conditionally import and lazy load the default view
let DefaultView: EditViewComponent = null
let CustomView: EditViewComponent = null
let ErrorView: AdminViewComponent = null
let DefaultView: ViewFromConfig<ServerSideEditViewProps> = null
let CustomView: ViewFromConfig<ServerSideEditViewProps> = null
let ErrorView: ViewFromConfig<AdminViewProps> = null
const {
routes: { admin: adminRoute },
@@ -60,7 +66,7 @@ export const getViewsFromConfig = ({
config?.admin?.livePreview?.globals?.includes(globalConfig?.slug)
if (collectionConfig) {
const editConfig = collectionConfig?.admin?.components?.views?.Edit
const editConfig = collectionConfig?.admin?.components?.views?.edit
const EditOverride = typeof editConfig === 'function' ? editConfig : null
if (EditOverride) {
@@ -80,17 +86,27 @@ export const getViewsFromConfig = ({
switch (segment3) {
case 'create': {
if ('create' in docPermissions && docPermissions?.create?.permission) {
CustomView = getCustomViewByKey(views, 'Default')
DefaultView = DefaultEditView
CustomView = {
payloadComponent: getCustomViewByKey(views, 'default'),
}
DefaultView = {
Component: DefaultEditView,
}
} else {
ErrorView = UnauthorizedView
ErrorView = {
Component: UnauthorizedView,
}
}
break
}
default: {
CustomView = getCustomViewByKey(views, 'Default')
DefaultView = DefaultEditView
CustomView = {
payloadComponent: getCustomViewByKey(views, 'default'),
}
DefaultView = {
Component: DefaultEditView,
}
break
}
}
@@ -102,25 +118,37 @@ export const getViewsFromConfig = ({
switch (segment4) {
case 'api': {
if (collectionConfig?.admin?.hideAPIURL !== true) {
CustomView = getCustomViewByKey(views, 'API')
DefaultView = DefaultAPIView
CustomView = {
payloadComponent: getCustomViewByKey(views, 'api'),
}
DefaultView = {
Component: DefaultAPIView,
}
}
break
}
case 'preview': {
if (livePreviewEnabled) {
DefaultView = DefaultLivePreviewView
DefaultView = {
Component: DefaultLivePreviewView,
}
}
break
}
case 'versions': {
if (docPermissions?.readVersions?.permission) {
CustomView = getCustomViewByKey(views, 'Versions')
DefaultView = DefaultVersionsView
CustomView = {
payloadComponent: getCustomViewByKey(views, 'versions'),
}
DefaultView = {
Component: DefaultVersionsView,
}
} else {
ErrorView = UnauthorizedView
ErrorView = {
Component: UnauthorizedView,
}
}
break
}
@@ -139,11 +167,13 @@ export const getViewsFromConfig = ({
.filter(Boolean)
.join('/')
CustomView = getCustomViewByRoute({
baseRoute,
currentRoute,
views,
})
CustomView = {
payloadComponent: getCustomViewByRoute({
baseRoute,
currentRoute,
views,
}),
}
break
}
}
@@ -154,10 +184,16 @@ export const getViewsFromConfig = ({
default: {
if (segment4 === 'versions') {
if (docPermissions?.readVersions?.permission) {
CustomView = getCustomViewByKey(views, 'Version')
DefaultView = DefaultVersionView
CustomView = {
payloadComponent: getCustomViewByKey(views, 'version'),
}
DefaultView = {
Component: DefaultVersionView,
}
} else {
ErrorView = UnauthorizedView
ErrorView = {
Component: UnauthorizedView,
}
}
} else {
const baseRoute = [
@@ -173,11 +209,13 @@ export const getViewsFromConfig = ({
.filter(Boolean)
.join('/')
CustomView = getCustomViewByRoute({
baseRoute,
currentRoute,
views,
})
CustomView = {
payloadComponent: getCustomViewByRoute({
baseRoute,
currentRoute,
views,
}),
}
}
break
}
@@ -187,7 +225,7 @@ export const getViewsFromConfig = ({
}
if (globalConfig) {
const editConfig = globalConfig?.admin?.components?.views?.Edit
const editConfig = globalConfig?.admin?.components?.views?.edit
const EditOverride = typeof editConfig === 'function' ? editConfig : null
if (EditOverride) {
@@ -202,8 +240,12 @@ export const getViewsFromConfig = ({
} else {
switch (routeSegments.length) {
case 2: {
CustomView = getCustomViewByKey(views, 'Default')
DefaultView = DefaultEditView
CustomView = {
payloadComponent: getCustomViewByKey(views, 'default'),
}
DefaultView = {
Component: DefaultEditView,
}
break
}
@@ -212,25 +254,37 @@ export const getViewsFromConfig = ({
switch (segment3) {
case 'api': {
if (globalConfig?.admin?.hideAPIURL !== true) {
CustomView = getCustomViewByKey(views, 'API')
DefaultView = DefaultAPIView
CustomView = {
payloadComponent: getCustomViewByKey(views, 'api'),
}
DefaultView = {
Component: DefaultAPIView,
}
}
break
}
case 'preview': {
if (livePreviewEnabled) {
DefaultView = DefaultLivePreviewView
DefaultView = {
Component: DefaultLivePreviewView,
}
}
break
}
case 'versions': {
if (docPermissions?.readVersions?.permission) {
CustomView = getCustomViewByKey(views, 'Versions')
DefaultView = DefaultVersionsView
CustomView = {
payloadComponent: getCustomViewByKey(views, 'versions'),
}
DefaultView = {
Component: DefaultVersionsView,
}
} else {
ErrorView = UnauthorizedView
ErrorView = {
Component: UnauthorizedView,
}
}
break
}
@@ -245,14 +299,20 @@ export const getViewsFromConfig = ({
.filter(Boolean)
.join('/')
CustomView = getCustomViewByRoute({
baseRoute,
currentRoute,
views,
})
DefaultView = DefaultEditView
CustomView = {
payloadComponent: getCustomViewByRoute({
baseRoute,
currentRoute,
views,
}),
}
DefaultView = {
Component: DefaultEditView,
}
} else {
ErrorView = UnauthorizedView
ErrorView = {
Component: UnauthorizedView,
}
}
break
}
@@ -264,10 +324,16 @@ export const getViewsFromConfig = ({
// `../:slug/versions/:version`, etc
if (segment3 === 'versions') {
if (docPermissions?.readVersions?.permission) {
CustomView = getCustomViewByKey(views, 'Version')
DefaultView = DefaultVersionView
CustomView = {
payloadComponent: getCustomViewByKey(views, 'version'),
}
DefaultView = {
Component: DefaultVersionView,
}
} else {
ErrorView = UnauthorizedView
ErrorView = {
Component: UnauthorizedView,
}
}
} else {
const baseRoute = [adminRoute !== '/' && adminRoute, 'globals', globalSlug]
@@ -278,11 +344,13 @@ export const getViewsFromConfig = ({
.filter(Boolean)
.join('/')
CustomView = getCustomViewByRoute({
baseRoute,
currentRoute,
views,
})
CustomView = {
payloadComponent: getCustomViewByRoute({
baseRoute,
currentRoute,
views,
}),
}
}
break
}

View File

@@ -1,9 +1,15 @@
import type { AdminViewComponent, AdminViewProps, EditViewComponent } from 'payload'
import type {
AdminViewProps,
EditViewComponent,
MappedComponent,
ServerSideEditViewProps,
} from 'payload'
import { DocumentInfoProvider, EditDepthProvider, HydrateAuthProvider } from '@payloadcms/ui'
import {
RenderCustomComponent,
RenderComponent,
formatAdminURL,
getCreateMappedComponent,
isEditing as getIsEditing,
} from '@payloadcms/ui/shared'
import { notFound, redirect } from 'next/navigation.js'
@@ -21,6 +27,7 @@ import { getViewsFromConfig } from './getViewsFromConfig.js'
export const generateMetadata: GenerateEditViewMetadata = async (args) => getMetaBySegment(args)
export const Document: React.FC<AdminViewProps> = async ({
importMap,
initPageResult,
params,
searchParams,
@@ -53,10 +60,10 @@ export const Document: React.FC<AdminViewProps> = async ({
const isEditing = getIsEditing({ id, collectionSlug, globalSlug })
let ViewOverride: EditViewComponent
let CustomView: EditViewComponent
let DefaultView: EditViewComponent
let ErrorView: AdminViewComponent
let ViewOverride: MappedComponent<ServerSideEditViewProps>
let CustomView: MappedComponent<ServerSideEditViewProps>
let DefaultView: MappedComponent<ServerSideEditViewProps>
let ErrorView: MappedComponent<AdminViewProps>
let apiURL: string
@@ -76,6 +83,21 @@ export const Document: React.FC<AdminViewProps> = async ({
req,
})
const createMappedComponent = getCreateMappedComponent({
importMap,
serverProps: {
i18n,
initPageResult,
locale,
params,
payload,
permissions,
routeSegments: segments,
searchParams,
user,
},
})
if (collectionConfig) {
if (!visibleEntities?.collections?.find((visibleSlug) => visibleSlug === collectionSlug)) {
notFound()
@@ -93,8 +115,17 @@ export const Document: React.FC<AdminViewProps> = async ({
apiURL = `${serverURL}${apiRoute}/${collectionSlug}/${id}${apiQueryParams}`
const editConfig = collectionConfig?.admin?.components?.views?.Edit
ViewOverride = typeof editConfig === 'function' ? editConfig : null
ViewOverride =
collectionConfig?.admin?.components?.views?.edit?.default &&
'Component' in collectionConfig.admin.components.views.edit.default
? createMappedComponent(
collectionConfig?.admin?.components?.views?.edit?.default
?.Component as EditViewComponent, // some type info gets lost from Config => SanitizedConfig due to our usage of Deep type operations from ts-essentials. Despite .Component being defined as EditViewComponent, this info is lost and we need cast it here.
undefined,
undefined,
'collectionConfig?.admin?.components?.views?.edit?.default',
)
: null
if (!ViewOverride) {
const collectionViews = getViewsFromConfig({
@@ -104,13 +135,30 @@ export const Document: React.FC<AdminViewProps> = async ({
routeSegments: segments,
})
CustomView = collectionViews?.CustomView
DefaultView = collectionViews?.DefaultView
ErrorView = collectionViews?.ErrorView
CustomView = createMappedComponent(
collectionViews?.CustomView?.payloadComponent,
undefined,
collectionViews?.CustomView?.Component,
'collectionViews?.CustomView.payloadComponent',
)
DefaultView = createMappedComponent(
collectionViews?.DefaultView?.payloadComponent,
undefined,
collectionViews?.DefaultView?.Component,
'collectionViews?.DefaultView.payloadComponent',
)
ErrorView = createMappedComponent(
collectionViews?.ErrorView?.payloadComponent,
undefined,
collectionViews?.ErrorView?.Component,
'collectionViews?.ErrorView.payloadComponent',
)
}
if (!CustomView && !DefaultView && !ViewOverride && !ErrorView) {
ErrorView = NotFoundView
ErrorView = createMappedComponent(undefined, undefined, NotFoundView, 'NotFoundView')
}
}
@@ -133,7 +181,7 @@ export const Document: React.FC<AdminViewProps> = async ({
apiURL = `${serverURL}${apiRoute}/${globalSlug}${apiQueryParams}`
const editConfig = globalConfig?.admin?.components?.views?.Edit
const editConfig = globalConfig?.admin?.components?.views?.edit
ViewOverride = typeof editConfig === 'function' ? editConfig : null
if (!ViewOverride) {
@@ -144,12 +192,29 @@ export const Document: React.FC<AdminViewProps> = async ({
routeSegments: segments,
})
CustomView = globalViews?.CustomView
DefaultView = globalViews?.DefaultView
ErrorView = globalViews?.ErrorView
CustomView = createMappedComponent(
globalViews?.CustomView?.payloadComponent,
undefined,
globalViews?.CustomView?.Component,
'globalViews?.CustomView.payloadComponent',
)
DefaultView = createMappedComponent(
globalViews?.DefaultView?.payloadComponent,
undefined,
globalViews?.DefaultView?.Component,
'globalViews?.DefaultView.payloadComponent',
)
ErrorView = createMappedComponent(
globalViews?.ErrorView?.payloadComponent,
undefined,
globalViews?.ErrorView?.Component,
'globalViews?.ErrorView.payloadComponent',
)
if (!CustomView && !DefaultView && !ViewOverride && !ErrorView) {
ErrorView = NotFoundView
ErrorView = createMappedComponent(undefined, undefined, NotFoundView, 'NotFoundView')
}
}
}
@@ -206,9 +271,9 @@ export const Document: React.FC<AdminViewProps> = async ({
{!ViewOverride && (
<DocumentHeader
collectionConfig={collectionConfig}
config={payload.config}
globalConfig={globalConfig}
i18n={i18n}
payload={payload}
permissions={permissions}
/>
)}
@@ -226,22 +291,10 @@ export const Document: React.FC<AdminViewProps> = async ({
key={`${collectionSlug || globalSlug}${locale?.code ? `-${locale?.code}` : ''}`}
>
{ErrorView ? (
<ErrorView initPageResult={initPageResult} searchParams={searchParams} />
<RenderComponent mappedComponent={ErrorView} />
) : (
<RenderCustomComponent
CustomComponent={ViewOverride || CustomView}
DefaultComponent={DefaultView}
serverOnlyProps={{
i18n,
initPageResult,
locale,
params,
payload,
permissions,
routeSegments: segments,
searchParams,
user,
}}
<RenderComponent
mappedComponent={ViewOverride ? ViewOverride : CustomView ? CustomView : DefaultView}
/>
)}
</EditDepthProvider>

View File

@@ -1,13 +1,11 @@
'use client'
import type { FieldMap, PayloadRequest } from 'payload'
import type { PayloadRequest, TextFieldClient } from 'payload'
import { getTranslation } from '@payloadcms/translations'
import {
CopyToClipboard,
FieldDescription,
FieldLabel,
GenerateConfirmation,
useComponentMap,
useConfig,
useDocumentInfo,
useField,
@@ -29,17 +27,16 @@ export const APIKey: React.FC<{ readonly enabled: boolean; readonly readOnly?: b
const [initialAPIKey] = useState(uuidv4())
const [highlightedField, setHighlightedField] = useState(false)
const { i18n, t } = useTranslation()
const config = useConfig()
const apiKey = useFormFields(([fields]) => (fields && fields[path]) || null)
const { config } = useConfig()
const { collectionSlug, docPermissions } = useDocumentInfo()
const { getFieldMap } = useComponentMap()
const apiKey = useFormFields(([fields]) => (fields && fields[path]) || null)
const fieldMap: FieldMap = getFieldMap({
collectionSlug,
})
const apiKeyField = fieldMap.find((field) => 'name' in field && field.name === 'apiKey')
const apiKeyField: TextFieldClient = config.collections
.find((collection) => {
return collection.slug === collectionSlug
})
?.fields?.find((field) => 'name' in field && field.name === 'apiKey') as TextFieldClient
const validate = (val) =>
text(val, {
@@ -54,7 +51,7 @@ export const APIKey: React.FC<{ readonly enabled: boolean; readonly readOnly?: b
config,
},
t,
} as PayloadRequest,
} as unknown as PayloadRequest,
siblingData: {},
})
@@ -63,21 +60,22 @@ export const APIKey: React.FC<{ readonly enabled: boolean; readonly readOnly?: b
const apiKeyLabel = useMemo(() => {
let label: Record<string, string> | string = 'API Key'
if (apiKeyField && apiKeyField.fieldComponentProps.label) {
label = apiKeyField.fieldComponentProps.label
if (apiKeyField?.label) {
label = apiKeyField.label
}
return getTranslation(label, i18n)
}, [apiKeyField, i18n])
const APIKeyLabelComponent = useMemo(() => {
return (
const APIKeyLabel = useMemo(
() => (
<div className={`${baseClass}__label`}>
<span>{apiKeyLabel}</span>
<CopyToClipboard value={apiKeyValue as string} />
</div>
)
}, [apiKeyLabel, apiKeyValue])
),
[apiKeyLabel, apiKeyValue],
)
const canUpdateAPIKey = useMemo(() => {
if (docPermissions && docPermissions?.fields?.apiKey) {
@@ -125,7 +123,14 @@ export const APIKey: React.FC<{ readonly enabled: boolean; readonly readOnly?: b
return (
<React.Fragment>
<div className={[fieldBaseClass, 'api-key', 'read-only'].filter(Boolean).join(' ')}>
<FieldLabel CustomLabel={APIKeyLabelComponent} htmlFor={path} />
<FieldLabel
Label={{
type: 'client',
Component: null,
RenderedComponent: APIKeyLabel,
}}
htmlFor={path}
/>
<input
aria-label={apiKeyLabel}
className={highlightedField ? 'highlight' : undefined}

View File

@@ -50,8 +50,10 @@ export const Auth: React.FC<Props> = (props) => {
const { docPermissions, isInitializing } = useDocumentInfo()
const {
routes: { api },
serverURL,
config: {
routes: { api },
serverURL,
},
} = useConfig()
const hasPermissionToUnlock: boolean = useMemo(() => {
@@ -147,11 +149,14 @@ export const Auth: React.FC<Props> = (props) => {
{(showPasswordFields || requirePassword) && (
<div className={`${baseClass}__changing-password`}>
<PasswordField
disabled={disabled}
label={t('authentication:newPassword')}
name="password"
path="password"
required
field={{
name: 'password',
admin: {
disabled,
},
label: t('authentication:newPassword'),
required: true,
}}
/>
<ConfirmPasswordField disabled={readOnly} />
</div>
@@ -194,20 +199,22 @@ export const Auth: React.FC<Props> = (props) => {
{useAPIKey && (
<div className={`${baseClass}__api-key`}>
<CheckboxField
disabled={disabled}
label={t('authentication:enableAPIKey')}
name="enableAPIKey"
readOnly={readOnly}
field={{
name: 'enableAPIKey',
admin: { disabled, readOnly },
label: t('authentication:enableAPIKey'),
}}
/>
<APIKey enabled={!!enableAPIKey?.value} readOnly={readOnly} />
</div>
)}
{verify && (
<CheckboxField
disabled={disabled}
label={t('authentication:verified')}
name="_verified"
readOnly={readOnly}
field={{
name: '_verified',
admin: { disabled, readOnly },
label: t('authentication:verified'),
}}
/>
)}
</div>

View File

@@ -36,7 +36,9 @@ export const SetDocumentStepNav: React.FC<{
const { i18n, t } = useTranslation()
const {
routes: { admin: adminRoute },
config: {
routes: { admin: adminRoute },
},
} = useConfig()
const drawerDepth = useEditDepth()

View File

@@ -1,14 +1,16 @@
'use client'
import type { ClientCollectionConfig, ClientGlobalConfig } from 'payload'
import {
DocumentControls,
DocumentFields,
Form,
type FormProps,
OperationProvider,
RenderComponent,
Upload,
useAuth,
useComponentMap,
useConfig,
useDocumentEvents,
useDocumentInfo,
@@ -56,9 +58,18 @@ export const DefaultEditView: React.FC = () => {
} = useDocumentInfo()
const { refreshCookieAsync, user } = useAuth()
const config = useConfig()
const {
config,
config: {
admin: { user: userSlug },
routes: { admin: adminRoute, api: apiRoute },
serverURL,
},
getEntityConfig,
} = useConfig()
const router = useRouter()
const { getComponentMap, getFieldMap } = useComponentMap()
const depth = useEditDepth()
const params = useSearchParams()
const { reportUpdate } = useDocumentEvents()
@@ -66,30 +77,12 @@ export const DefaultEditView: React.FC = () => {
const locale = params.get('locale')
const {
admin: { user: userSlug },
collections,
globals,
routes: { admin: adminRoute, api: apiRoute },
serverURL,
} = config
const collectionConfig = getEntityConfig({ collectionSlug }) as ClientCollectionConfig
const collectionConfig =
collectionSlug && collections.find((collection) => collection.slug === collectionSlug)
const globalConfig = globalSlug && globals.find((global) => global.slug === globalSlug)
const globalConfig = getEntityConfig({ globalSlug }) as ClientGlobalConfig
const entitySlug = collectionConfig?.slug || globalConfig?.slug
const componentMap = getComponentMap({
collectionSlug: collectionConfig?.slug,
globalSlug: globalConfig?.slug,
})
const fieldMap = getFieldMap({
collectionSlug: collectionConfig?.slug,
globalSlug: globalConfig?.slug,
})
const operation = collectionSlug && !id ? 'create' : 'update'
const auth = collectionConfig ? collectionConfig.auth : undefined
@@ -253,8 +246,10 @@ export const DefaultEditView: React.FC = () => {
)}
{upload && (
<React.Fragment>
{componentMap.Upload !== undefined ? (
componentMap.Upload
{collectionConfig?.admin?.components?.edit?.Upload ? (
<RenderComponent
mappedComponent={collectionConfig.admin.components.edit.Upload}
/>
) : (
<Upload
collectionSlug={collectionConfig.slug}
@@ -268,7 +263,7 @@ export const DefaultEditView: React.FC = () => {
)
}
docPermissions={docPermissions}
fieldMap={fieldMap}
fields={(collectionConfig || globalConfig)?.fields}
readOnly={!hasSavePermission}
schemaPath={schemaPath}
/>

View File

@@ -1,17 +1,20 @@
'use client'
import { SetViewActions, useComponentMap, useDocumentInfo } from '@payloadcms/ui'
import type { ClientCollectionConfig, ClientGlobalConfig } from 'payload'
import { RenderComponent, SetViewActions, useConfig, useDocumentInfo } from '@payloadcms/ui'
import React, { Fragment } from 'react'
export const EditViewClient: React.FC = () => {
const { collectionSlug, globalSlug } = useDocumentInfo()
const { getComponentMap } = useComponentMap()
const { getEntityConfig } = useConfig()
const { Edit, actionsMap } = getComponentMap({
collectionSlug,
globalSlug,
})
const collectionConfig = getEntityConfig({ collectionSlug }) as ClientCollectionConfig
const globalConfig = getEntityConfig({ globalSlug }) as ClientGlobalConfig
const Edit = (collectionConfig || globalConfig)?.admin?.components?.views?.edit?.default
?.Component
if (!Edit) {
return null
@@ -19,8 +22,12 @@ export const EditViewClient: React.FC = () => {
return (
<Fragment>
<SetViewActions actions={actionsMap?.Edit?.Default} />
{Edit}
<SetViewActions
actions={
(collectionConfig || globalConfig)?.admin?.components?.views?.edit?.default?.actions
}
/>
<RenderComponent mappedComponent={Edit} />
</Fragment>
)
}

View File

@@ -1,9 +1,9 @@
import type { EditViewComponent } from 'payload'
import type { EditViewComponent, PayloadServerReactComponent } from 'payload'
import React from 'react'
import { EditViewClient } from './index.client.js'
export const EditView: EditViewComponent = () => {
export const EditView: PayloadServerReactComponent<EditViewComponent> = () => {
return <EditViewClient />
}

View File

@@ -8,7 +8,7 @@ import { email, text } from 'payload/shared'
import React, { Fragment, useState } from 'react'
export const ForgotPasswordForm: React.FC = () => {
const config = useConfig()
const { config } = useConfig()
const {
admin: { user: userSlug },
@@ -77,9 +77,11 @@ export const ForgotPasswordForm: React.FC = () => {
{loginWithUsername ? (
<TextField
label={t('authentication:username')}
name="username"
required
field={{
name: 'username',
label: t('authentication:username'),
required: true,
}}
validate={(value) =>
text(value, {
name: 'username',
@@ -91,7 +93,7 @@ export const ForgotPasswordForm: React.FC = () => {
config,
},
t,
} as PayloadRequest,
} as unknown as PayloadRequest,
required: true,
siblingData: {},
})
@@ -100,9 +102,11 @@ export const ForgotPasswordForm: React.FC = () => {
) : (
<EmailField
autoComplete="email"
label={t('general:email')}
name="email"
required
field={{
name: 'email',
label: t('general:email'),
required: true,
}}
validate={(value) =>
email(value, {
name: 'email',

View File

@@ -1,7 +1,6 @@
/* eslint-disable @typescript-eslint/no-misused-promises */
'use client'
import type { CollectionComponentMap } from '@payloadcms/ui/utilities/buildComponentMap'
import type { ClientCollectionConfig } from 'payload'
import { getTranslation } from '@payloadcms/translations'
import {
@@ -16,12 +15,13 @@ import {
Pill,
PublishMany,
RelationshipProvider,
RenderComponent,
SelectionProvider,
SetViewActions,
StaggeredShimmers,
Table,
UnpublishMany,
useComponentMap,
ViewDescription,
useConfig,
useEditDepth,
useListInfo,
@@ -45,27 +45,27 @@ export const DefaultListView: React.FC = () => {
const { data, defaultLimit, handlePageChange, handlePerPageChange } = useListQuery()
const { searchParams } = useSearchParams()
const config = useConfig()
const { getEntityConfig } = useConfig()
const { getComponentMap } = useComponentMap()
const componentMap = getComponentMap({ collectionSlug }) as CollectionComponentMap
const collectionConfig = getEntityConfig({ collectionSlug }) as ClientCollectionConfig
const {
AfterList,
AfterListTable,
BeforeList,
BeforeListTable,
Description,
actionsMap,
fieldMap,
} = componentMap || {}
const collectionConfig = config.collections.find(
(collection) => collection.slug === collectionSlug,
)
const { labels } = collectionConfig
admin: {
components: {
Description,
afterList,
afterListTable,
beforeList,
beforeListTable,
views: {
list: { actions },
},
},
description,
},
fields,
labels,
} = collectionConfig
const { i18n } = useTranslation()
@@ -100,8 +100,8 @@ export const DefaultListView: React.FC = () => {
return (
<div className={`${baseClass} ${baseClass}--${collectionSlug}`}>
<SetViewActions actions={actionsMap?.List} />
{BeforeList}
<SetViewActions actions={actions} />
<RenderComponent mappedComponent={beforeList} />
<SelectionProvider docs={data.docs} totalDocs={data.totalDocs}>
<Gutter className={`${baseClass}__wrap`}>
<header className={`${baseClass}__header`}>
@@ -121,14 +121,20 @@ export const DefaultListView: React.FC = () => {
{!smallBreak && (
<ListSelection label={getTranslation(collectionConfig.labels.plural, i18n)} />
)}
{Description ? (
<div className={`${baseClass}__sub-header`}>{Description}</div>
) : null}
{description && (
<div className={`${baseClass}__sub-header`}>
<RenderComponent
Component={ViewDescription}
clientProps={{ description }}
mappedComponent={Description}
/>
</div>
)}
</Fragment>
)}
</header>
<ListControls collectionConfig={collectionConfig} fieldMap={fieldMap} />
{BeforeListTable}
<ListControls collectionConfig={collectionConfig} fields={fields} />
<RenderComponent mappedComponent={beforeListTable} />
{!data.docs && (
<StaggeredShimmers
className={[`${baseClass}__shimmer`, `${baseClass}__shimmer--rows`].join(' ')}
@@ -143,7 +149,7 @@ export const DefaultListView: React.FC = () => {
uploadConfig: collectionConfig.upload,
}}
data={docs}
fieldMap={fieldMap}
fields={fields}
/>
</RelationshipProvider>
)}
@@ -159,7 +165,7 @@ export const DefaultListView: React.FC = () => {
)}
</div>
)}
{AfterListTable}
<RenderComponent mappedComponent={afterListTable} />
{data.docs && data.docs.length > 0 && (
<div className={`${baseClass}__page-controls`}>
<Pagination
@@ -194,7 +200,7 @@ export const DefaultListView: React.FC = () => {
<div className={`${baseClass}__list-selection`}>
<ListSelection label={getTranslation(collectionConfig.labels.plural, i18n)} />
<div className={`${baseClass}__list-selection-actions`}>
<EditMany collection={collectionConfig} fieldMap={fieldMap} />
<EditMany collection={collectionConfig} fields={fields} />
<PublishMany collection={collectionConfig} />
<UnpublishMany collection={collectionConfig} />
<DeleteMany collection={collectionConfig} />
@@ -207,7 +213,7 @@ export const DefaultListView: React.FC = () => {
)}
</Gutter>
</SelectionProvider>
{AfterList}
<RenderComponent mappedComponent={afterList} />
</div>
)
}

View File

@@ -1,4 +1,4 @@
import type { AdminViewProps, Where } from 'payload'
import type { AdminViewProps, ClientCollectionConfig, Where } from 'payload'
import {
HydrateAuthProvider,
@@ -6,14 +6,16 @@ import {
ListQueryProvider,
TableColumnsProvider,
} from '@payloadcms/ui'
import { RenderCustomComponent, formatAdminURL } from '@payloadcms/ui/shared'
import { RenderComponent, formatAdminURL, getCreateMappedComponent } from '@payloadcms/ui/shared'
import { createClientCollectionConfig } from '@payloadcms/ui/utilities/createClientConfig'
import { notFound } from 'next/navigation.js'
import { createClientCollectionConfig, mergeListSearchAndWhere } from 'payload'
import { isNumber, isReactComponentOrFunction } from 'payload/shared'
import { deepCopyObjectSimple, mergeListSearchAndWhere } from 'payload'
import { isNumber } from 'payload/shared'
import React, { Fragment } from 'react'
import type { DefaultListViewProps, ListPreferences } from './Default/types.js'
import type { ListPreferences } from './Default/types.js'
import { DefaultEditView } from '../Edit/Default/index.js'
import { DefaultListView } from './Default/index.js'
export { generateListMetadata } from './meta.js'
@@ -84,22 +86,10 @@ export const ListView: React.FC<AdminViewProps> = async ({
} = config
if (collectionConfig) {
const {
admin: { components: { views: { List: CustomList } = {} } = {} },
} = collectionConfig
if (!visibleEntities.collections.includes(collectionSlug)) {
return notFound()
}
let CustomListView = null
if (CustomList && typeof CustomList === 'function') {
CustomListView = CustomList
} else if (typeof CustomList === 'object' && isReactComponentOrFunction(CustomList.Component)) {
CustomListView = CustomList.Component
}
const page = isNumber(query?.page) ? Number(query.page) : 0
const whereQuery = mergeListSearchAndWhere({
collectionConfig,
@@ -131,19 +121,56 @@ export const ListView: React.FC<AdminViewProps> = async ({
where: whereQuery || {},
})
const viewComponentProps: DefaultListViewProps = {
collectionSlug,
listSearchableFields: collectionConfig.admin.listSearchableFields,
}
const createMappedComponent = getCreateMappedComponent({
importMap: payload.importMap,
serverProps: {
collectionConfig,
collectionSlug,
data,
hasCreatePermission: permissions?.collections?.[collectionSlug]?.create?.permission,
i18n,
limit,
listPreferences,
listSearchableFields: collectionConfig.admin.listSearchableFields,
locale: fullLocale,
newDocumentURL: formatAdminURL({
adminRoute,
path: `/collections/${collectionSlug}/create`,
}),
params,
payload,
permissions,
searchParams,
user,
},
})
const ListComponent = createMappedComponent(
collectionConfig?.admin?.components?.views?.list?.Component,
undefined,
DefaultListView,
'collectionConfig?.admin?.components?.views?.list?.Component',
)
let clientCollectionConfig = deepCopyObjectSimple(
collectionConfig,
) as unknown as ClientCollectionConfig
clientCollectionConfig = createClientCollectionConfig({
DefaultEditView,
DefaultListView,
clientCollection: clientCollectionConfig,
collection: collectionConfig,
createMappedComponent,
i18n,
importMap: payload.importMap,
payload,
})
return (
<Fragment>
<HydrateAuthProvider permissions={permissions} />
<ListInfoProvider
collectionConfig={createClientCollectionConfig({
collection: collectionConfig,
t: initPageResult.req.i18n.t,
})}
collectionConfig={clientCollectionConfig}
collectionSlug={collectionSlug}
hasCreatePermission={permissions?.collections?.[collectionSlug]?.create?.permission}
newDocumentURL={formatAdminURL({
@@ -164,29 +191,12 @@ export const ListView: React.FC<AdminViewProps> = async ({
listPreferences={listPreferences}
preferenceKey={preferenceKey}
>
<RenderCustomComponent
CustomComponent={CustomListView}
DefaultComponent={DefaultListView}
componentProps={viewComponentProps}
serverOnlyProps={{
collectionConfig,
data,
hasCreatePermission:
permissions?.collections?.[collectionSlug]?.create?.permission,
i18n,
limit,
listPreferences,
locale: fullLocale,
newDocumentURL: formatAdminURL({
adminRoute,
path: `/collections/${collectionSlug}/create`,
}),
params,
payload,
permissions,
searchParams,
user,
<RenderComponent
clientProps={{
collectionSlug,
listSearchableFields: collectionConfig?.admin?.listSearchableFields,
}}
mappedComponent={ListComponent}
/>
</TableColumnsProvider>
</ListQueryProvider>

View File

@@ -28,6 +28,6 @@ export const generateListMetadata = async (
keywords,
serverURL: config.serverURL,
title,
...(collectionConfig.admin.meta || {}),
...(collectionConfig?.admin?.meta || {}),
})
}

View File

@@ -20,7 +20,7 @@ export interface LivePreviewContextType {
width: number
}
openPopupWindow: ReturnType<typeof usePopupWindow>['openPopupWindow']
popupRef?: React.MutableRefObject<Window | null>
popupRef?: React.RefObject<Window | null>
previewWindowType: 'iframe' | 'popup'
setAppIsReady: (appIsReady: boolean) => void
setBreakpoint: (breakpoint: LivePreviewConfig['breakpoints'][number]['name']) => void

View File

@@ -1,5 +1,5 @@
'use client'
import type { ClientFieldConfig, LivePreviewConfig } from 'payload'
import type { ClientField, LivePreviewConfig } from 'payload'
import { DndContext } from '@dnd-kit/core'
import { fieldSchemaToJSON } from 'payload/shared'
@@ -19,10 +19,10 @@ export type LivePreviewProviderProps = {
height: number
width: number
}
fieldSchema: ClientFieldConfig[]
fieldSchema: ClientField[]
isPopupOpen?: boolean
openPopupWindow?: ReturnType<typeof usePopupWindow>['openPopupWindow']
popupRef?: React.MutableRefObject<Window>
popupRef?: React.RefObject<Window>
url?: string
}

View File

@@ -3,9 +3,9 @@ import type { FormProps } from '@payloadcms/ui'
import type {
ClientCollectionConfig,
ClientConfig,
ClientField,
ClientGlobalConfig,
Data,
FieldMap,
LivePreviewConfig,
} from 'payload'
@@ -16,7 +16,6 @@ import {
OperationProvider,
SetViewActions,
useAuth,
useComponentMap,
useConfig,
useDocumentEvents,
useDocumentInfo,
@@ -37,20 +36,20 @@ import { usePopupWindow } from './usePopupWindow.js'
const baseClass = 'live-preview'
type Props = {
apiRoute: string
collectionConfig?: ClientCollectionConfig
config: ClientConfig
fieldMap: FieldMap
globalConfig?: ClientGlobalConfig
schemaPath: string
serverURL: string
readonly apiRoute: string
readonly collectionConfig?: ClientCollectionConfig
readonly config: ClientConfig
readonly fields: ClientField[]
readonly globalConfig?: ClientGlobalConfig
readonly schemaPath: string
readonly serverURL: string
}
const PreviewView: React.FC<Props> = ({
apiRoute,
collectionConfig,
config,
fieldMap,
fields,
globalConfig,
schemaPath,
serverURL,
@@ -81,7 +80,9 @@ const PreviewView: React.FC<Props> = ({
const operation = id ? 'update' : 'create'
const {
admin: { user: userSlug },
config: {
admin: { user: userSlug },
},
} = useConfig()
const { t } = useTranslation()
const { previewWindowType } = useLivePreviewContext()
@@ -132,125 +133,115 @@ const PreviewView: React.FC<Props> = ({
)
return (
<Fragment>
<OperationProvider operation={operation}>
<Form
action={action}
className={`${baseClass}__form`}
disabled={!hasSavePermission}
initialState={initialState}
isInitializing={isInitializing}
method={id ? 'PATCH' : 'POST'}
onChange={[onChange]}
onSuccess={onSave}
<OperationProvider operation={operation}>
<Form
action={action}
className={`${baseClass}__form`}
disabled={!hasSavePermission}
initialState={initialState}
isInitializing={isInitializing}
method={id ? 'PATCH' : 'POST'}
onChange={[onChange]}
onSuccess={onSave}
>
{((collectionConfig &&
!(collectionConfig.versions?.drafts && collectionConfig.versions?.drafts?.autosave)) ||
(globalConfig &&
!(globalConfig.versions?.drafts && globalConfig.versions?.drafts?.autosave))) &&
!disableLeaveWithoutSaving && <LeaveWithoutSaving />}
<SetDocumentStepNav
collectionSlug={collectionSlug}
globalLabel={globalConfig?.label}
globalSlug={globalSlug}
id={id}
pluralLabel={collectionConfig ? collectionConfig?.labels?.plural : undefined}
useAsTitle={collectionConfig ? collectionConfig?.admin?.useAsTitle : undefined}
view={t('general:livePreview')}
/>
<SetDocumentTitle
collectionConfig={collectionConfig}
config={config}
fallback={id?.toString() || ''}
globalConfig={globalConfig}
/>
<DocumentControls
apiURL={apiURL}
data={initialData}
disableActions={disableActions}
hasPublishPermission={hasPublishPermission}
hasSavePermission={hasSavePermission}
id={id}
isEditing={isEditing}
permissions={docPermissions}
slug={collectionConfig?.slug || globalConfig?.slug}
/>
<div
className={[baseClass, previewWindowType === 'popup' && `${baseClass}--detached`]
.filter(Boolean)
.join(' ')}
>
{((collectionConfig &&
!(collectionConfig.versions?.drafts && collectionConfig.versions?.drafts?.autosave)) ||
(globalConfig &&
!(globalConfig.versions?.drafts && globalConfig.versions?.drafts?.autosave))) &&
!disableLeaveWithoutSaving && <LeaveWithoutSaving />}
<SetDocumentStepNav
collectionSlug={collectionSlug}
globalLabel={globalConfig?.label}
globalSlug={globalSlug}
id={id}
pluralLabel={collectionConfig ? collectionConfig?.labels?.plural : undefined}
useAsTitle={collectionConfig ? collectionConfig?.admin?.useAsTitle : undefined}
view={t('general:livePreview')}
/>
<SetDocumentTitle
collectionConfig={collectionConfig}
config={config}
fallback={id?.toString() || ''}
globalConfig={globalConfig}
/>
<DocumentControls
apiURL={apiURL}
data={initialData}
disableActions={disableActions}
hasPublishPermission={hasPublishPermission}
hasSavePermission={hasSavePermission}
id={id}
isEditing={isEditing}
permissions={docPermissions}
slug={collectionConfig?.slug || globalConfig?.slug}
/>
<div
className={[baseClass, previewWindowType === 'popup' && `${baseClass}--detached`]
className={[
`${baseClass}__main`,
previewWindowType === 'popup' && `${baseClass}__main--popup-open`,
]
.filter(Boolean)
.join(' ')}
>
<div
className={[
`${baseClass}__main`,
previewWindowType === 'popup' && `${baseClass}__main--popup-open`,
]
.filter(Boolean)
.join(' ')}
>
{BeforeDocument}
<DocumentFields
AfterFields={AfterFields}
BeforeFields={BeforeFields}
docPermissions={docPermissions}
fieldMap={fieldMap}
forceSidebarWrap
readOnly={!hasSavePermission}
schemaPath={collectionSlug || globalSlug}
/>
{AfterDocument}
</div>
<LivePreview collectionSlug={collectionSlug} globalSlug={globalSlug} />
{BeforeDocument}
<DocumentFields
AfterFields={AfterFields}
BeforeFields={BeforeFields}
docPermissions={docPermissions}
fields={fields}
forceSidebarWrap
readOnly={!hasSavePermission}
schemaPath={collectionSlug || globalSlug}
/>
{AfterDocument}
</div>
</Form>
</OperationProvider>
</Fragment>
<LivePreview collectionSlug={collectionSlug} globalSlug={globalSlug} />
</div>
</Form>
</OperationProvider>
)
}
export const LivePreviewClient: React.FC<{
breakpoints: LivePreviewConfig['breakpoints']
initialData: Data
url: string
readonly breakpoints: LivePreviewConfig['breakpoints']
readonly initialData: Data
readonly url: string
}> = (props) => {
const { breakpoints, url } = props
const { collectionSlug, globalSlug } = useDocumentInfo()
const config = useConfig()
const {
config,
config: {
routes: { api: apiRoute },
serverURL,
},
getEntityConfig,
} = useConfig()
const { isPopupOpen, openPopupWindow, popupRef } = usePopupWindow({
eventType: 'payload-live-preview',
url,
})
const {
collections,
globals,
routes: { api: apiRoute },
serverURL,
} = config
const collectionConfig = getEntityConfig({ collectionSlug }) as ClientCollectionConfig
const collectionConfig =
collectionSlug && collections.find((collection) => collection.slug === collectionSlug)
const globalConfig = globalSlug && globals.find((global) => global.slug === globalSlug)
const globalConfig = getEntityConfig({ globalSlug }) as ClientGlobalConfig
const schemaPath = collectionSlug || globalSlug
const { getComponentMap } = useComponentMap()
const componentMap = getComponentMap({ collectionSlug, globalSlug })
const { getFieldMap } = useComponentMap()
const fieldMap = getFieldMap({
collectionSlug: collectionConfig?.slug,
globalSlug: globalConfig?.slug,
})
return (
<Fragment>
<SetViewActions actions={componentMap?.actionsMap?.Edit?.LivePreview} />
<SetViewActions
actions={
(collectionConfig || globalConfig)?.admin?.components?.views?.edit?.livePreview?.actions
}
/>
<LivePreviewProvider
breakpoints={breakpoints}
fieldSchema={collectionConfig?.fields || globalConfig?.fields}
@@ -263,7 +254,7 @@ export const LivePreviewClient: React.FC<{
apiRoute={apiRoute}
collectionConfig={collectionConfig}
config={config}
fieldMap={fieldMap}
fields={(collectionConfig || globalConfig)?.fields}
globalConfig={globalConfig}
schemaPath={schemaPath}
serverURL={serverURL}

View File

@@ -1,4 +1,9 @@
import type { EditViewComponent, LivePreviewConfig, TypeWithID } from 'payload'
import type {
EditViewComponent,
LivePreviewConfig,
PayloadServerReactComponent,
TypeWithID,
} from 'payload'
import { notFound } from 'next/navigation.js'
import React from 'react'
@@ -6,7 +11,7 @@ import React from 'react'
import { LivePreviewClient } from './index.client.js'
import './index.scss'
export const LivePreviewView: EditViewComponent = async (props) => {
export const LivePreviewView: PayloadServerReactComponent<EditViewComponent> = async (props) => {
const { initPageResult } = props
const {

View File

@@ -1,4 +1,6 @@
'use client'
import type React from 'react'
import { useConfig } from '@payloadcms/ui'
import { useCallback, useEffect, useRef, useState } from 'react'
@@ -20,12 +22,14 @@ export const usePopupWindow = (props: {
}): {
isPopupOpen: boolean
openPopupWindow: () => void
popupRef?: React.MutableRefObject<Window | null>
popupRef?: React.RefObject<Window | null>
} => {
const { eventType, onMessage, url } = props
const isReceivingMessage = useRef(false)
const [isOpen, setIsOpen] = useState(false)
const { serverURL } = useConfig()
const {
config: { serverURL },
} = useConfig()
const popupRef = useRef<Window | null>(null)
// Optionally broadcast messages back out to the parent component

View File

@@ -4,11 +4,13 @@ import type { Validate, ValidateOptions } from 'payload'
import { EmailField, TextField, useTranslation } from '@payloadcms/ui'
import { email, username } from 'payload/shared'
import React from 'react'
export type LoginFieldProps = {
required?: boolean
type: 'email' | 'emailOrUsername' | 'username'
validate?: Validate
readonly required?: boolean
readonly type: 'email' | 'emailOrUsername' | 'username'
readonly validate?: Validate
}
export const LoginField: React.FC<LoginFieldProps> = ({ type, required = true }) => {
const { t } = useTranslation()
@@ -16,10 +18,11 @@ export const LoginField: React.FC<LoginFieldProps> = ({ type, required = true })
return (
<EmailField
autoComplete="email"
label={t('general:email')}
name="email"
path="email"
required={required}
field={{
name: 'email',
label: t('general:email'),
required,
}}
validate={email}
/>
)
@@ -28,10 +31,11 @@ export const LoginField: React.FC<LoginFieldProps> = ({ type, required = true })
if (type === 'username') {
return (
<TextField
label={t('authentication:username')}
name="username"
path="username"
required={required}
field={{
name: 'username',
label: t('authentication:username'),
required,
}}
validate={username}
/>
)
@@ -40,10 +44,11 @@ export const LoginField: React.FC<LoginFieldProps> = ({ type, required = true })
if (type === 'emailOrUsername') {
return (
<TextField
label={t('authentication:emailOrUsername')}
name="username"
path="username"
required={required}
field={{
name: 'username',
label: t('authentication:emailOrUsername'),
required,
}}
validate={(value, options) => {
const passesUsername = username(value, options)
const passesEmail = email(

View File

@@ -22,7 +22,7 @@ export const LoginForm: React.FC<{
prefillUsername?: string
searchParams: { [key: string]: string | string[] | undefined }
}> = ({ prefillEmail, prefillPassword, prefillUsername, searchParams }) => {
const config = useConfig()
const { config } = useConfig()
const {
admin: {
@@ -81,7 +81,13 @@ export const LoginForm: React.FC<{
>
<div className={`${baseClass}__inputWrap`}>
<LoginField type={loginType} />
<PasswordField label={t('general:password')} name="password" required />
<PasswordField
field={{
name: 'password',
label: t('general:password'),
required: true,
}}
/>
</div>
<Link
href={formatAdminURL({

View File

@@ -1,6 +1,6 @@
import type { AdminViewProps } from 'payload'
import { WithServerSideProps } from '@payloadcms/ui/shared'
import { RenderComponent, getCreateMappedComponent } from '@payloadcms/ui/shared'
import { redirect } from 'next/navigation.js'
import React, { Fragment } from 'react'
@@ -28,41 +28,22 @@ export const LoginView: React.FC<AdminViewProps> = ({ initPageResult, params, se
routes: { admin },
} = config
const BeforeLogins = Array.isArray(beforeLogin)
? beforeLogin.map((Component, i) => (
<WithServerSideProps
Component={Component}
key={i}
serverOnlyProps={{
i18n,
locale,
params,
payload,
permissions,
searchParams,
user,
}}
/>
))
: null
const createMappedComponent = getCreateMappedComponent({
importMap: payload.importMap,
serverProps: {
i18n,
locale,
params,
payload,
permissions,
searchParams,
user,
},
})
const AfterLogins = Array.isArray(afterLogin)
? afterLogin.map((Component, i) => (
<WithServerSideProps
Component={Component}
key={i}
serverOnlyProps={{
i18n,
locale,
params,
payload,
permissions,
searchParams,
user,
}}
/>
))
: null
const mappedBeforeLogins = createMappedComponent(beforeLogin, undefined, undefined, 'beforeLogin')
const mappedAfterLogins = createMappedComponent(afterLogin, undefined, undefined, 'afterLogin')
if (user) {
redirect(admin)
@@ -101,7 +82,7 @@ export const LoginView: React.FC<AdminViewProps> = ({ initPageResult, params, se
user={user}
/>
</div>
{Array.isArray(BeforeLogins) && BeforeLogins.map((Component) => Component)}
<RenderComponent mappedComponent={mappedBeforeLogins} />
{!collectionConfig?.auth?.disableLocalStrategy && (
<LoginForm
prefillEmail={prefillEmail}
@@ -110,7 +91,7 @@ export const LoginView: React.FC<AdminViewProps> = ({ initPageResult, params, se
searchParams={searchParams}
/>
)}
{Array.isArray(AfterLogins) && AfterLogins.map((Component) => Component)}
<RenderComponent mappedComponent={mappedAfterLogins} />
</Fragment>
)
}

View File

@@ -18,7 +18,9 @@ export const NotFoundClient: React.FC<{
const { t } = useTranslation()
const {
routes: { admin: adminRoute },
config: {
routes: { admin: adminRoute },
},
} = useConfig()
useEffect(() => {

View File

@@ -1,6 +1,11 @@
import type { I18n } from '@payloadcms/translations'
import type { Metadata } from 'next'
import type { AdminViewComponent, SanitizedConfig } from 'payload'
import type {
AdminViewComponent,
ImportMap,
PayloadServerReactComponent,
SanitizedConfig,
} from 'payload'
import { HydrateAuthProvider } from '@payloadcms/ui'
import { formatAdminURL } from '@payloadcms/ui/shared'
@@ -36,10 +41,12 @@ export type GenerateViewMetadata = (args: {
export const NotFoundPage = async ({
config: configPromise,
importMap,
params,
searchParams,
}: {
config: Promise<SanitizedConfig>
importMap: ImportMap
params: {
segments: string[]
}
@@ -52,6 +59,7 @@ export const NotFoundPage = async ({
const initPageResult = await initPage({
config,
importMap,
redirectUnauthenticatedUser: true,
route: formatAdminURL({ adminRoute, path: '/not-found' }),
searchParams,
@@ -73,6 +81,6 @@ export const NotFoundPage = async ({
)
}
export const NotFoundView: AdminViewComponent = () => {
export const NotFoundView: PayloadServerReactComponent<AdminViewComponent> = () => {
return <NotFoundClient marginTop="large" />
}

View File

@@ -17,7 +17,7 @@ import React from 'react'
import { toast } from 'sonner'
type Args = {
token: string
readonly token: string
}
const initialState: FormState = {
@@ -36,12 +36,14 @@ const initialState: FormState = {
export const ResetPasswordClient: React.FC<Args> = ({ token }) => {
const i18n = useTranslation()
const {
admin: {
routes: { login: loginRoute },
user: userSlug,
config: {
admin: {
routes: { login: loginRoute },
user: userSlug,
},
routes: { admin: adminRoute, api: apiRoute },
serverURL,
},
routes: { admin: adminRoute, api: apiRoute },
serverURL,
} = useConfig()
const history = useRouter()
@@ -74,13 +76,20 @@ export const ResetPasswordClient: React.FC<Args> = ({ token }) => {
onSuccess={onSuccess}
>
<PasswordField
label={i18n.t('authentication:newPassword')}
name="password"
path="password"
required
field={{
name: 'password',
label: i18n.t('authentication:newPassword'),
required: true,
}}
/>
<ConfirmPasswordField />
<HiddenField forceUsePathFromProps name="token" value={token} />
<HiddenField
field={{
name: 'token',
}}
forceUsePathFromProps
value={token}
/>
<FormSubmit size="large">{i18n.t('authentication:resetPassword')}</FormSubmit>
</Form>
)

View File

@@ -1,4 +1,6 @@
import type { AdminViewComponent, SanitizedConfig } from 'payload'
import type { SanitizedConfig } from 'payload'
import type { ViewFromConfig } from './getViewFromConfig.js'
import { isPathMatchingRoute } from './isPathMatchingRoute.js'
@@ -8,7 +10,7 @@ export const getCustomViewByRoute = ({
}: {
config: SanitizedConfig
currentRoute: string
}): AdminViewComponent => {
}): ViewFromConfig => {
const {
admin: {
components: { views },
@@ -22,16 +24,20 @@ export const getCustomViewByRoute = ({
views &&
typeof views === 'object' &&
Object.entries(views).find(([, view]) => {
if (typeof view === 'object') {
return isPathMatchingRoute({
currentRoute,
exact: view.exact,
path: view.path,
sensitive: view.sensitive,
strict: view.strict,
})
}
return isPathMatchingRoute({
currentRoute,
exact: view.exact,
path: view.path,
sensitive: view.sensitive,
strict: view.strict,
})
})?.[1]
return typeof foundViewConfig === 'object' ? foundViewConfig.Component : null
if (!foundViewConfig) {
return null
}
return {
payloadComponent: foundViewConfig.Component,
}
}

View File

@@ -1,4 +1,5 @@
import type { AdminViewComponent, SanitizedConfig } from 'payload'
import type { AdminViewComponent, AdminViewProps, ImportMap, SanitizedConfig } from 'payload'
import type React from 'react'
import { formatAdminURL } from '@payloadcms/ui/shared'
@@ -27,7 +28,12 @@ const baseClasses = {
}
type OneSegmentViews = {
[K in Exclude<keyof SanitizedConfig['admin']['routes'], 'reset'>]: AdminViewComponent
[K in Exclude<keyof SanitizedConfig['admin']['routes'], 'reset'>]: React.FC<AdminViewProps>
}
export type ViewFromConfig = {
Component?: React.FC<AdminViewProps>
payloadComponent?: AdminViewComponent
}
const oneSegmentViews: OneSegmentViews = {
@@ -44,28 +50,31 @@ export const getViewFromConfig = ({
adminRoute,
config,
currentRoute,
importMap,
searchParams,
segments,
}: {
adminRoute
adminRoute: string
config: SanitizedConfig
currentRoute: string
importMap: ImportMap
searchParams: {
[key: string]: string | string[]
}
segments: string[]
}): {
DefaultView: AdminViewComponent
DefaultView: ViewFromConfig
initPageOptions: Parameters<typeof initPage>[0]
templateClassName: string
templateType: 'default' | 'minimal'
} => {
let ViewToRender: AdminViewComponent = null
let ViewToRender: ViewFromConfig = null
let templateClassName: string
let templateType: 'default' | 'minimal' | undefined
const initPageOptions: Parameters<typeof initPage>[0] = {
config,
importMap,
route: currentRoute,
searchParams,
}
@@ -78,7 +87,9 @@ export const getViewFromConfig = ({
switch (segments.length) {
case 0: {
if (currentRoute === adminRoute) {
ViewToRender = Dashboard
ViewToRender = {
Component: Dashboard,
}
templateClassName = 'dashboard'
templateType = 'default'
initPageOptions.redirectUnauthenticatedUser = true
@@ -113,7 +124,9 @@ export const getViewFromConfig = ({
// --> /logout-inactivity
// --> /unauthorized
ViewToRender = oneSegmentViews[viewToRender]
ViewToRender = {
Component: oneSegmentViews[viewToRender],
}
templateClassName = baseClasses[viewToRender]
templateType = 'minimal'
@@ -127,7 +140,9 @@ export const getViewFromConfig = ({
case 2: {
if (segmentOne === 'reset') {
// --> /reset/:token
ViewToRender = ResetPassword
ViewToRender = {
Component: ResetPassword,
}
templateClassName = baseClasses[segmentTwo]
templateType = 'minimal'
}
@@ -135,13 +150,17 @@ export const getViewFromConfig = ({
if (isCollection) {
// --> /collections/:collectionSlug
initPageOptions.redirectUnauthenticatedUser = true
ViewToRender = ListView
ViewToRender = {
Component: ListView,
}
templateClassName = `${segmentTwo}-list`
templateType = 'default'
} else if (isGlobal) {
// --> /globals/:globalSlug
initPageOptions.redirectUnauthenticatedUser = true
ViewToRender = DocumentView
ViewToRender = {
Component: DocumentView,
}
templateClassName = 'global-edit'
templateType = 'default'
}
@@ -150,7 +169,9 @@ export const getViewFromConfig = ({
default:
if (segmentTwo === 'verify') {
// --> /:collectionSlug/verify/:token
ViewToRender = Verify
ViewToRender = {
Component: Verify,
}
templateClassName = 'verify'
templateType = 'minimal'
} else if (isCollection) {
@@ -161,7 +182,9 @@ export const getViewFromConfig = ({
// --> /collections/:collectionSlug/:id/versions/:versionId
// --> /collections/:collectionSlug/:id/api
initPageOptions.redirectUnauthenticatedUser = true
ViewToRender = DocumentView
ViewToRender = {
Component: DocumentView,
}
templateClassName = `collection-default-edit`
templateType = 'default'
} else if (isGlobal) {
@@ -171,7 +194,9 @@ export const getViewFromConfig = ({
// --> /globals/:globalSlug/versions/:versionId
// --> /globals/:globalSlug/api
initPageOptions.redirectUnauthenticatedUser = true
ViewToRender = DocumentView
ViewToRender = {
Component: DocumentView,
}
templateClassName = `global-edit`
templateType = 'default'
}

View File

@@ -1,8 +1,8 @@
import type { I18nClient } from '@payloadcms/translations'
import type { Metadata } from 'next'
import type { SanitizedConfig } from 'payload'
import type { ImportMap, MappedComponent, SanitizedConfig } from 'payload'
import { WithServerSideProps, formatAdminURL } from '@payloadcms/ui/shared'
import { RenderComponent, formatAdminURL, getCreateMappedComponent } from '@payloadcms/ui/shared'
import { notFound, redirect } from 'next/navigation.js'
import React, { Fragment } from 'react'
@@ -22,14 +22,16 @@ export type GenerateViewMetadata = (args: {
export const RootPage = async ({
config: configPromise,
importMap,
params,
searchParams,
}: {
config: Promise<SanitizedConfig>
params: {
readonly config: Promise<SanitizedConfig>
readonly importMap: ImportMap
readonly params: {
segments: string[]
}
searchParams: {
readonly searchParams: {
[key: string]: string | string[]
}
}) => {
@@ -54,13 +56,14 @@ export const RootPage = async ({
adminRoute,
config,
currentRoute,
importMap,
searchParams,
segments,
})
let dbHasUser = false
if (!DefaultView) {
if (!DefaultView?.Component && !DefaultView?.payloadComponent) {
notFound()
}
@@ -92,19 +95,27 @@ export const RootPage = async ({
}
}
const RenderedView = (
<WithServerSideProps
Component={DefaultView}
serverOnlyProps={
{
initPageResult,
params,
searchParams,
} as any
}
/>
const createMappedView = getCreateMappedComponent({
importMap,
serverProps: {
i18n: initPageResult?.req.i18n,
importMap,
initPageResult,
params,
payload: initPageResult?.req.payload,
searchParams,
},
})
const MappedView: MappedComponent = createMappedView(
DefaultView.payloadComponent,
undefined,
DefaultView.Component,
'createMappedView',
)
const RenderedView = <RenderComponent mappedComponent={MappedView} />
return (
<Fragment>
{!templateType && <Fragment>{RenderedView}</Fragment>}

View File

@@ -1,4 +1,4 @@
import type { AdminViewComponent } from 'payload'
import type { AdminViewComponent, PayloadServerReactComponent } from 'payload'
import { Button, Gutter } from '@payloadcms/ui'
import LinkImport from 'next/link.js'
@@ -12,7 +12,9 @@ export { generateUnauthorizedMetadata } from './meta.js'
const baseClass = 'unauthorized'
export const UnauthorizedView: AdminViewComponent = ({ initPageResult }) => {
export const UnauthorizedView: PayloadServerReactComponent<AdminViewComponent> = ({
initPageResult,
}) => {
const {
req: {
i18n,

View File

@@ -1,22 +1,23 @@
import type { StepNavItem } from '@payloadcms/ui'
import type { ClientCollectionConfig, ClientGlobalConfig, FieldMap } from 'payload'
import type { ClientCollectionConfig, ClientField, ClientGlobalConfig } from 'payload'
import type React from 'react'
import { getTranslation } from '@payloadcms/translations'
import { useConfig, useLocale, useStepNav, useTranslation } from '@payloadcms/ui'
import { formatAdminURL, formatDate } from '@payloadcms/ui/shared'
import { fieldAffectsData } from 'payload/shared'
import { useEffect } from 'react'
export const SetStepNav: React.FC<{
collectionConfig?: ClientCollectionConfig
collectionSlug?: string
doc: any
fieldMap: FieldMap
globalConfig?: ClientGlobalConfig
globalSlug?: string
id?: number | string
}> = ({ id, collectionConfig, collectionSlug, doc, fieldMap, globalConfig, globalSlug }) => {
const config = useConfig()
readonly collectionConfig?: ClientCollectionConfig
readonly collectionSlug?: string
readonly doc: any
readonly fields: ClientField[]
readonly globalConfig?: ClientGlobalConfig
readonly globalSlug?: string
readonly id?: number | string
}> = ({ id, collectionConfig, collectionSlug, doc, fields, globalConfig, globalSlug }) => {
const { config } = useConfig()
const { setStepNav } = useStepNav()
const { i18n, t } = useTranslation()
const locale = useLocale()
@@ -38,14 +39,13 @@ export const SetStepNav: React.FC<{
if (formattedDoc) {
if (useAsTitle !== 'id') {
const titleField = fieldMap.find((f) => {
const { isFieldAffectingData } = f
const titleField = fields.find((f) => {
const fieldName = 'name' in f ? f.name : undefined
return Boolean(isFieldAffectingData && fieldName === useAsTitle)
return Boolean(fieldAffectsData(f) && fieldName === useAsTitle)
})
if (titleField && formattedDoc[useAsTitle]) {
if (titleField.localized) {
if ('localized' in titleField && titleField.localized) {
docLabel = formattedDoc[useAsTitle]?.[locale.code]
} else {
docLabel = formattedDoc[useAsTitle]
@@ -118,7 +118,7 @@ export const SetStepNav: React.FC<{
t,
i18n,
collectionConfig,
fieldMap,
fields,
globalConfig,
])

View File

@@ -1,10 +1,9 @@
'use client'
import type { OptionObject } from 'payload'
import type { ClientCollectionConfig, ClientGlobalConfig, OptionObject } from 'payload'
import {
Gutter,
SetViewActions,
useComponentMap,
useConfig,
useDocumentInfo,
usePayloadAPI,
@@ -15,7 +14,7 @@ import React, { useState } from 'react'
import type { CompareOption, DefaultVersionsViewProps } from './types.js'
import diffComponents from '../RenderFieldsToDiff/fields/index.js'
import { diffComponents } from '../RenderFieldsToDiff/fields/index.js'
import RenderFieldsToDiff from '../RenderFieldsToDiff/index.js'
import Restore from '../Restore/index.js'
import { SelectComparison } from '../SelectComparison/index.js'
@@ -34,22 +33,16 @@ export const DefaultVersionView: React.FC<DefaultVersionsViewProps> = ({
localeOptions,
versionID,
}) => {
const config = useConfig()
const { config, getEntityConfig } = useConfig()
const { i18n } = useTranslation()
const { id, collectionSlug, globalSlug } = useDocumentInfo()
const { getComponentMap, getFieldMap } = useComponentMap()
const componentMap = getComponentMap({ collectionSlug, globalSlug })
const [fieldMap] = useState(() => getFieldMap({ collectionSlug, globalSlug }))
const [collectionConfig] = useState(() =>
config.collections.find((collection) => collection.slug === collectionSlug),
const [collectionConfig] = useState(
() => getEntityConfig({ collectionSlug }) as ClientCollectionConfig,
)
const [globalConfig] = useState(() => config.globals.find((global) => global.slug === globalSlug))
const [globalConfig] = useState(() => getEntityConfig({ globalSlug }) as ClientGlobalConfig)
const [locales, setLocales] = useState<OptionObject[]>(localeOptions)
@@ -85,12 +78,16 @@ export const DefaultVersionView: React.FC<DefaultVersionsViewProps> = ({
return (
<main className={baseClass}>
<SetViewActions actions={componentMap?.actionsMap?.Edit?.Version} />
<SetViewActions
actions={
(collectionConfig || globalConfig)?.admin?.components?.views?.edit?.version?.actions
}
/>
<SetStepNav
collectionConfig={collectionConfig}
collectionSlug={collectionSlug}
doc={doc}
fieldMap={fieldMap}
fields={(collectionConfig || globalConfig)?.fields}
globalConfig={globalConfig}
globalSlug={globalSlug}
id={id}
@@ -136,8 +133,8 @@ export const DefaultVersionView: React.FC<DefaultVersionsViewProps> = ({
<RenderFieldsToDiff
comparison={comparison}
diffComponents={diffComponents}
fieldMap={fieldMap}
fieldPermissions={docPermissions?.fields}
fields={(collectionConfig || globalConfig)?.fields}
i18n={i18n}
locales={localeValues}
version={

View File

@@ -8,11 +8,11 @@ export type CompareOption = {
}
export type DefaultVersionsViewProps = {
doc: Document
docPermissions: CollectionPermission | GlobalPermission
initialComparisonDoc: Document
latestDraftVersion?: string
latestPublishedVersion?: string
localeOptions: OptionObject[]
versionID?: string
readonly doc: Document
readonly docPermissions: CollectionPermission | GlobalPermission
readonly initialComparisonDoc: Document
readonly latestDraftVersion?: string
readonly latestPublishedVersion?: string
readonly localeOptions: OptionObject[]
readonly versionID?: string
}

View File

@@ -1,10 +1,10 @@
import type { MappedField } from 'payload'
import type { ClientField } from 'payload'
import { getTranslation } from '@payloadcms/translations'
import { getUniqueListBy } from 'payload/shared'
import React from 'react'
import type { Props } from '../types.js'
import type { DiffComponentProps } from '../types.js'
import Label from '../../Label/index.js'
import RenderFieldsToDiff from '../../index.js'
@@ -12,7 +12,7 @@ import './index.scss'
const baseClass = 'iterable-diff'
const Iterable: React.FC<Props> = ({
const Iterable: React.FC<DiffComponentProps> = ({
comparison,
diffComponents,
field,
@@ -28,27 +28,24 @@ const Iterable: React.FC<Props> = ({
return (
<div className={baseClass}>
{'label' in field.fieldComponentProps &&
field.fieldComponentProps.label &&
typeof field.fieldComponentProps.label !== 'function' && (
<Label>
{locale && <span className={`${baseClass}__locale-label`}>{locale}</span>}
{getTranslation(field.fieldComponentProps.label, i18n)}
</Label>
)}
{'label' in field && field.label && typeof field.label !== 'function' && (
<Label>
{locale && <span className={`${baseClass}__locale-label`}>{locale}</span>}
{getTranslation(field.label, i18n)}
</Label>
)}
{maxRows > 0 && (
<React.Fragment>
{Array.from(Array(maxRows).keys()).map((row, i) => {
const versionRow = version?.[i] || {}
const comparisonRow = comparison?.[i] || {}
let fieldMap: MappedField[] = []
let fields: ClientField[] = []
if (field.type === 'array' && 'fieldMap' in field.fieldComponentProps)
fieldMap = field.fieldComponentProps.fieldMap
if (field.type === 'array' && 'fields' in field) fields = field.fields
if (field.type === 'blocks') {
fieldMap = [
fields = [
// {
// name: 'blockType',
// label: i18n.t('fields:blockType'),
@@ -57,35 +54,25 @@ const Iterable: React.FC<Props> = ({
]
if (versionRow?.blockType === comparisonRow?.blockType) {
const matchedBlock = ('blocks' in field.fieldComponentProps &&
field.fieldComponentProps.blocks?.find(
(block) => block.slug === versionRow?.blockType,
)) || {
fieldMap: [],
const matchedBlock = ('blocks' in field &&
field.blocks?.find((block) => block.slug === versionRow?.blockType)) || {
fields: [],
}
fieldMap = [...fieldMap, ...matchedBlock.fieldMap]
fields = [...fields, ...matchedBlock.fields]
} else {
const matchedVersionBlock = ('blocks' in field.fieldComponentProps &&
field.fieldComponentProps.blocks?.find(
(block) => block.slug === versionRow?.blockType,
)) || {
fieldMap: [],
const matchedVersionBlock = ('blocks' in field &&
field.blocks?.find((block) => block.slug === versionRow?.blockType)) || {
fields: [],
}
const matchedComparisonBlock = ('blocks' in field.fieldComponentProps &&
field.fieldComponentProps.blocks?.find(
(block) => block.slug === comparisonRow?.blockType,
)) || {
fieldMap: [],
const matchedComparisonBlock = ('blocks' in field &&
field.blocks?.find((block) => block.slug === comparisonRow?.blockType)) || {
fields: [],
}
fieldMap = getUniqueListBy<MappedField>(
[
...fieldMap,
...matchedVersionBlock.fieldMap,
...matchedComparisonBlock.fieldMap,
],
fields = getUniqueListBy<ClientField>(
[...fields, ...matchedVersionBlock.fields, ...matchedComparisonBlock.fields],
'name',
)
}
@@ -96,8 +83,8 @@ const Iterable: React.FC<Props> = ({
<RenderFieldsToDiff
comparison={comparisonRow}
diffComponents={diffComponents}
fieldMap={fieldMap}
fieldPermissions={permissions}
fields={fields}
i18n={i18n}
locales={locales}
version={versionRow}
@@ -111,8 +98,8 @@ const Iterable: React.FC<Props> = ({
<div className={`${baseClass}__no-rows`}>
{i18n.t('version:noRowsFound', {
label:
'labels' in field.fieldComponentProps && field.fieldComponentProps.labels?.plural
? getTranslation(field.fieldComponentProps.labels.plural, i18n)
'labels' in field && field.labels?.plural
? getTranslation(field.labels.plural, i18n)
: i18n.t('general:rows'),
})}
</div>

Some files were not shown because too many files have changed in this diff Show More