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/runConfigurations
!/.idea/payload.iml !/.idea/payload.iml
test-results test-results
.devcontainer .devcontainer
.localstack .localstack
@@ -300,3 +301,8 @@ $RECYCLE.BIN/
/build /build
.swc .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/live-preview/dist" />
<excludeFolder url="file://$MODULE_DIR$/packages/next/.swc" /> <excludeFolder url="file://$MODULE_DIR$/packages/next/.swc" />
<excludeFolder url="file://$MODULE_DIR$/packages/next/.turbo" /> <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/payload/fields" />
<excludeFolder url="file://$MODULE_DIR$/packages/plugin-cloud-storage/.turbo" /> <excludeFolder url="file://$MODULE_DIR$/packages/plugin-cloud-storage/.turbo" />
<excludeFolder url="file://$MODULE_DIR$/packages/plugin-cloud-storage/dist" /> <excludeFolder url="file://$MODULE_DIR$/packages/plugin-cloud-storage/dist" />

View File

@@ -1,8 +1,13 @@
<component name="ProjectRunConfigurationManager"> <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$"> <configuration default="false" name="Run Dev Fields" type="js.build_tools.npm">
<envs> <package-json value="$PROJECT_DIR$/package.json" />
<env name="NODE_OPTIONS" value="--no-deprecation" /> <command value="run" />
</envs> <scripts>
<script value="dev" />
</scripts>
<arguments value="fields" />
<node-interpreter value="project" />
<envs />
<method v="2" /> <method v="2" />
</configuration> </configuration>
</component> </component>

View File

@@ -1,8 +1,13 @@
<component name="ProjectRunConfigurationManager"> <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$"> <configuration default="false" name="Run Dev _community" type="js.build_tools.npm">
<envs> <package-json value="$PROJECT_DIR$/package.json" />
<env name="NODE_OPTIONS" value="--no-deprecation" /> <command value="run" />
</envs> <scripts>
<script value="dev" />
</scripts>
<arguments value="_community" />
<node-interpreter value="project" />
<envs />
<method v="2" /> <method v="2" />
</configuration> </configuration>
</component> </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"> <component name="ProjectRunConfigurationManager">
<configuration default="true" type="JavaScriptTestRunnerJest"> <configuration default="true" type="JavaScriptTestRunnerJest">
<node-interpreter value="project" /> <node-interpreter value="project" />
<node-options value="--experimental-vm-modules --no-deprecation" /> <node-options value="--no-deprecation" />
<envs /> <envs />
<scope-kind value="ALL" /> <scope-kind value="ALL" />
<method v="2" /> <method v="2" />

View File

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

View File

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

View File

@@ -1,6 +1,9 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
import configPromise from '@payload-config' import configPromise from '@payload-config'
import { RootLayout } from '@payloadcms/next/layouts' 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` // 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. */ /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import React from 'react' import React from 'react'
@@ -11,6 +14,10 @@ type Args = {
children: React.ReactNode 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 export default Layout

View File

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

View File

@@ -33,8 +33,6 @@ To override Root Components, use the `admin.components` property in your [Payloa
```ts ```ts
import { buildConfig } from 'payload' import { buildConfig } from 'payload'
import { MyCustomLogo } from './MyCustomLogo'
export default buildConfig({ export default buildConfig({
// ... // ...
admin: { admin: {
@@ -81,13 +79,11 @@ To add a Custom Provider, use the `admin.components.providers` property in your
```ts ```ts
import { buildConfig } from 'payload' import { buildConfig } from 'payload'
import { MyProvider } from './MyProvider'
export default buildConfig({ export default buildConfig({
// ... // ...
admin: { admin: {
components: { components: {
providers: [MyProvider], // highlight-line providers: ['/path/to/MyProvider'], // highlight-line
}, },
}, },
}) })
@@ -207,7 +203,7 @@ import React from 'react'
import { useConfig } from '@payloadcms/ui' import { useConfig } from '@payloadcms/ui'
export const MyClientComponent: React.FC = () => { export const MyClientComponent: React.FC = () => {
const { serverURL } = useConfig() // highlight-line const { config: { serverURL } } = useConfig() // highlight-line
return ( return (
<Link href={serverURL}> <Link href={serverURL}>
@@ -221,6 +217,22 @@ export const MyClientComponent: React.FC = () => {
See [Using Hooks](#using-hooks) for more details. See [Using Hooks](#using-hooks) for more details.
</Banner> </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 ### 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: 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: { admin: {
components: { components: {
Field: MyFieldComponent, // highlight-line Field: '/path/to/MyFieldComponent', // highlight-line
}, },
}, },
} }
@@ -135,32 +135,12 @@ All Field Components receive the following props:
| Property | Description | | Property | Description |
| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`AfterInput`** | The rendered result of the `admin.components.afterInput` property. [More details](#afterinput-and-beforeinput). | | **`docPreferences`** | An object that contains the [Preferences](./preferences) for the document.
| **`BeforeInput`** | The rendered result of the `admin.components.beforeInput` property. [More details](#afterinput-and-beforeinput). | | **`field`** | The sanitized, client-friendly version of the field's config. [More details](#the-field-prop) |
| **`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. |
| **`locale`** | The locale of the field. [More details](../configuration/localization). | | **`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. | | **`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). | | **`user`** | The currently authenticated user. [More details](../authentication/overview). |
| **`validate`** | A function that can be used to validate the field. | | **`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"> <Banner type="success">
<strong>Reminder:</strong> <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). 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> </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
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. 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', type: 'text',
admin: { admin: {
components: { components: {
Cell: MyCustomCell, // highlight-line Cell: '/path/to/MyCustomCellComponent', // highlight-line
}, },
}, },
} }
@@ -219,20 +292,9 @@ All Cell Components receive the following props:
| Property | Description | | Property | Description |
| ---------------- | ----------------------------------------------------------------- | | ---------------- | ----------------------------------------------------------------- |
| **`name`** | The name of the field. | | **`field`** | The sanitized, client-friendly version of the field's config. [More details](#the-field-prop) |
| **`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. |
| **`link`** | A boolean representing whether this cell should be wrapped in a link. | | **`link`** | A boolean representing whether this cell should be wrapped in a link. |
| **`onClick`** | A function that is called when the cell is clicked. | | **`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"> <Banner type="info">
<strong>Tip:</strong> <strong>Tip:</strong>
@@ -258,7 +320,7 @@ export const myField: Field = {
type: 'text', type: 'text',
admin: { admin: {
components: { 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 | | 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"> <Banner type="success">
<strong>Reminder:</strong> <strong>Reminder:</strong>
@@ -279,7 +341,7 @@ Custom Label Components receive all [Field Component](#the-field-component) prop
#### TypeScript #### 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 ```tsx
import type { import type {
@@ -321,7 +383,7 @@ export const myField: Field = {
type: 'text', type: 'text',
admin: { admin: {
components: { 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 | | 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"> <Banner type="success">
<strong>Reminder:</strong> <strong>Reminder:</strong>
@@ -443,7 +505,6 @@ To easily add a Description Component to a field, use the `admin.components.Desc
```ts ```ts
import type { SanitizedCollectionConfig } from 'payload' import type { SanitizedCollectionConfig } from 'payload'
import { MyCustomDescription } from './MyCustomDescription'
export const MyCollectionConfig: SanitizedCollectionConfig = { export const MyCollectionConfig: SanitizedCollectionConfig = {
// ... // ...
@@ -454,7 +515,7 @@ export const MyCollectionConfig: SanitizedCollectionConfig = {
type: 'text', type: 'text',
admin: { admin: {
components: { 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 | | 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"> <Banner type="success">
<strong>Reminder:</strong> <strong>Reminder:</strong>
@@ -524,8 +585,8 @@ export const MyCollectionConfig: SanitizedCollectionConfig = {
admin: { admin: {
components: { components: {
// highlight-start // highlight-start
beforeInput: [MyCustomComponent], beforeInput: ['/path/to/MyCustomComponent'],
afterInput: [MyOtherCustomComponent], afterInput: ['/path/to/MyOtherCustomComponent'],
// highlight-end // highlight-end
} }
} }

View File

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

View File

@@ -52,7 +52,7 @@ The `useField` hook accepts the following arguments:
The `useField` hook returns the following object: The `useField` hook returns the following object:
```ts ```ts
type FieldResult<T> = { type FieldType<T> = {
errorMessage?: string errorMessage?: string
errorPaths?: string[] errorPaths?: string[]
filterOptions?: FilterOptionsResult filterOptions?: FilterOptionsResult
@@ -65,7 +65,7 @@ type FieldResult<T> = {
readOnly?: boolean readOnly?: boolean
rows?: Row[] rows?: Row[]
schemaPath: string schemaPath: string
setValue: (val: unknown, disableModifyingForm?: boolean) => voi setValue: (val: unknown, disableModifyingForm?: boolean) => void
showError: boolean showError: boolean
valid?: boolean valid?: boolean
value: T value: T
@@ -463,7 +463,7 @@ export const CustomArrayManager = () => {
name: "customArrayManager", name: "customArrayManager",
admin: { admin: {
components: { components: {
Field: CustomArrayManager, Field: '/path/to/CustomArrayManagerField',
}, },
}, },
}, },
@@ -560,7 +560,7 @@ export const CustomArrayManager = () => {
name: "customArrayManager", name: "customArrayManager",
admin: { admin: {
components: { components: {
Field: CustomArrayManager, Field: '/path/to/CustomArrayManagerField',
}, },
}, },
}, },
@@ -670,7 +670,7 @@ export const CustomArrayManager = () => {
name: "customArrayManager", name: "customArrayManager",
admin: { admin: {
components: { components: {
Field: CustomArrayManager, Field: '/path/to/CustomArrayManagerField',
}, },
}, },
}, },
@@ -818,7 +818,7 @@ import { useConfig } from '@payloadcms/ui'
const MyComponent: React.FC = () => { const MyComponent: React.FC = () => {
// highlight-start // highlight-start
const config = useConfig() const { config } = useConfig()
// highlight-end // highlight-end
return <span>{config.serverURL}</span> 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. */ /* 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 ## Admin Options
All options for the Admin Panel are defined in your [Payload Config](../configuration/overview) under the `admin` property: All options for the Admin Panel are defined in your [Payload Config](../configuration/overview) under the `admin` property:
@@ -168,11 +290,11 @@ const config = buildConfig({
The following options are available: The following options are available:
| Option | Default route | Description | | Option | Default route | Description |
| ------------------ | ----------------------- | ------------------------------------- | |---------------------|-----------------------|---------------------------------------------------|
| `admin` | `/admin` | The Admin Panel itself. | | `admin` | `/admin` | The Admin Panel itself. |
| `api` | `/api` | The [REST API](../rest-api/overview) base path. | | `api` | `/api` | The [REST API](../rest-api/overview) base path. |
| `graphQL` | `/graphql` | The [GraphQL API](../graphql/overview) base path. | | `graphQL` | `/graphql` | The [GraphQL API](../graphql/overview) base path. |
| `graphQLPlayground`| `/graphql-playground` | The GraphQL Playground. | | `graphQLPlayground` | `/graphql-playground` | The GraphQL Playground. |
<Banner type="success"> <Banner type="success">
<strong>Tip:</strong> <strong>Tip:</strong>

View File

@@ -31,7 +31,9 @@ const config = buildConfig({
admin: { admin: {
components: { components: {
views: { views: {
Dashboard: MyCustomDashboardView, // highlight-line dashboard: {
Component: '/path/to/MyCustomDashboardView#MyCustomDashboardViewComponent', // highlight-line
}
}, },
}, },
}, },
@@ -44,8 +46,8 @@ The following options are available:
| Property | Description | | Property | Description |
| --------------- | ----------------------------------------------------------------------------- | | --------------- | ----------------------------------------------------------------------------- |
| **`Account`** | The Account view is used to show the currently logged in user's Account page. | | **`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). | | **`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: 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: { components: {
views: { views: {
// highlight-start // highlight-start
MyCustomView: { myCustomView: {
// highlight-end // highlight-end
Component: MyCustomView, Component: '/path/to/MyCustomView#MyCustomViewComponent',
path: '/my-custom-view', path: '/my-custom-view',
}, },
}, },
@@ -108,7 +110,9 @@ export const MyCollectionConfig: SanitizedCollectionConfig = {
admin: { admin: {
components: { components: {
views: { views: {
Edit: MyCustomEditView, // highlight-line edit: {
Component: '/path/to/MyCustomEditView', // highlight-line
}
}, },
}, },
}, },
@@ -126,8 +130,8 @@ The following options are available:
| Property | Description | | Property | Description |
| ---------- | ----------------------------------------------------------------------------------------------------------------- | | ---------- | ----------------------------------------------------------------------------------------------------------------- |
| **`Edit`** | The Edit View is used to edit a single document for any given Collection. [More details](#document-views). | | **`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. | | **`list`** | The List View is used to show a list of documents for any given Collection. |
<Banner type="success"> <Banner type="success">
<strong>Note:</strong> <strong>Note:</strong>
@@ -148,7 +152,7 @@ export const MyGlobalConfig: SanitizedGlobalConfig = {
admin: { admin: {
components: { components: {
views: { views: {
Edit: MyCustomEditView, // highlight-line edit: '/path/to/MyCustomEditView', // highlight-line
}, },
}, },
}, },
@@ -166,7 +170,7 @@ The following options are available:
| Property | Description | | 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"> <Banner type="success">
<strong>Note:</strong> <strong>Note:</strong>
@@ -187,9 +191,9 @@ export const MyCollectionOrGlobalConfig: SanitizedCollectionConfig = {
admin: { admin: {
components: { components: {
views: { views: {
Edit: { edit: {
API: { api: {
Component: MyCustomAPIView, // highlight-line Component: '/path/to/MyCustomAPIViewComponent', // highlight-line
}, },
}, },
}, },
@@ -209,15 +213,15 @@ The following options are available:
| Property | Description | | Property | Description |
| ----------------- | --------------------------------------------------------------------------------------------------------------------------- | | ----------------- | --------------------------------------------------------------------------------------------------------------------------- |
| **`Default`** | The Default view is the primary view in which your document is edited. | | **`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). | | **`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). | | **`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. | | **`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). | | **`livePreview`** | The LivePreview view is used to display the Live Preview interface. [More details](../live-preview). |
### Document Tabs ### 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 ```ts
import type { SanitizedCollectionConfig } from 'payload' import type { SanitizedCollectionConfig } from 'payload'
@@ -227,17 +231,19 @@ export const MyCollection: SanitizedCollectionConfig = {
admin: { admin: {
components: { components: {
views: { views: {
Edit: { edit: {
MyCustomTab: { myCustomTab: {
Component: MyCustomTab, Component: '/path/to/MyCustomTab',
path: '/my-custom-tab', path: '/my-custom-tab',
Tab: MyCustomTab // highlight-line tab: {
Component: '/path/to/MyCustomTabComponent' // highlight-line
}
}, },
AnotherCustomView: { anotherCustomTab: {
Component: AnotherCustomView, Component: '/path/to/AnotherCustomView',
path: '/another-custom-view', path: '/another-custom-view',
// highlight-start // highlight-start
Tab: { tab: {
label: 'Another Custom View', label: 'Another Custom View',
href: '/another-custom-view', href: '/another-custom-view',
} }
@@ -261,14 +267,15 @@ Custom Views are just [Custom Components](./components) rendered at the page-lev
```ts ```ts
import type { SanitizedCollectionConfig } from 'payload' import type { SanitizedCollectionConfig } from 'payload'
import { MyCustomView } from './MyCustomView'
export const MyCollectionConfig: SanitizedCollectionConfig = { export const MyCollectionConfig: SanitizedCollectionConfig = {
// ... // ...
admin: { admin: {
components: { components: {
views: { 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. 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 ```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. 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 ```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. 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 ```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 title: Using Payload outside Next.js
label: Outside Next.js label: Outside Next.js
order: 20 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 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> <Banner>
<strong>Note:</strong> <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 ## 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. 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.
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.
```ts ```ts
// We are importing `getPayload` because we don't need HMR // We are importing `getPayload` because we don't need HMR
// for a standalone script. For usage of Payload inside Next.js, // for a standalone script. For usage of Payload inside Next.js,
// you should always use `import { getPayloadHMR } from '@payloadcms/next/utilities'` instead. // you should always use `import { getPayloadHMR } from '@payloadcms/next/utilities'` instead.
import { getPayload } from 'payload' import { getPayload } from 'payload'
import config from '@payload-config'
// 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'),
})
const seed = async () => { const seed = async () => {
// Get a local copy of Payload by passing your config // 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 // 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, useAllFormFields,
useAuth, useAuth,
useClientFunctions, useClientFunctions,
useComponentMap,
useConfig, useConfig,
useDebounce, useDebounce,
useDebouncedCallback, useDebouncedCallback,
@@ -212,7 +211,6 @@ import {
ActionsProvider, ActionsProvider,
AuthProvider, AuthProvider,
ClientFunctionProvider, ClientFunctionProvider,
ComponentMapProvider,
ConfigProvider, ConfigProvider,
DocumentEventsProvider, DocumentEventsProvider,
DocumentInfoProvider, DocumentInfoProvider,
@@ -299,14 +297,10 @@ import {
fieldBaseClass, fieldBaseClass,
// TS Types // TS Types
ActionMap,
CollectionComponentMap,
ColumnPreferences, ColumnPreferences,
ConfigComponentMapBase,
DocumentInfoContext, DocumentInfoContext,
DocumentInfoProps, DocumentInfoProps,
FieldType, FieldType,
FieldComponentProps,
FormProps, FormProps,
RowLabelProps, RowLabelProps,
SelectFieldProps, SelectFieldProps,
@@ -323,7 +317,6 @@ import {
AppHeader, AppHeader,
BlocksDrawer, BlocksDrawer,
Column, Column,
ComponentMap,
DefaultBlockImage, DefaultBlockImage,
DeleteMany, DeleteMany,
DocumentControls, DocumentControls,
@@ -338,7 +331,6 @@ import {
FormLoadingOverlayToggle, FormLoadingOverlayToggle,
FormSubmit, FormSubmit,
GenerateConfirmation, GenerateConfirmation,
GlobalComponentMap,
HydrateClientUser, HydrateClientUser,
ListControls, ListControls,
ListSelection, ListSelection,
@@ -349,7 +341,8 @@ import {
PublishMany, PublishMany,
ReactSelect, ReactSelect,
ReactSelectOption, ReactSelectOption,
ReducedBlock, ClientField,
ClientBlock,
RenderFields, RenderFields,
SectionTitle, SectionTitle,
Select, Select,

View File

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

View File

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

View File

@@ -29,6 +29,6 @@
"build:server": "tsc", "build:server": "tsc",
"build": "yarn build:server && yarn build:payload", "build": "yarn build:server && yarn build:payload",
"serve": "PAYLOAD_CONFIG_PATH=dist/payload.config.js NODE_ENV=production node dist/server.js", "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} */ /** @type {import('jest').Config} */
const baseJestConfig = { const baseJestConfig = {
extensionsToTreatAsEsm: ['.ts', '.tsx'], extensionsToTreatAsEsm: ['.ts', '.tsx'],
setupFiles: ['<rootDir>/test/jest.setup.env.js'],
setupFilesAfterEnv: ['<rootDir>/test/jest.setup.js'], setupFilesAfterEnv: ['<rootDir>/test/jest.setup.js'],
transformIgnorePatterns: [
`/node_modules/(?!.pnpm)(?!(${esModules})/)`,
`/node_modules/.pnpm/(?!(${esModules.replace(/\//g, '\\+')})@)`,
],
moduleNameMapper: { moduleNameMapper: {
'\\.(css|scss)$': '<rootDir>/test/helpers/mocks/emptyModule.js', '\\.(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)$': '\\.(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 bundleAnalyzer from '@next/bundle-analyzer'
import withPayload from './packages/next/src/withPayload.js' 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({ const withBundleAnalyzer = bundleAnalyzer({
enabled: process.env.ANALYZE === 'true', enabled: process.env.ANALYZE === 'true',
@@ -15,6 +20,10 @@ export default withBundleAnalyzer(
typescript: { typescript: {
ignoreBuildErrors: true, ignoreBuildErrors: true,
}, },
env: {
PAYLOAD_CORE_DEV: 'true',
ROOT_DIR: path.resolve(dirname),
},
async redirects() { async redirects() {
return [ return [
{ {

View File

@@ -20,6 +20,7 @@
"build:email-nodemailer": "turbo build --filter email-nodemailer", "build:email-nodemailer": "turbo build --filter email-nodemailer",
"build:email-resend": "turbo build --filter email-resend", "build:email-resend": "turbo build --filter email-resend",
"build:eslint-config": "turbo build --filter eslint-config", "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:force": "pnpm run build:core:force",
"build:graphql": "turbo build --filter graphql", "build:graphql": "turbo build --filter graphql",
"build:live-preview": "turbo build --filter live-preview", "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: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: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/*", "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": "pnpm runts ./test/dev.ts",
"dev:generate-graphql-schema": "cross-env NODE_OPTIONS=--no-deprecation tsx ./test/generateGraphQLSchema.ts", "runts": "node --no-deprecation --import @swc-node/register/esm-register",
"dev:generate-types": "cross-env NODE_OPTIONS=--no-deprecation tsx ./test/generateTypes.ts", "dev:generate-graphql-schema": "pnpm runts ./test/generateGraphQLSchema.ts",
"dev:postgres": "cross-env NODE_OPTIONS=--no-deprecation PAYLOAD_DATABASE=postgres node ./test/dev.js", "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", "devsafe": "node ./scripts/delete-recursively.js '**/.next' && pnpm dev",
"docker:restart": "pnpm docker:stop --remove-orphans && pnpm docker:start", "docker:restart": "pnpm docker:stop --remove-orphans && pnpm docker:start",
"docker:start": "docker compose -f packages/plugin-cloud-storage/docker-compose.yml up -d", "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 {} +", "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", "prepare": "husky",
"reinstall": "pnpm clean:all && pnpm install", "reinstall": "pnpm clean:all && pnpm install",
"release:alpha": "tsx ./scripts/release.ts --bump prerelease --tag alpha", "release:alpha": "pnpm runts ./scripts/release.ts --bump prerelease --tag alpha",
"release:beta": "tsx ./scripts/release.ts --bump prerelease --tag beta", "release:beta": "pnpm runts ./scripts/release.ts --bump prerelease --tag beta",
"script:gen-templates": "tsx ./scripts/generate-template-variations.ts", "script:gen-templates": "pnpm runts ./scripts/generate-template-variations.ts",
"script:list-published": "tsx scripts/lib/getPackageRegistryVersions.ts", "script:list-published": "pnpm runts scripts/lib/getPackageRegistryVersions.ts",
"script:pack": "tsx scripts/pack-all-to-dest.ts", "script:pack": "pnpm runts scripts/pack-all-to-dest.ts",
"pretest": "pnpm build", "pretest": "pnpm build",
"test": "pnpm test:int && pnpm test:components && pnpm test:e2e", "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:components": "cross-env NODE_OPTIONS=\" --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: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: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: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": "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=\"--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: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=\"--experimental-vm-modules --no-deprecation\" NODE_NO_WARNINGS=1 DISABLE_LOGGING=true jest --forceExit --detectOpenHandles --config=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" "translateNewKeys": "pnpm --filter payload run translateNewKeys"
}, },
"lint-staged": { "lint-staged": {
@@ -100,8 +103,9 @@
"@payloadcms/eslint-config": "workspace:*", "@payloadcms/eslint-config": "workspace:*",
"@payloadcms/eslint-plugin": "workspace:*", "@payloadcms/eslint-plugin": "workspace:*",
"@payloadcms/live-preview-react": "workspace:*", "@payloadcms/live-preview-react": "workspace:*",
"@playwright/test": "1.43.0", "@playwright/test": "1.46.0",
"@swc/cli": "0.3.12", "@swc-node/register": "1.10.9",
"@swc/cli": "0.4.0",
"@swc/jest": "0.2.36", "@swc/jest": "0.2.36",
"@types/fs-extra": "^11.0.2", "@types/fs-extra": "^11.0.2",
"@types/jest": "29.5.12", "@types/jest": "29.5.12",
@@ -134,8 +138,8 @@
"next": "15.0.0-canary.104", "next": "15.0.0-canary.104",
"open": "^10.1.0", "open": "^10.1.0",
"p-limit": "^5.0.0", "p-limit": "^5.0.0",
"playwright": "1.43.0", "playwright": "1.46.0",
"playwright-core": "1.43.0", "playwright-core": "1.46.0",
"prettier": "3.3.2", "prettier": "3.3.2",
"prompts": "2.4.2", "prompts": "2.4.2",
"react": "^19.0.0-rc-06d0b89e-20240801", "react": "^19.0.0-rc-06d0b89e-20240801",
@@ -146,9 +150,8 @@
"shelljs": "0.8.5", "shelljs": "0.8.5",
"slash": "3.0.0", "slash": "3.0.0",
"sort-package-json": "^2.10.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", "tempy": "1.0.1",
"tsx": "4.16.2",
"turbo": "^1.13.3", "turbo": "^1.13.3",
"typescript": "5.5.4" "typescript": "5.5.4"
}, },
@@ -177,9 +180,6 @@
"react": "$react", "react": "$react",
"react-dom": "$react-dom", "react-dom": "$react-dom",
"typescript": "$typescript" "typescript": "$typescript"
},
"patchedDependencies": {
"playwright@1.43.0": "patches/playwright@1.43.0.patch"
} }
}, },
"overrides": { "overrides": {

View File

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

View File

@@ -42,7 +42,7 @@
"build": "pnpm pack-template-files && pnpm typecheck && pnpm build:swc", "build": "pnpm pack-template-files && pnpm typecheck && pnpm build:swc",
"build:swc": "swc ./src -d ./dist --config-file .swcrc --strip-leading-paths", "build:swc": "swc ./src -d ./dist --config-file .swcrc --strip-leading-paths",
"clean": "rimraf {dist,*.tsbuildinfo}", "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", "prepublishOnly": "pnpm clean && pnpm build",
"test": "jest", "test": "jest",
"typecheck": "tsc" "typecheck": "tsc"
@@ -50,7 +50,7 @@
"dependencies": { "dependencies": {
"@clack/prompts": "^0.7.0", "@clack/prompts": "^0.7.0",
"@sindresorhus/slugify": "^1.1.0", "@sindresorhus/slugify": "^1.1.0",
"@swc/core": "^1.6.13", "@swc/core": "1.7.10",
"arg": "^5.0.0", "arg": "^5.0.0",
"chalk": "^4.1.0", "chalk": "^4.1.0",
"comment-json": "^4.2.3", "comment-json": "^4.2.3",

View File

@@ -1,6 +1,6 @@
import type { ExportDefaultExpression, ModuleItem } from '@swc/core' import type { ExportDefaultExpression, ModuleItem } from '@swc/core'
import swc from '@swc/core' import { parse } from '@swc/core'
import chalk from 'chalk' import chalk from 'chalk'
import { Syntax, parseModule } from 'esprima-next' import { Syntax, parseModule } from 'esprima-next'
import fs from 'fs' import fs from 'fs'
@@ -281,10 +281,10 @@ async function compileTypeScriptFileToAST(
* https://github.com/swc-project/swc/issues/1366 * https://github.com/swc-project/swc/issues/1366
*/ */
if (process.env.NODE_ENV === 'test') { 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', syntax: 'typescript',
}) })

View File

@@ -67,31 +67,6 @@ export type FieldGenerator<TSchema, TField> = {
schema: TSchema 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> = ( export type FieldGeneratorFunction<TSchema, TField extends Field> = (
args: FieldGenerator<TSchema, TField>, args: FieldGenerator<TSchema, TField>,
) => void ) => void

View File

@@ -42,7 +42,7 @@
"clean": "rimraf {dist,*.tsbuildinfo}", "clean": "rimraf {dist,*.tsbuildinfo}",
"prepack": "pnpm clean && pnpm turbo build", "prepack": "pnpm clean && pnpm turbo build",
"prepublishOnly": "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": { "dependencies": {
"@payloadcms/drizzle": "workspace:*", "@payloadcms/drizzle": "workspace:*",

View File

@@ -1,5 +1,21 @@
#!/usr/bin/env node #!/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": { "dependencies": {
"graphql-scalars": "1.22.2", "graphql-scalars": "1.22.2",
"pluralize": "8.0.0", "pluralize": "8.0.0",
"@swc-node/register": "1.10.9",
"ts-essentials": "7.0.3" "ts-essentials": "7.0.3"
}, },
"devDependencies": { "devDependencies": {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,12 @@
import type { I18n } from '@payloadcms/translations' import type { I18n } from '@payloadcms/translations'
import type { import type {
Payload,
Permissions, Permissions,
SanitizedCollectionConfig, SanitizedCollectionConfig,
SanitizedConfig,
SanitizedGlobalConfig, SanitizedGlobalConfig,
} from 'payload' } from 'payload'
import { RenderComponent, getCreateMappedComponent } from '@payloadcms/ui/shared'
import { isPlainObject } from 'payload' import { isPlainObject } from 'payload'
import React from 'react' import React from 'react'
@@ -20,12 +21,13 @@ const baseClass = 'doc-tabs'
export const DocumentTabs: React.FC<{ export const DocumentTabs: React.FC<{
collectionConfig: SanitizedCollectionConfig collectionConfig: SanitizedCollectionConfig
config: SanitizedConfig
globalConfig: SanitizedGlobalConfig globalConfig: SanitizedGlobalConfig
i18n: I18n i18n: I18n
payload: Payload
permissions: Permissions permissions: Permissions
}> = (props) => { }> = (props) => {
const { collectionConfig, config, globalConfig, permissions } = props const { collectionConfig, globalConfig, i18n, payload, permissions } = props
const { config } = payload
const customViews = getCustomViews({ collectionConfig, globalConfig }) const customViews = getCustomViews({ collectionConfig, globalConfig })
@@ -46,10 +48,9 @@ export const DocumentTabs: React.FC<{
}) })
?.map(([name, tab], index) => { ?.map(([name, tab], index) => {
const viewConfig = getViewConfig({ name, collectionConfig, globalConfig }) const viewConfig = getViewConfig({ name, collectionConfig, globalConfig })
const tabFromConfig = viewConfig && 'Tab' in viewConfig ? viewConfig.Tab : undefined const tabFromConfig = viewConfig && 'tab' in viewConfig ? viewConfig.tab : undefined
const tabConfig = typeof tabFromConfig === 'object' ? tabFromConfig : undefined
const { condition } = tabConfig || {} const { condition } = tabFromConfig || {}
const meetsCondition = const meetsCondition =
!condition || !condition ||
@@ -72,17 +73,39 @@ export const DocumentTabs: React.FC<{
return null return null
})} })}
{customViews?.map((CustomView, index) => { {customViews?.map((CustomView, index) => {
if ('Tab' in CustomView) { if ('tab' in CustomView) {
const { Tab, path } = CustomView const { path, tab } = CustomView
if (typeof Tab === 'object' && !isPlainObject(Tab)) { if (tab.Component) {
throw new Error( const createMappedComponent = getCreateMappedComponent({
`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`, 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 (
return <Tab path={path} {...props} key={`tab-custom-${index}`} /> <RenderComponent
clientProps={{
key: `tab-custom-${index}`,
path,
}}
key={`tab-custom-${index}`}
mappedComponent={mappedTab}
/>
)
} }
return ( return (
@@ -90,7 +113,7 @@ export const DocumentTabs: React.FC<{
key={`tab-custom-${index}`} key={`tab-custom-${index}`}
{...{ {...{
...props, ...props,
...Tab, ...tab,
}} }}
/> />
) )

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import type { ServerProps } from 'payload' import type { ServerProps } from 'payload'
import { PayloadLogo, RenderCustomComponent } from '@payloadcms/ui/shared' import { PayloadLogo, RenderComponent, getCreateMappedComponent } from '@payloadcms/ui/shared'
import React from 'react' import React from 'react'
export const Logo: React.FC<ServerProps> = (props) => { export const Logo: React.FC<ServerProps> = (props) => {
@@ -16,11 +16,9 @@ export const Logo: React.FC<ServerProps> = (props) => {
} = {}, } = {},
} = payload.config } = payload.config
return ( const createMappedComponent = getCreateMappedComponent({
<RenderCustomComponent importMap: payload.importMap,
CustomComponent={CustomLogo} serverProps: {
DefaultComponent={PayloadLogo}
serverOnlyProps={{
i18n, i18n,
locale, locale,
params, params,
@@ -28,7 +26,10 @@ export const Logo: React.FC<ServerProps> = (props) => {
permissions, permissions,
searchParams, searchParams,
user, 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 pathname = usePathname()
const { const {
config: {
collections, collections,
globals, globals,
routes: { admin: adminRoute }, routes: { admin: adminRoute },
},
} = useConfig() } = useConfig()
const { i18n } = useTranslation() const { i18n } = useTranslation()

View File

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

View File

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

View File

@@ -20,6 +20,8 @@ export const buildFormState = async ({ req }: { req: PayloadRequest }) => {
status: httpStatus.OK, status: httpStatus.OK,
}) })
} catch (err) { } 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') { if (err.message === 'Could not find field schema for given path') {
return Response.json( 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({ return routeError({
config: req.payload.config, config: req.payload.config,
err, err,

View File

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

View File

@@ -1,6 +1,6 @@
import type { PayloadRequest } from 'payload' import type { PayloadRequest } from 'payload'
import { PayloadIcon } from '@payloadcms/ui/shared' import { PayloadIcon, getCreateMappedComponent } from '@payloadcms/ui/shared'
import fs from 'fs/promises' import fs from 'fs/promises'
import { ImageResponse } from 'next/og.js' import { ImageResponse } from 'next/og.js'
import { NextResponse } from 'next/server.js' import { NextResponse } from 'next/server.js'
@@ -32,7 +32,18 @@ export const generateOGImage = async ({ req }: { req: PayloadRequest }) => {
const hasLeader = searchParams.has('leader') const hasLeader = searchParams.has('leader')
const leader = hasLeader ? searchParams.get('leader')?.slice(0, 100).replace('-', ' ') : '' const leader = hasLeader ? searchParams.get('leader')?.slice(0, 100).replace('-', ' ') : ''
const description = searchParams.has('description') ? searchParams.get('description') : '' 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 let fontData
@@ -50,7 +61,7 @@ export const generateOGImage = async ({ req }: { req: PayloadRequest }) => {
return new ImageResponse( return new ImageResponse(
( (
<OGImage <OGImage
Icon={Icon} Icon={mappedIcon}
description={description} description={description}
fontFamily={fontFamily} fontFamily={fontFamily}
leader={leader} 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 { AppHeader, EntityVisibilityProvider, NavToggler } from '@payloadcms/ui'
import { RenderCustomComponent } from '@payloadcms/ui/shared' import { RenderComponent, getCreateMappedComponent } from '@payloadcms/ui/shared'
import React from 'react' 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 { NavHamburger } from './NavHamburger/index.js'
import { Wrapper } from './Wrapper/index.js' import { Wrapper } from './Wrapper/index.js'
import './index.scss' import './index.scss'
@@ -37,7 +37,9 @@ export const DefaultTemplate: React.FC<DefaultTemplateProps> = ({
} = {}, } = {},
} = payload.config || {} } = payload.config || {}
const navProps: NavProps = { const createMappedComponent = getCreateMappedComponent({
importMap: payload.importMap,
serverProps: {
i18n, i18n,
locale, locale,
params, params,
@@ -45,7 +47,15 @@ export const DefaultTemplate: React.FC<DefaultTemplateProps> = ({
permissions, permissions,
searchParams, searchParams,
user, user,
} },
})
const MappedDefaultNav: MappedComponent = createMappedComponent(
CustomNav,
undefined,
DefaultNav,
'CustomNav',
)
return ( return (
<EntityVisibilityProvider visibleEntities={visibleEntities}> <EntityVisibilityProvider visibleEntities={visibleEntities}>
@@ -56,20 +66,8 @@ export const DefaultTemplate: React.FC<DefaultTemplateProps> = ({
</NavToggler> </NavToggler>
</div> </div>
<Wrapper baseClass={baseClass} className={className}> <Wrapper baseClass={baseClass} className={className}>
<RenderCustomComponent <RenderComponent mappedComponent={MappedDefaultNav} />
CustomComponent={CustomNav}
DefaultComponent={DefaultNav}
componentProps={navProps}
serverOnlyProps={{
i18n,
locale,
params,
payload,
permissions,
searchParams,
user,
}}
/>
<div className={`${baseClass}__wrap`}> <div className={`${baseClass}__wrap`}>
<AppHeader /> <AppHeader />
{children} {children}

View File

@@ -1,6 +1,6 @@
import type { InitOptions, Payload, SanitizedConfig } from 'payload' import type { InitOptions, Payload, SanitizedConfig } from 'payload'
import { BasePayload } from 'payload' import { BasePayload, generateImportMap } from 'payload'
import WebSocket from 'ws' import WebSocket from 'ws'
let cached: { 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() await payload.db.init()
if (payload.db.connect) { if (payload.db.connect) {
await payload.db.connect({ hotReload: true }) await payload.db.connect({ hotReload: true })
@@ -74,6 +81,9 @@ export const getPayloadHMR = async (options: InitOptions): Promise<Payload> => {
await cached.reload await cached.reload
} }
if (options?.importMap) {
cached.payload.importMap = options.importMap
}
return cached.payload return cached.payload
} }
@@ -115,5 +125,9 @@ export const getPayloadHMR = async (options: InitOptions): Promise<Payload> => {
throw e throw e
} }
if (options?.importMap) {
cached.payload.importMap = options.importMap
}
return cached.payload return cached.payload
} }

View File

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

View File

@@ -1,4 +1,4 @@
import type { SanitizedConfig } from 'payload' import type { ImportMap, SanitizedConfig } from 'payload'
export type Args = { export type Args = {
/** /**
@@ -6,6 +6,7 @@ export type Args = {
* If unresolved, this function will await the promise. * If unresolved, this function will await the promise.
*/ */
config: Promise<SanitizedConfig> | SanitizedConfig config: Promise<SanitizedConfig> | SanitizedConfig
importMap: ImportMap
/** /**
* If true, redirects unauthenticated users to the admin login page. * If true, redirects unauthenticated users to the admin login page.
* If a string is provided, the user will be redirected to that specific URL. * 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' import React from 'react'
export const LocaleSelector: React.FC<{ export const LocaleSelector: React.FC<{
localeOptions: { readonly localeOptions: {
label: Record<string, string> | string label: Record<string, string> | string
value: string value: string
}[] }[]
onChange: (value: string) => void readonly onChange: (value: string) => void
}> = ({ localeOptions, onChange }) => { }> = ({ localeOptions, onChange }) => {
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
<SelectField <SelectField
label={t('general:locale')} field={{
name="locale" name: 'locale',
_path: 'locale',
label: t('general:locale'),
options: localeOptions,
}}
onChange={(value: string) => onChange(value)} onChange={(value: string) => onChange(value)}
options={localeOptions}
path="locale"
/> />
) )
} }

View File

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

View File

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

View File

@@ -16,10 +16,10 @@ export const ToggleTheme: React.FC = () => {
return ( return (
<RadioGroupField <RadioGroupField
label={t('general:adminTheme')} field={{
name="theme" name: 'theme',
onChange={onChange} label: t('general:adminTheme'),
options={[ options: [
{ {
label: t('general:automatic'), label: t('general:automatic'),
value: 'auto', value: 'auto',
@@ -32,7 +32,9 @@ export const ToggleTheme: React.FC = () => {
label: t('general:dark'), label: t('general:dark'),
value: 'dark', value: 'dark',
}, },
]} ],
}}
onChange={onChange}
value={autoMode ? 'auto' : theme} 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 { DocumentInfoProvider, EditDepthProvider, HydrateAuthProvider, RenderComponent } from '@payloadcms/ui'
import { RenderCustomComponent } from '@payloadcms/ui/shared' import { getCreateMappedComponent } from '@payloadcms/ui/shared'
import { notFound } from 'next/navigation.js' import { notFound } from 'next/navigation.js'
import React from 'react' import React from 'react'
@@ -56,12 +56,27 @@ export const Account: React.FC<AdminViewProps> = async ({
req, req,
}) })
const viewComponentProps: ServerSideEditViewProps = { const createMappedComponent = getCreateMappedComponent({
importMap: payload.importMap,
serverProps: {
i18n,
initPageResult, initPageResult,
locale,
params, params,
payload,
permissions,
routeSegments: [], routeSegments: [],
searchParams, searchParams,
} user,
},
})
const mappedAccountComponent = createMappedComponent(
CustomAccountComponent?.Component,
undefined,
EditView,
'CustomAccountComponent.Component',
)
return ( return (
<DocumentInfoProvider <DocumentInfoProvider
@@ -77,30 +92,16 @@ export const Account: React.FC<AdminViewProps> = async ({
isEditing isEditing
> >
<EditDepthProvider depth={1}> <EditDepthProvider depth={1}>
<DocumentHeader <DocumentHeader
collectionConfig={collectionConfig} collectionConfig={collectionConfig}
config={payload.config}
hideTabs hideTabs
i18n={i18n} i18n={i18n}
payload={payload}
permissions={permissions} permissions={permissions}
/> />
<HydrateAuthProvider permissions={permissions} /> <HydrateAuthProvider permissions={permissions} />
<RenderCustomComponent <RenderComponent mappedComponent={mappedAccountComponent} />
CustomComponent={
typeof CustomAccountComponent === 'function' ? CustomAccountComponent : undefined
}
DefaultComponent={EditView}
componentProps={viewComponentProps}
serverOnlyProps={{
i18n,
locale,
params,
payload,
permissions,
searchParams,
user,
}}
/>
<AccountClient /> <AccountClient />
</EditDepthProvider> </EditDepthProvider>
</DocumentInfoProvider> </DocumentInfoProvider>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,14 @@
import type { import type {
AdminViewComponent, AdminViewProps,
CollectionPermission, CollectionPermission,
EditViewComponent,
GlobalPermission, GlobalPermission,
PayloadComponent,
SanitizedCollectionConfig, SanitizedCollectionConfig,
SanitizedConfig, SanitizedConfig,
SanitizedGlobalConfig, SanitizedGlobalConfig,
ServerSideEditViewProps,
} from 'payload' } from 'payload'
import type React from 'react'
import { notFound } from 'next/navigation.js' import { notFound } from 'next/navigation.js'
@@ -19,6 +21,11 @@ import { VersionsView as DefaultVersionsView } from '../Versions/index.js'
import { getCustomViewByKey } from './getCustomViewByKey.js' import { getCustomViewByKey } from './getCustomViewByKey.js'
import { getCustomViewByRoute } from './getCustomViewByRoute.js' import { getCustomViewByRoute } from './getCustomViewByRoute.js'
export type ViewFromConfig<TProps extends object> = {
Component?: React.FC<TProps>
payloadComponent?: PayloadComponent<TProps>
}
export const getViewsFromConfig = ({ export const getViewsFromConfig = ({
collectionConfig, collectionConfig,
config, config,
@@ -28,22 +35,21 @@ export const getViewsFromConfig = ({
}: { }: {
collectionConfig?: SanitizedCollectionConfig collectionConfig?: SanitizedCollectionConfig
config: SanitizedConfig config: SanitizedConfig
docPermissions: CollectionPermission | GlobalPermission docPermissions: CollectionPermission | GlobalPermission
globalConfig?: SanitizedGlobalConfig globalConfig?: SanitizedGlobalConfig
routeSegments: string[] routeSegments: string[]
}): { }): {
CustomView: EditViewComponent CustomView: ViewFromConfig<ServerSideEditViewProps>
DefaultView: EditViewComponent 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 * 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 => { } | null => {
// Conditionally import and lazy load the default view // Conditionally import and lazy load the default view
let DefaultView: EditViewComponent = null let DefaultView: ViewFromConfig<ServerSideEditViewProps> = null
let CustomView: EditViewComponent = null let CustomView: ViewFromConfig<ServerSideEditViewProps> = null
let ErrorView: AdminViewComponent = null let ErrorView: ViewFromConfig<AdminViewProps> = null
const { const {
routes: { admin: adminRoute }, routes: { admin: adminRoute },
@@ -60,7 +66,7 @@ export const getViewsFromConfig = ({
config?.admin?.livePreview?.globals?.includes(globalConfig?.slug) config?.admin?.livePreview?.globals?.includes(globalConfig?.slug)
if (collectionConfig) { if (collectionConfig) {
const editConfig = collectionConfig?.admin?.components?.views?.Edit const editConfig = collectionConfig?.admin?.components?.views?.edit
const EditOverride = typeof editConfig === 'function' ? editConfig : null const EditOverride = typeof editConfig === 'function' ? editConfig : null
if (EditOverride) { if (EditOverride) {
@@ -80,17 +86,27 @@ export const getViewsFromConfig = ({
switch (segment3) { switch (segment3) {
case 'create': { case 'create': {
if ('create' in docPermissions && docPermissions?.create?.permission) { if ('create' in docPermissions && docPermissions?.create?.permission) {
CustomView = getCustomViewByKey(views, 'Default') CustomView = {
DefaultView = DefaultEditView payloadComponent: getCustomViewByKey(views, 'default'),
}
DefaultView = {
Component: DefaultEditView,
}
} else { } else {
ErrorView = UnauthorizedView ErrorView = {
Component: UnauthorizedView,
}
} }
break break
} }
default: { default: {
CustomView = getCustomViewByKey(views, 'Default') CustomView = {
DefaultView = DefaultEditView payloadComponent: getCustomViewByKey(views, 'default'),
}
DefaultView = {
Component: DefaultEditView,
}
break break
} }
} }
@@ -102,25 +118,37 @@ export const getViewsFromConfig = ({
switch (segment4) { switch (segment4) {
case 'api': { case 'api': {
if (collectionConfig?.admin?.hideAPIURL !== true) { if (collectionConfig?.admin?.hideAPIURL !== true) {
CustomView = getCustomViewByKey(views, 'API') CustomView = {
DefaultView = DefaultAPIView payloadComponent: getCustomViewByKey(views, 'api'),
}
DefaultView = {
Component: DefaultAPIView,
}
} }
break break
} }
case 'preview': { case 'preview': {
if (livePreviewEnabled) { if (livePreviewEnabled) {
DefaultView = DefaultLivePreviewView DefaultView = {
Component: DefaultLivePreviewView,
}
} }
break break
} }
case 'versions': { case 'versions': {
if (docPermissions?.readVersions?.permission) { if (docPermissions?.readVersions?.permission) {
CustomView = getCustomViewByKey(views, 'Versions') CustomView = {
DefaultView = DefaultVersionsView payloadComponent: getCustomViewByKey(views, 'versions'),
}
DefaultView = {
Component: DefaultVersionsView,
}
} else { } else {
ErrorView = UnauthorizedView ErrorView = {
Component: UnauthorizedView,
}
} }
break break
} }
@@ -139,11 +167,13 @@ export const getViewsFromConfig = ({
.filter(Boolean) .filter(Boolean)
.join('/') .join('/')
CustomView = getCustomViewByRoute({ CustomView = {
payloadComponent: getCustomViewByRoute({
baseRoute, baseRoute,
currentRoute, currentRoute,
views, views,
}) }),
}
break break
} }
} }
@@ -154,10 +184,16 @@ export const getViewsFromConfig = ({
default: { default: {
if (segment4 === 'versions') { if (segment4 === 'versions') {
if (docPermissions?.readVersions?.permission) { if (docPermissions?.readVersions?.permission) {
CustomView = getCustomViewByKey(views, 'Version') CustomView = {
DefaultView = DefaultVersionView payloadComponent: getCustomViewByKey(views, 'version'),
}
DefaultView = {
Component: DefaultVersionView,
}
} else { } else {
ErrorView = UnauthorizedView ErrorView = {
Component: UnauthorizedView,
}
} }
} else { } else {
const baseRoute = [ const baseRoute = [
@@ -173,11 +209,13 @@ export const getViewsFromConfig = ({
.filter(Boolean) .filter(Boolean)
.join('/') .join('/')
CustomView = getCustomViewByRoute({ CustomView = {
payloadComponent: getCustomViewByRoute({
baseRoute, baseRoute,
currentRoute, currentRoute,
views, views,
}) }),
}
} }
break break
} }
@@ -187,7 +225,7 @@ export const getViewsFromConfig = ({
} }
if (globalConfig) { if (globalConfig) {
const editConfig = globalConfig?.admin?.components?.views?.Edit const editConfig = globalConfig?.admin?.components?.views?.edit
const EditOverride = typeof editConfig === 'function' ? editConfig : null const EditOverride = typeof editConfig === 'function' ? editConfig : null
if (EditOverride) { if (EditOverride) {
@@ -202,8 +240,12 @@ export const getViewsFromConfig = ({
} else { } else {
switch (routeSegments.length) { switch (routeSegments.length) {
case 2: { case 2: {
CustomView = getCustomViewByKey(views, 'Default') CustomView = {
DefaultView = DefaultEditView payloadComponent: getCustomViewByKey(views, 'default'),
}
DefaultView = {
Component: DefaultEditView,
}
break break
} }
@@ -212,25 +254,37 @@ export const getViewsFromConfig = ({
switch (segment3) { switch (segment3) {
case 'api': { case 'api': {
if (globalConfig?.admin?.hideAPIURL !== true) { if (globalConfig?.admin?.hideAPIURL !== true) {
CustomView = getCustomViewByKey(views, 'API') CustomView = {
DefaultView = DefaultAPIView payloadComponent: getCustomViewByKey(views, 'api'),
}
DefaultView = {
Component: DefaultAPIView,
}
} }
break break
} }
case 'preview': { case 'preview': {
if (livePreviewEnabled) { if (livePreviewEnabled) {
DefaultView = DefaultLivePreviewView DefaultView = {
Component: DefaultLivePreviewView,
}
} }
break break
} }
case 'versions': { case 'versions': {
if (docPermissions?.readVersions?.permission) { if (docPermissions?.readVersions?.permission) {
CustomView = getCustomViewByKey(views, 'Versions') CustomView = {
DefaultView = DefaultVersionsView payloadComponent: getCustomViewByKey(views, 'versions'),
}
DefaultView = {
Component: DefaultVersionsView,
}
} else { } else {
ErrorView = UnauthorizedView ErrorView = {
Component: UnauthorizedView,
}
} }
break break
} }
@@ -245,14 +299,20 @@ export const getViewsFromConfig = ({
.filter(Boolean) .filter(Boolean)
.join('/') .join('/')
CustomView = getCustomViewByRoute({ CustomView = {
payloadComponent: getCustomViewByRoute({
baseRoute, baseRoute,
currentRoute, currentRoute,
views, views,
}) }),
DefaultView = DefaultEditView }
DefaultView = {
Component: DefaultEditView,
}
} else { } else {
ErrorView = UnauthorizedView ErrorView = {
Component: UnauthorizedView,
}
} }
break break
} }
@@ -264,10 +324,16 @@ export const getViewsFromConfig = ({
// `../:slug/versions/:version`, etc // `../:slug/versions/:version`, etc
if (segment3 === 'versions') { if (segment3 === 'versions') {
if (docPermissions?.readVersions?.permission) { if (docPermissions?.readVersions?.permission) {
CustomView = getCustomViewByKey(views, 'Version') CustomView = {
DefaultView = DefaultVersionView payloadComponent: getCustomViewByKey(views, 'version'),
}
DefaultView = {
Component: DefaultVersionView,
}
} else { } else {
ErrorView = UnauthorizedView ErrorView = {
Component: UnauthorizedView,
}
} }
} else { } else {
const baseRoute = [adminRoute !== '/' && adminRoute, 'globals', globalSlug] const baseRoute = [adminRoute !== '/' && adminRoute, 'globals', globalSlug]
@@ -278,11 +344,13 @@ export const getViewsFromConfig = ({
.filter(Boolean) .filter(Boolean)
.join('/') .join('/')
CustomView = getCustomViewByRoute({ CustomView = {
payloadComponent: getCustomViewByRoute({
baseRoute, baseRoute,
currentRoute, currentRoute,
views, views,
}) }),
}
} }
break 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 { DocumentInfoProvider, EditDepthProvider, HydrateAuthProvider } from '@payloadcms/ui'
import { import {
RenderCustomComponent, RenderComponent,
formatAdminURL, formatAdminURL,
getCreateMappedComponent,
isEditing as getIsEditing, isEditing as getIsEditing,
} from '@payloadcms/ui/shared' } from '@payloadcms/ui/shared'
import { notFound, redirect } from 'next/navigation.js' 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 generateMetadata: GenerateEditViewMetadata = async (args) => getMetaBySegment(args)
export const Document: React.FC<AdminViewProps> = async ({ export const Document: React.FC<AdminViewProps> = async ({
importMap,
initPageResult, initPageResult,
params, params,
searchParams, searchParams,
@@ -53,10 +60,10 @@ export const Document: React.FC<AdminViewProps> = async ({
const isEditing = getIsEditing({ id, collectionSlug, globalSlug }) const isEditing = getIsEditing({ id, collectionSlug, globalSlug })
let ViewOverride: EditViewComponent let ViewOverride: MappedComponent<ServerSideEditViewProps>
let CustomView: EditViewComponent let CustomView: MappedComponent<ServerSideEditViewProps>
let DefaultView: EditViewComponent let DefaultView: MappedComponent<ServerSideEditViewProps>
let ErrorView: AdminViewComponent let ErrorView: MappedComponent<AdminViewProps>
let apiURL: string let apiURL: string
@@ -76,6 +83,21 @@ export const Document: React.FC<AdminViewProps> = async ({
req, req,
}) })
const createMappedComponent = getCreateMappedComponent({
importMap,
serverProps: {
i18n,
initPageResult,
locale,
params,
payload,
permissions,
routeSegments: segments,
searchParams,
user,
},
})
if (collectionConfig) { if (collectionConfig) {
if (!visibleEntities?.collections?.find((visibleSlug) => visibleSlug === collectionSlug)) { if (!visibleEntities?.collections?.find((visibleSlug) => visibleSlug === collectionSlug)) {
notFound() notFound()
@@ -93,8 +115,17 @@ export const Document: React.FC<AdminViewProps> = async ({
apiURL = `${serverURL}${apiRoute}/${collectionSlug}/${id}${apiQueryParams}` apiURL = `${serverURL}${apiRoute}/${collectionSlug}/${id}${apiQueryParams}`
const editConfig = collectionConfig?.admin?.components?.views?.Edit ViewOverride =
ViewOverride = typeof editConfig === 'function' ? editConfig : null 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) { if (!ViewOverride) {
const collectionViews = getViewsFromConfig({ const collectionViews = getViewsFromConfig({
@@ -104,13 +135,30 @@ export const Document: React.FC<AdminViewProps> = async ({
routeSegments: segments, routeSegments: segments,
}) })
CustomView = collectionViews?.CustomView CustomView = createMappedComponent(
DefaultView = collectionViews?.DefaultView collectionViews?.CustomView?.payloadComponent,
ErrorView = collectionViews?.ErrorView 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) { 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}` 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 ViewOverride = typeof editConfig === 'function' ? editConfig : null
if (!ViewOverride) { if (!ViewOverride) {
@@ -144,12 +192,29 @@ export const Document: React.FC<AdminViewProps> = async ({
routeSegments: segments, routeSegments: segments,
}) })
CustomView = globalViews?.CustomView CustomView = createMappedComponent(
DefaultView = globalViews?.DefaultView globalViews?.CustomView?.payloadComponent,
ErrorView = globalViews?.ErrorView 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) { 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 && ( {!ViewOverride && (
<DocumentHeader <DocumentHeader
collectionConfig={collectionConfig} collectionConfig={collectionConfig}
config={payload.config}
globalConfig={globalConfig} globalConfig={globalConfig}
i18n={i18n} i18n={i18n}
payload={payload}
permissions={permissions} permissions={permissions}
/> />
)} )}
@@ -226,22 +291,10 @@ export const Document: React.FC<AdminViewProps> = async ({
key={`${collectionSlug || globalSlug}${locale?.code ? `-${locale?.code}` : ''}`} key={`${collectionSlug || globalSlug}${locale?.code ? `-${locale?.code}` : ''}`}
> >
{ErrorView ? ( {ErrorView ? (
<ErrorView initPageResult={initPageResult} searchParams={searchParams} /> <RenderComponent mappedComponent={ErrorView} />
) : ( ) : (
<RenderCustomComponent <RenderComponent
CustomComponent={ViewOverride || CustomView} mappedComponent={ViewOverride ? ViewOverride : CustomView ? CustomView : DefaultView}
DefaultComponent={DefaultView}
serverOnlyProps={{
i18n,
initPageResult,
locale,
params,
payload,
permissions,
routeSegments: segments,
searchParams,
user,
}}
/> />
)} )}
</EditDepthProvider> </EditDepthProvider>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import type { AdminViewProps, Where } from 'payload' import type { AdminViewProps, ClientCollectionConfig, Where } from 'payload'
import { import {
HydrateAuthProvider, HydrateAuthProvider,
@@ -6,14 +6,16 @@ import {
ListQueryProvider, ListQueryProvider,
TableColumnsProvider, TableColumnsProvider,
} from '@payloadcms/ui' } 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 { notFound } from 'next/navigation.js'
import { createClientCollectionConfig, mergeListSearchAndWhere } from 'payload' import { deepCopyObjectSimple, mergeListSearchAndWhere } from 'payload'
import { isNumber, isReactComponentOrFunction } from 'payload/shared' import { isNumber } from 'payload/shared'
import React, { Fragment } from 'react' 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' import { DefaultListView } from './Default/index.js'
export { generateListMetadata } from './meta.js' export { generateListMetadata } from './meta.js'
@@ -84,22 +86,10 @@ export const ListView: React.FC<AdminViewProps> = async ({
} = config } = config
if (collectionConfig) { if (collectionConfig) {
const {
admin: { components: { views: { List: CustomList } = {} } = {} },
} = collectionConfig
if (!visibleEntities.collections.includes(collectionSlug)) { if (!visibleEntities.collections.includes(collectionSlug)) {
return notFound() 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 page = isNumber(query?.page) ? Number(query.page) : 0
const whereQuery = mergeListSearchAndWhere({ const whereQuery = mergeListSearchAndWhere({
collectionConfig, collectionConfig,
@@ -131,19 +121,56 @@ export const ListView: React.FC<AdminViewProps> = async ({
where: whereQuery || {}, where: whereQuery || {},
}) })
const viewComponentProps: DefaultListViewProps = { const createMappedComponent = getCreateMappedComponent({
importMap: payload.importMap,
serverProps: {
collectionConfig,
collectionSlug, collectionSlug,
data,
hasCreatePermission: permissions?.collections?.[collectionSlug]?.create?.permission,
i18n,
limit,
listPreferences,
listSearchableFields: collectionConfig.admin.listSearchableFields, 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 ( return (
<Fragment> <Fragment>
<HydrateAuthProvider permissions={permissions} /> <HydrateAuthProvider permissions={permissions} />
<ListInfoProvider <ListInfoProvider
collectionConfig={createClientCollectionConfig({ collectionConfig={clientCollectionConfig}
collection: collectionConfig,
t: initPageResult.req.i18n.t,
})}
collectionSlug={collectionSlug} collectionSlug={collectionSlug}
hasCreatePermission={permissions?.collections?.[collectionSlug]?.create?.permission} hasCreatePermission={permissions?.collections?.[collectionSlug]?.create?.permission}
newDocumentURL={formatAdminURL({ newDocumentURL={formatAdminURL({
@@ -164,29 +191,12 @@ export const ListView: React.FC<AdminViewProps> = async ({
listPreferences={listPreferences} listPreferences={listPreferences}
preferenceKey={preferenceKey} preferenceKey={preferenceKey}
> >
<RenderCustomComponent <RenderComponent
CustomComponent={CustomListView} clientProps={{
DefaultComponent={DefaultListView} collectionSlug,
componentProps={viewComponentProps} listSearchableFields: collectionConfig?.admin?.listSearchableFields,
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,
}} }}
mappedComponent={ListComponent}
/> />
</TableColumnsProvider> </TableColumnsProvider>
</ListQueryProvider> </ListQueryProvider>

View File

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

View File

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

View File

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

View File

@@ -3,9 +3,9 @@ import type { FormProps } from '@payloadcms/ui'
import type { import type {
ClientCollectionConfig, ClientCollectionConfig,
ClientConfig, ClientConfig,
ClientField,
ClientGlobalConfig, ClientGlobalConfig,
Data, Data,
FieldMap,
LivePreviewConfig, LivePreviewConfig,
} from 'payload' } from 'payload'
@@ -16,7 +16,6 @@ import {
OperationProvider, OperationProvider,
SetViewActions, SetViewActions,
useAuth, useAuth,
useComponentMap,
useConfig, useConfig,
useDocumentEvents, useDocumentEvents,
useDocumentInfo, useDocumentInfo,
@@ -37,20 +36,20 @@ import { usePopupWindow } from './usePopupWindow.js'
const baseClass = 'live-preview' const baseClass = 'live-preview'
type Props = { type Props = {
apiRoute: string readonly apiRoute: string
collectionConfig?: ClientCollectionConfig readonly collectionConfig?: ClientCollectionConfig
config: ClientConfig readonly config: ClientConfig
fieldMap: FieldMap readonly fields: ClientField[]
globalConfig?: ClientGlobalConfig readonly globalConfig?: ClientGlobalConfig
schemaPath: string readonly schemaPath: string
serverURL: string readonly serverURL: string
} }
const PreviewView: React.FC<Props> = ({ const PreviewView: React.FC<Props> = ({
apiRoute, apiRoute,
collectionConfig, collectionConfig,
config, config,
fieldMap, fields,
globalConfig, globalConfig,
schemaPath, schemaPath,
serverURL, serverURL,
@@ -81,7 +80,9 @@ const PreviewView: React.FC<Props> = ({
const operation = id ? 'update' : 'create' const operation = id ? 'update' : 'create'
const { const {
config: {
admin: { user: userSlug }, admin: { user: userSlug },
},
} = useConfig() } = useConfig()
const { t } = useTranslation() const { t } = useTranslation()
const { previewWindowType } = useLivePreviewContext() const { previewWindowType } = useLivePreviewContext()
@@ -132,7 +133,6 @@ const PreviewView: React.FC<Props> = ({
) )
return ( return (
<Fragment>
<OperationProvider operation={operation}> <OperationProvider operation={operation}>
<Form <Form
action={action} action={action}
@@ -193,7 +193,7 @@ const PreviewView: React.FC<Props> = ({
AfterFields={AfterFields} AfterFields={AfterFields}
BeforeFields={BeforeFields} BeforeFields={BeforeFields}
docPermissions={docPermissions} docPermissions={docPermissions}
fieldMap={fieldMap} fields={fields}
forceSidebarWrap forceSidebarWrap
readOnly={!hasSavePermission} readOnly={!hasSavePermission}
schemaPath={collectionSlug || globalSlug} schemaPath={collectionSlug || globalSlug}
@@ -204,53 +204,44 @@ const PreviewView: React.FC<Props> = ({
</div> </div>
</Form> </Form>
</OperationProvider> </OperationProvider>
</Fragment>
) )
} }
export const LivePreviewClient: React.FC<{ export const LivePreviewClient: React.FC<{
breakpoints: LivePreviewConfig['breakpoints'] readonly breakpoints: LivePreviewConfig['breakpoints']
initialData: Data readonly initialData: Data
url: string readonly url: string
}> = (props) => { }> = (props) => {
const { breakpoints, url } = props const { breakpoints, url } = props
const { collectionSlug, globalSlug } = useDocumentInfo() const { collectionSlug, globalSlug } = useDocumentInfo()
const config = useConfig() const {
config,
config: {
routes: { api: apiRoute },
serverURL,
},
getEntityConfig,
} = useConfig()
const { isPopupOpen, openPopupWindow, popupRef } = usePopupWindow({ const { isPopupOpen, openPopupWindow, popupRef } = usePopupWindow({
eventType: 'payload-live-preview', eventType: 'payload-live-preview',
url, url,
}) })
const { const collectionConfig = getEntityConfig({ collectionSlug }) as ClientCollectionConfig
collections,
globals,
routes: { api: apiRoute },
serverURL,
} = config
const collectionConfig = const globalConfig = getEntityConfig({ globalSlug }) as ClientGlobalConfig
collectionSlug && collections.find((collection) => collection.slug === collectionSlug)
const globalConfig = globalSlug && globals.find((global) => global.slug === globalSlug)
const schemaPath = collectionSlug || globalSlug 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 ( return (
<Fragment> <Fragment>
<SetViewActions actions={componentMap?.actionsMap?.Edit?.LivePreview} /> <SetViewActions
actions={
(collectionConfig || globalConfig)?.admin?.components?.views?.edit?.livePreview?.actions
}
/>
<LivePreviewProvider <LivePreviewProvider
breakpoints={breakpoints} breakpoints={breakpoints}
fieldSchema={collectionConfig?.fields || globalConfig?.fields} fieldSchema={collectionConfig?.fields || globalConfig?.fields}
@@ -263,7 +254,7 @@ export const LivePreviewClient: React.FC<{
apiRoute={apiRoute} apiRoute={apiRoute}
collectionConfig={collectionConfig} collectionConfig={collectionConfig}
config={config} config={config}
fieldMap={fieldMap} fields={(collectionConfig || globalConfig)?.fields}
globalConfig={globalConfig} globalConfig={globalConfig}
schemaPath={schemaPath} schemaPath={schemaPath}
serverURL={serverURL} 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 { notFound } from 'next/navigation.js'
import React from 'react' import React from 'react'
@@ -6,7 +11,7 @@ import React from 'react'
import { LivePreviewClient } from './index.client.js' import { LivePreviewClient } from './index.client.js'
import './index.scss' import './index.scss'
export const LivePreviewView: EditViewComponent = async (props) => { export const LivePreviewView: PayloadServerReactComponent<EditViewComponent> = async (props) => {
const { initPageResult } = props const { initPageResult } = props
const { const {

View File

@@ -1,4 +1,6 @@
'use client' 'use client'
import type React from 'react'
import { useConfig } from '@payloadcms/ui' import { useConfig } from '@payloadcms/ui'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
@@ -20,12 +22,14 @@ export const usePopupWindow = (props: {
}): { }): {
isPopupOpen: boolean isPopupOpen: boolean
openPopupWindow: () => void openPopupWindow: () => void
popupRef?: React.MutableRefObject<Window | null> popupRef?: React.RefObject<Window | null>
} => { } => {
const { eventType, onMessage, url } = props const { eventType, onMessage, url } = props
const isReceivingMessage = useRef(false) const isReceivingMessage = useRef(false)
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const { serverURL } = useConfig() const {
config: { serverURL },
} = useConfig()
const popupRef = useRef<Window | null>(null) const popupRef = useRef<Window | null>(null)
// Optionally broadcast messages back out to the parent component // 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 { EmailField, TextField, useTranslation } from '@payloadcms/ui'
import { email, username } from 'payload/shared' import { email, username } from 'payload/shared'
import React from 'react' import React from 'react'
export type LoginFieldProps = { export type LoginFieldProps = {
required?: boolean readonly required?: boolean
type: 'email' | 'emailOrUsername' | 'username' readonly type: 'email' | 'emailOrUsername' | 'username'
validate?: Validate readonly validate?: Validate
} }
export const LoginField: React.FC<LoginFieldProps> = ({ type, required = true }) => { export const LoginField: React.FC<LoginFieldProps> = ({ type, required = true }) => {
const { t } = useTranslation() const { t } = useTranslation()
@@ -16,10 +18,11 @@ export const LoginField: React.FC<LoginFieldProps> = ({ type, required = true })
return ( return (
<EmailField <EmailField
autoComplete="email" autoComplete="email"
label={t('general:email')} field={{
name="email" name: 'email',
path="email" label: t('general:email'),
required={required} required,
}}
validate={email} validate={email}
/> />
) )
@@ -28,10 +31,11 @@ export const LoginField: React.FC<LoginFieldProps> = ({ type, required = true })
if (type === 'username') { if (type === 'username') {
return ( return (
<TextField <TextField
label={t('authentication:username')} field={{
name="username" name: 'username',
path="username" label: t('authentication:username'),
required={required} required,
}}
validate={username} validate={username}
/> />
) )
@@ -40,10 +44,11 @@ export const LoginField: React.FC<LoginFieldProps> = ({ type, required = true })
if (type === 'emailOrUsername') { if (type === 'emailOrUsername') {
return ( return (
<TextField <TextField
label={t('authentication:emailOrUsername')} field={{
name="username" name: 'username',
path="username" label: t('authentication:emailOrUsername'),
required={required} required,
}}
validate={(value, options) => { validate={(value, options) => {
const passesUsername = username(value, options) const passesUsername = username(value, options)
const passesEmail = email( const passesEmail = email(

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,7 +17,7 @@ import React from 'react'
import { toast } from 'sonner' import { toast } from 'sonner'
type Args = { type Args = {
token: string readonly token: string
} }
const initialState: FormState = { const initialState: FormState = {
@@ -36,12 +36,14 @@ const initialState: FormState = {
export const ResetPasswordClient: React.FC<Args> = ({ token }) => { export const ResetPasswordClient: React.FC<Args> = ({ token }) => {
const i18n = useTranslation() const i18n = useTranslation()
const { const {
config: {
admin: { admin: {
routes: { login: loginRoute }, routes: { login: loginRoute },
user: userSlug, user: userSlug,
}, },
routes: { admin: adminRoute, api: apiRoute }, routes: { admin: adminRoute, api: apiRoute },
serverURL, serverURL,
},
} = useConfig() } = useConfig()
const history = useRouter() const history = useRouter()
@@ -74,13 +76,20 @@ export const ResetPasswordClient: React.FC<Args> = ({ token }) => {
onSuccess={onSuccess} onSuccess={onSuccess}
> >
<PasswordField <PasswordField
label={i18n.t('authentication:newPassword')} field={{
name="password" name: 'password',
path="password" label: i18n.t('authentication:newPassword'),
required required: true,
}}
/> />
<ConfirmPasswordField /> <ConfirmPasswordField />
<HiddenField forceUsePathFromProps name="token" value={token} /> <HiddenField
field={{
name: 'token',
}}
forceUsePathFromProps
value={token}
/>
<FormSubmit size="large">{i18n.t('authentication:resetPassword')}</FormSubmit> <FormSubmit size="large">{i18n.t('authentication:resetPassword')}</FormSubmit>
</Form> </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' import { isPathMatchingRoute } from './isPathMatchingRoute.js'
@@ -8,7 +10,7 @@ export const getCustomViewByRoute = ({
}: { }: {
config: SanitizedConfig config: SanitizedConfig
currentRoute: string currentRoute: string
}): AdminViewComponent => { }): ViewFromConfig => {
const { const {
admin: { admin: {
components: { views }, components: { views },
@@ -22,7 +24,6 @@ export const getCustomViewByRoute = ({
views && views &&
typeof views === 'object' && typeof views === 'object' &&
Object.entries(views).find(([, view]) => { Object.entries(views).find(([, view]) => {
if (typeof view === 'object') {
return isPathMatchingRoute({ return isPathMatchingRoute({
currentRoute, currentRoute,
exact: view.exact, exact: view.exact,
@@ -30,8 +31,13 @@ export const getCustomViewByRoute = ({
sensitive: view.sensitive, sensitive: view.sensitive,
strict: view.strict, strict: view.strict,
}) })
}
})?.[1] })?.[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' import { formatAdminURL } from '@payloadcms/ui/shared'
@@ -27,7 +28,12 @@ const baseClasses = {
} }
type OneSegmentViews = { 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 = { const oneSegmentViews: OneSegmentViews = {
@@ -44,28 +50,31 @@ export const getViewFromConfig = ({
adminRoute, adminRoute,
config, config,
currentRoute, currentRoute,
importMap,
searchParams, searchParams,
segments, segments,
}: { }: {
adminRoute adminRoute: string
config: SanitizedConfig config: SanitizedConfig
currentRoute: string currentRoute: string
importMap: ImportMap
searchParams: { searchParams: {
[key: string]: string | string[] [key: string]: string | string[]
} }
segments: string[] segments: string[]
}): { }): {
DefaultView: AdminViewComponent DefaultView: ViewFromConfig
initPageOptions: Parameters<typeof initPage>[0] initPageOptions: Parameters<typeof initPage>[0]
templateClassName: string templateClassName: string
templateType: 'default' | 'minimal' templateType: 'default' | 'minimal'
} => { } => {
let ViewToRender: AdminViewComponent = null let ViewToRender: ViewFromConfig = null
let templateClassName: string let templateClassName: string
let templateType: 'default' | 'minimal' | undefined let templateType: 'default' | 'minimal' | undefined
const initPageOptions: Parameters<typeof initPage>[0] = { const initPageOptions: Parameters<typeof initPage>[0] = {
config, config,
importMap,
route: currentRoute, route: currentRoute,
searchParams, searchParams,
} }
@@ -78,7 +87,9 @@ export const getViewFromConfig = ({
switch (segments.length) { switch (segments.length) {
case 0: { case 0: {
if (currentRoute === adminRoute) { if (currentRoute === adminRoute) {
ViewToRender = Dashboard ViewToRender = {
Component: Dashboard,
}
templateClassName = 'dashboard' templateClassName = 'dashboard'
templateType = 'default' templateType = 'default'
initPageOptions.redirectUnauthenticatedUser = true initPageOptions.redirectUnauthenticatedUser = true
@@ -113,7 +124,9 @@ export const getViewFromConfig = ({
// --> /logout-inactivity // --> /logout-inactivity
// --> /unauthorized // --> /unauthorized
ViewToRender = oneSegmentViews[viewToRender] ViewToRender = {
Component: oneSegmentViews[viewToRender],
}
templateClassName = baseClasses[viewToRender] templateClassName = baseClasses[viewToRender]
templateType = 'minimal' templateType = 'minimal'
@@ -127,7 +140,9 @@ export const getViewFromConfig = ({
case 2: { case 2: {
if (segmentOne === 'reset') { if (segmentOne === 'reset') {
// --> /reset/:token // --> /reset/:token
ViewToRender = ResetPassword ViewToRender = {
Component: ResetPassword,
}
templateClassName = baseClasses[segmentTwo] templateClassName = baseClasses[segmentTwo]
templateType = 'minimal' templateType = 'minimal'
} }
@@ -135,13 +150,17 @@ export const getViewFromConfig = ({
if (isCollection) { if (isCollection) {
// --> /collections/:collectionSlug // --> /collections/:collectionSlug
initPageOptions.redirectUnauthenticatedUser = true initPageOptions.redirectUnauthenticatedUser = true
ViewToRender = ListView ViewToRender = {
Component: ListView,
}
templateClassName = `${segmentTwo}-list` templateClassName = `${segmentTwo}-list`
templateType = 'default' templateType = 'default'
} else if (isGlobal) { } else if (isGlobal) {
// --> /globals/:globalSlug // --> /globals/:globalSlug
initPageOptions.redirectUnauthenticatedUser = true initPageOptions.redirectUnauthenticatedUser = true
ViewToRender = DocumentView ViewToRender = {
Component: DocumentView,
}
templateClassName = 'global-edit' templateClassName = 'global-edit'
templateType = 'default' templateType = 'default'
} }
@@ -150,7 +169,9 @@ export const getViewFromConfig = ({
default: default:
if (segmentTwo === 'verify') { if (segmentTwo === 'verify') {
// --> /:collectionSlug/verify/:token // --> /:collectionSlug/verify/:token
ViewToRender = Verify ViewToRender = {
Component: Verify,
}
templateClassName = 'verify' templateClassName = 'verify'
templateType = 'minimal' templateType = 'minimal'
} else if (isCollection) { } else if (isCollection) {
@@ -161,7 +182,9 @@ export const getViewFromConfig = ({
// --> /collections/:collectionSlug/:id/versions/:versionId // --> /collections/:collectionSlug/:id/versions/:versionId
// --> /collections/:collectionSlug/:id/api // --> /collections/:collectionSlug/:id/api
initPageOptions.redirectUnauthenticatedUser = true initPageOptions.redirectUnauthenticatedUser = true
ViewToRender = DocumentView ViewToRender = {
Component: DocumentView,
}
templateClassName = `collection-default-edit` templateClassName = `collection-default-edit`
templateType = 'default' templateType = 'default'
} else if (isGlobal) { } else if (isGlobal) {
@@ -171,7 +194,9 @@ export const getViewFromConfig = ({
// --> /globals/:globalSlug/versions/:versionId // --> /globals/:globalSlug/versions/:versionId
// --> /globals/:globalSlug/api // --> /globals/:globalSlug/api
initPageOptions.redirectUnauthenticatedUser = true initPageOptions.redirectUnauthenticatedUser = true
ViewToRender = DocumentView ViewToRender = {
Component: DocumentView,
}
templateClassName = `global-edit` templateClassName = `global-edit`
templateType = 'default' templateType = 'default'
} }

View File

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

View File

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

View File

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

View File

@@ -8,11 +8,11 @@ export type CompareOption = {
} }
export type DefaultVersionsViewProps = { export type DefaultVersionsViewProps = {
doc: Document readonly doc: Document
docPermissions: CollectionPermission | GlobalPermission readonly docPermissions: CollectionPermission | GlobalPermission
initialComparisonDoc: Document readonly initialComparisonDoc: Document
latestDraftVersion?: string readonly latestDraftVersion?: string
latestPublishedVersion?: string readonly latestPublishedVersion?: string
localeOptions: OptionObject[] readonly localeOptions: OptionObject[]
versionID?: string 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 { getTranslation } from '@payloadcms/translations'
import { getUniqueListBy } from 'payload/shared' import { getUniqueListBy } from 'payload/shared'
import React from 'react' import React from 'react'
import type { Props } from '../types.js' import type { DiffComponentProps } from '../types.js'
import Label from '../../Label/index.js' import Label from '../../Label/index.js'
import RenderFieldsToDiff from '../../index.js' import RenderFieldsToDiff from '../../index.js'
@@ -12,7 +12,7 @@ import './index.scss'
const baseClass = 'iterable-diff' const baseClass = 'iterable-diff'
const Iterable: React.FC<Props> = ({ const Iterable: React.FC<DiffComponentProps> = ({
comparison, comparison,
diffComponents, diffComponents,
field, field,
@@ -28,12 +28,10 @@ const Iterable: React.FC<Props> = ({
return ( return (
<div className={baseClass}> <div className={baseClass}>
{'label' in field.fieldComponentProps && {'label' in field && field.label && typeof field.label !== 'function' && (
field.fieldComponentProps.label &&
typeof field.fieldComponentProps.label !== 'function' && (
<Label> <Label>
{locale && <span className={`${baseClass}__locale-label`}>{locale}</span>} {locale && <span className={`${baseClass}__locale-label`}>{locale}</span>}
{getTranslation(field.fieldComponentProps.label, i18n)} {getTranslation(field.label, i18n)}
</Label> </Label>
)} )}
{maxRows > 0 && ( {maxRows > 0 && (
@@ -42,13 +40,12 @@ const Iterable: React.FC<Props> = ({
const versionRow = version?.[i] || {} const versionRow = version?.[i] || {}
const comparisonRow = comparison?.[i] || {} const comparisonRow = comparison?.[i] || {}
let fieldMap: MappedField[] = [] let fields: ClientField[] = []
if (field.type === 'array' && 'fieldMap' in field.fieldComponentProps) if (field.type === 'array' && 'fields' in field) fields = field.fields
fieldMap = field.fieldComponentProps.fieldMap
if (field.type === 'blocks') { if (field.type === 'blocks') {
fieldMap = [ fields = [
// { // {
// name: 'blockType', // name: 'blockType',
// label: i18n.t('fields:blockType'), // label: i18n.t('fields:blockType'),
@@ -57,35 +54,25 @@ const Iterable: React.FC<Props> = ({
] ]
if (versionRow?.blockType === comparisonRow?.blockType) { if (versionRow?.blockType === comparisonRow?.blockType) {
const matchedBlock = ('blocks' in field.fieldComponentProps && const matchedBlock = ('blocks' in field &&
field.fieldComponentProps.blocks?.find( field.blocks?.find((block) => block.slug === versionRow?.blockType)) || {
(block) => block.slug === versionRow?.blockType, fields: [],
)) || {
fieldMap: [],
} }
fieldMap = [...fieldMap, ...matchedBlock.fieldMap] fields = [...fields, ...matchedBlock.fields]
} else { } else {
const matchedVersionBlock = ('blocks' in field.fieldComponentProps && const matchedVersionBlock = ('blocks' in field &&
field.fieldComponentProps.blocks?.find( field.blocks?.find((block) => block.slug === versionRow?.blockType)) || {
(block) => block.slug === versionRow?.blockType, fields: [],
)) || {
fieldMap: [],
} }
const matchedComparisonBlock = ('blocks' in field.fieldComponentProps && const matchedComparisonBlock = ('blocks' in field &&
field.fieldComponentProps.blocks?.find( field.blocks?.find((block) => block.slug === comparisonRow?.blockType)) || {
(block) => block.slug === comparisonRow?.blockType, fields: [],
)) || {
fieldMap: [],
} }
fieldMap = getUniqueListBy<MappedField>( fields = getUniqueListBy<ClientField>(
[ [...fields, ...matchedVersionBlock.fields, ...matchedComparisonBlock.fields],
...fieldMap,
...matchedVersionBlock.fieldMap,
...matchedComparisonBlock.fieldMap,
],
'name', 'name',
) )
} }
@@ -96,8 +83,8 @@ const Iterable: React.FC<Props> = ({
<RenderFieldsToDiff <RenderFieldsToDiff
comparison={comparisonRow} comparison={comparisonRow}
diffComponents={diffComponents} diffComponents={diffComponents}
fieldMap={fieldMap}
fieldPermissions={permissions} fieldPermissions={permissions}
fields={fields}
i18n={i18n} i18n={i18n}
locales={locales} locales={locales}
version={versionRow} version={versionRow}
@@ -111,8 +98,8 @@ const Iterable: React.FC<Props> = ({
<div className={`${baseClass}__no-rows`}> <div className={`${baseClass}__no-rows`}>
{i18n.t('version:noRowsFound', { {i18n.t('version:noRowsFound', {
label: label:
'labels' in field.fieldComponentProps && field.fieldComponentProps.labels?.plural 'labels' in field && field.labels?.plural
? getTranslation(field.fieldComponentProps.labels.plural, i18n) ? getTranslation(field.labels.plural, i18n)
: i18n.t('general:rows'), : i18n.t('general:rows'),
})} })}
</div> </div>

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