feat!: on demand rsc (#8364)

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

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

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

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

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

## Breaking Changes

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

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

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

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

    type Args = {
      children: React.ReactNode
    }

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

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

    export default Layout
    ```

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

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

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

## Breaking Changes

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

More details coming soon.

---------

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

View File

@@ -1,8 +1,10 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* 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 configPromise from '@payload-config'
import { RootLayout } from '@payloadcms/next/layouts'
// 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`
import type { ServerFunctionClient } from 'payload'
import config from '@payload-config'
import { handleServerFunctions, RootLayout } from '@payloadcms/next/layouts'
import React from 'react' import React from 'react'
import { importMap } from './admin/importMap.js' import { importMap } from './admin/importMap.js'
@@ -12,8 +14,17 @@ type Args = {
children: React.ReactNode children: React.ReactNode
} }
const serverFunction: ServerFunctionClient = async function (args) {
'use server'
return handleServerFunctions({
...args,
config,
importMap,
})
}
const Layout = ({ children }: Args) => ( const Layout = ({ children }: Args) => (
<RootLayout config={configPromise} importMap={importMap}> <RootLayout config={config} importMap={importMap} serverFunction={serverFunction}>
{children} {children}
</RootLayout> </RootLayout>
) )

View File

@@ -228,7 +228,6 @@ The following additional properties are also provided to the `field` prop:
| Property | Description | | 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`. | | **`_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` | | **`_schemaPath`** | A string representing the direct, static path to the [Field Config](../fields/overview), i.e. `myGroup.myArray.myField` |

View File

@@ -370,7 +370,12 @@ const yourEditorState: SerializedEditorState // <= your current editor state her
// Import editor state into your headless editor // Import editor state into your headless editor
try { try {
headlessEditor.setEditorState(headlessEditor.parseEditorState(yourEditorState)) // This should commit the editor state immediately headlessEditor.update(
() => {
headlessEditor.setEditorState(headlessEditor.parseEditorState(yourEditorState))
},
{ discrete: true }, // This should commit the editor state immediately
)
} catch (e) { } catch (e) {
logger.error({ err: e }, 'ERROR parsing editor state') logger.error({ err: e }, 'ERROR parsing editor state')
} }
@@ -382,8 +387,6 @@ headlessEditor.getEditorState().read(() => {
}) })
``` ```
The `.setEditorState()` function immediately updates your editor state. Thus, there's no need for the `discrete: true` flag when reading the state afterward.
## Lexical => Plain Text ## Lexical => Plain Text
Export content from the Lexical editor into plain text using these steps: Export content from the Lexical editor into plain text using these steps:
@@ -401,8 +404,13 @@ const yourEditorState: SerializedEditorState // <= your current editor state her
// Import editor state into your headless editor // Import editor state into your headless editor
try { try {
headlessEditor.setEditorState(headlessEditor.parseEditorState(yourEditorState)) // This should commit the editor state immediately headlessEditor.update(
} catch (e) { () => {
headlessEditor.setEditorState(headlessEditor.parseEditorState(yourEditorState))
},
{ discrete: true }, // This should commit the editor state immediately
)
} catch (e) {
logger.error({ err: e }, 'ERROR parsing editor state') logger.error({ err: e }, 'ERROR parsing editor state')
} }

View File

@@ -1,6 +1,6 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* 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 configPromise from '@payload-config' import config from '@payload-config'
import '@payloadcms/next/css' import '@payloadcms/next/css'
import { RootLayout } from '@payloadcms/next/layouts' import { RootLayout } from '@payloadcms/next/layouts'
import React from 'react' import React from 'react'
@@ -13,7 +13,7 @@ type Args = {
} }
const Layout = ({ children }: Args) => ( const Layout = ({ children }: Args) => (
<RootLayout config={configPromise} importMap={importMap}> <RootLayout config={config} importMap={importMap}>
{children} {children}
</RootLayout> </RootLayout>
) )

View File

@@ -1,8 +1,8 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import configPromise from '@payload-config' import configPromise from '@payload-config'
import '@payloadcms/next/css' import '@payloadcms/next/css'
import { RootLayout } from '@payloadcms/next/layouts' import { RootLayout } from '@payloadcms/next/layouts'
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import React from 'react' import React from 'react'
import './custom.scss' import './custom.scss'

View File

@@ -1,6 +1,6 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* 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 configPromise from '@payload-config' import config from '@payload-config'
import '@payloadcms/next/css' import '@payloadcms/next/css'
import { RootLayout } from '@payloadcms/next/layouts' import { RootLayout } from '@payloadcms/next/layouts'
import React from 'react' import React from 'react'
@@ -13,7 +13,7 @@ type Args = {
} }
const Layout = ({ children }: Args) => ( const Layout = ({ children }: Args) => (
<RootLayout config={configPromise} importMap={importMap}> <RootLayout config={config} importMap={importMap}>
{children} {children}
</RootLayout> </RootLayout>
) )

View File

@@ -19,6 +19,11 @@ const config = withBundleAnalyzer(
typescript: { typescript: {
ignoreBuildErrors: true, ignoreBuildErrors: true,
}, },
experimental: {
serverActions: {
bodySizeLimit: '5mb',
},
},
env: { env: {
PAYLOAD_CORE_DEV: 'true', PAYLOAD_CORE_DEV: 'true',
ROOT_DIR: path.resolve(dirname), ROOT_DIR: path.resolve(dirname),

View File

@@ -58,6 +58,7 @@
"dev:generate-importmap": "pnpm runts ./test/generateImportMap.ts", "dev:generate-importmap": "pnpm runts ./test/generateImportMap.ts",
"dev:generate-types": "pnpm runts ./test/generateTypes.ts", "dev:generate-types": "pnpm runts ./test/generateTypes.ts",
"dev:postgres": "cross-env PAYLOAD_DATABASE=postgres pnpm runts ./test/dev.ts", "dev:postgres": "cross-env PAYLOAD_DATABASE=postgres pnpm runts ./test/dev.ts",
"dev:prod": "cross-env NODE_OPTIONS=--no-deprecation tsx ./test/dev.ts --prod",
"dev:vercel-postgres": "cross-env PAYLOAD_DATABASE=vercel-postgres pnpm runts ./test/dev.ts", "dev:vercel-postgres": "cross-env PAYLOAD_DATABASE=vercel-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",
@@ -65,12 +66,12 @@
"docker:stop": "docker compose -f packages/plugin-cloud-storage/docker-compose.yml down", "docker:stop": "docker compose -f packages/plugin-cloud-storage/docker-compose.yml down",
"force:build": "pnpm run build:core:force", "force:build": "pnpm run build:core:force",
"lint": "turbo run lint --concurrency 1 --continue", "lint": "turbo run lint --concurrency 1 --continue",
"lint-staged": "lint-staged", "lint-staged": "node ./scripts/run-lint-staged.js",
"lint:fix": "turbo run lint:fix --concurrency 1 --continue", "lint:fix": "turbo run lint:fix --concurrency 1 --continue",
"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",
"prepare-run-test-against-prod": "pnpm bf && rm -rf test/packed && rm -rf test/node_modules && rm -f test/pnpm-lock.yaml && pnpm run script:pack --all --no-build --dest test/packed && pnpm runts test/setupProd.ts && cd test && pnpm i --ignore-workspace && cd ..", "prepare-run-test-against-prod": "pnpm bf && rm -rf test/packed && rm -rf test/node_modules && rm -rf app && rm -f test/pnpm-lock.yaml && pnpm run script:pack --all --no-build --dest test/packed && pnpm runts test/setupProd.ts && cd test && pnpm i --ignore-workspace && cd ..",
"prepare-run-test-against-prod:ci": "rm -rf test/node_modules && rm -f test/pnpm-lock.yaml && pnpm run script:pack --all --no-build --dest test/packed && pnpm runts test/setupProd.ts && cd test && pnpm i --ignore-workspace && cd ..", "prepare-run-test-against-prod:ci": "rm -rf test/node_modules && rm -rf app && rm -f test/pnpm-lock.yaml && pnpm run script:pack --all --no-build --dest test/packed && pnpm runts test/setupProd.ts && cd test && pnpm i --ignore-workspace && cd ..",
"reinstall": "pnpm clean:all && pnpm install", "reinstall": "pnpm clean:all && pnpm install",
"release:alpha": "pnpm runts ./scripts/release.ts --bump prerelease --tag alpha", "release:alpha": "pnpm runts ./scripts/release.ts --bump prerelease --tag alpha",
"release:beta": "pnpm runts ./scripts/release.ts --bump prerelease --tag beta", "release:beta": "pnpm runts ./scripts/release.ts --bump prerelease --tag beta",

View File

@@ -0,0 +1,48 @@
import type { QueryOptions } from 'mongoose'
import type { CountGlobalVersions, PayloadRequest } from 'payload'
import { flattenWhereToOperators } from 'payload'
import type { MongooseAdapter } from './index.js'
import { withSession } from './withSession.js'
export const countGlobalVersions: CountGlobalVersions = async function countGlobalVersions(
this: MongooseAdapter,
{ global, locale, req = {} as PayloadRequest, where },
) {
const Model = this.versions[global]
const options: QueryOptions = await withSession(this, req)
let hasNearConstraint = false
if (where) {
const constraints = flattenWhereToOperators(where)
hasNearConstraint = constraints.some((prop) => Object.keys(prop).some((key) => key === 'near'))
}
const query = await Model.buildQuery({
locale,
payload: this.payload,
where,
})
// useEstimatedCount is faster, but not accurate, as it ignores any filters. It is thus set to true if there are no filters.
const useEstimatedCount = hasNearConstraint || !query || Object.keys(query).length === 0
if (!useEstimatedCount && Object.keys(query).length === 0 && this.disableIndexHints !== true) {
// Improve the performance of the countDocuments query which is used if useEstimatedCount is set to false by adding
// a hint. By default, if no hint is provided, MongoDB does not use an indexed field to count the returned documents,
// which makes queries very slow. This only happens when no query (filter) is provided. If one is provided, it uses
// the correct indexed field
options.hint = {
_id: 1,
}
}
const result = await Model.countDocuments(query, options)
return {
totalDocs: result,
}
}

View File

@@ -0,0 +1,48 @@
import type { QueryOptions } from 'mongoose'
import type { CountVersions, PayloadRequest } from 'payload'
import { flattenWhereToOperators } from 'payload'
import type { MongooseAdapter } from './index.js'
import { withSession } from './withSession.js'
export const countVersions: CountVersions = async function countVersions(
this: MongooseAdapter,
{ collection, locale, req = {} as PayloadRequest, where },
) {
const Model = this.versions[collection]
const options: QueryOptions = await withSession(this, req)
let hasNearConstraint = false
if (where) {
const constraints = flattenWhereToOperators(where)
hasNearConstraint = constraints.some((prop) => Object.keys(prop).some((key) => key === 'near'))
}
const query = await Model.buildQuery({
locale,
payload: this.payload,
where,
})
// useEstimatedCount is faster, but not accurate, as it ignores any filters. It is thus set to true if there are no filters.
const useEstimatedCount = hasNearConstraint || !query || Object.keys(query).length === 0
if (!useEstimatedCount && Object.keys(query).length === 0 && this.disableIndexHints !== true) {
// Improve the performance of the countDocuments query which is used if useEstimatedCount is set to false by adding
// a hint. By default, if no hint is provided, MongoDB does not use an indexed field to count the returned documents,
// which makes queries very slow. This only happens when no query (filter) is provided. If one is provided, it uses
// the correct indexed field
options.hint = {
_id: 1,
}
}
const result = await Model.countDocuments(query, options)
return {
totalDocs: result,
}
}

View File

@@ -12,6 +12,8 @@ import type { CollectionModel, GlobalModel, MigrateDownArgs, MigrateUpArgs } fro
import { connect } from './connect.js' import { connect } from './connect.js'
import { count } from './count.js' import { count } from './count.js'
import { countGlobalVersions } from './countGlobalVersions.js'
import { countVersions } from './countVersions.js'
import { create } from './create.js' import { create } from './create.js'
import { createGlobal } from './createGlobal.js' import { createGlobal } from './createGlobal.js'
import { createGlobalVersion } from './createGlobalVersion.js' import { createGlobalVersion } from './createGlobalVersion.js'
@@ -154,7 +156,6 @@ export function mongooseAdapter({
collections: {}, collections: {},
connection: undefined, connection: undefined,
connectOptions: connectOptions || {}, connectOptions: connectOptions || {},
count,
disableIndexHints, disableIndexHints,
globals: undefined, globals: undefined,
mongoMemoryServer, mongoMemoryServer,
@@ -166,6 +167,9 @@ export function mongooseAdapter({
beginTransaction: transactionOptions === false ? defaultBeginTransaction() : beginTransaction, beginTransaction: transactionOptions === false ? defaultBeginTransaction() : beginTransaction,
commitTransaction, commitTransaction,
connect, connect,
count,
countGlobalVersions,
countVersions,
create, create,
createGlobal, createGlobal,
createGlobalVersion, createGlobalVersion,

View File

@@ -1,8 +1,6 @@
import type { PipelineStage } from 'mongoose' import type { PipelineStage } from 'mongoose'
import type { CollectionSlug, JoinQuery, SanitizedCollectionConfig, Where } from 'payload' import type { CollectionSlug, JoinQuery, SanitizedCollectionConfig, Where } from 'payload'
import { combineQueries } from 'payload'
import type { MongooseAdapter } from '../index.js' import type { MongooseAdapter } from '../index.js'
import { buildSortParam } from '../queries/buildSortParam.js' import { buildSortParam } from '../queries/buildSortParam.js'
@@ -60,11 +58,11 @@ export const buildJoinAggregation = async ({
for (const join of joinConfig[slug]) { for (const join of joinConfig[slug]) {
const joinModel = adapter.collections[join.field.collection] const joinModel = adapter.collections[join.field.collection]
if (projection && !projection[join.schemaPath]) { if (projection && !projection[join.joinPath]) {
continue continue
} }
if (joins?.[join.schemaPath] === false) { if (joins?.[join.joinPath] === false) {
continue continue
} }
@@ -72,7 +70,7 @@ export const buildJoinAggregation = async ({
limit: limitJoin = join.field.defaultLimit ?? 10, limit: limitJoin = join.field.defaultLimit ?? 10,
sort: sortJoin = join.field.defaultSort || collectionConfig.defaultSort, sort: sortJoin = join.field.defaultSort || collectionConfig.defaultSort,
where: whereJoin, where: whereJoin,
} = joins?.[join.schemaPath] || {} } = joins?.[join.joinPath] || {}
const sort = buildSortParam({ const sort = buildSortParam({
config: adapter.payload.config, config: adapter.payload.config,
@@ -105,7 +103,7 @@ export const buildJoinAggregation = async ({
if (adapter.payload.config.localization && locale === 'all') { if (adapter.payload.config.localization && locale === 'all') {
adapter.payload.config.localization.localeCodes.forEach((code) => { adapter.payload.config.localization.localeCodes.forEach((code) => {
const as = `${versions ? `version.${join.schemaPath}` : join.schemaPath}${code}` const as = `${versions ? `version.${join.joinPath}` : join.joinPath}${code}`
aggregate.push( aggregate.push(
{ {
@@ -146,7 +144,7 @@ export const buildJoinAggregation = async ({
} else { } else {
const localeSuffix = const localeSuffix =
join.field.localized && adapter.payload.config.localization && locale ? `.${locale}` : '' join.field.localized && adapter.payload.config.localization && locale ? `.${locale}` : ''
const as = `${versions ? `version.${join.schemaPath}` : join.schemaPath}${localeSuffix}` const as = `${versions ? `version.${join.joinPath}` : join.joinPath}${localeSuffix}`
aggregate.push( aggregate.push(
{ {

View File

@@ -19,8 +19,8 @@ export const handleError = ({
collection, collection,
errors: [ errors: [
{ {
field: Object.keys(error.keyValue)[0],
message: req.t('error:valueMustBeUnique'), message: req.t('error:valueMustBeUnique'),
path: Object.keys(error.keyValue)[0],
}, },
], ],
global, global,

View File

@@ -4,6 +4,8 @@ import {
beginTransaction, beginTransaction,
commitTransaction, commitTransaction,
count, count,
countGlobalVersions,
countVersions,
create, create,
createGlobal, createGlobal,
createGlobalVersion, createGlobalVersion,
@@ -126,6 +128,8 @@ export function postgresAdapter(args: Args): DatabaseAdapterObj<PostgresAdapter>
convertPathToJSONTraversal, convertPathToJSONTraversal,
count, count,
countDistinct, countDistinct,
countGlobalVersions,
countVersions,
create, create,
createGlobal, createGlobal,
createGlobalVersion, createGlobalVersion,

View File

@@ -5,6 +5,8 @@ import {
beginTransaction, beginTransaction,
commitTransaction, commitTransaction,
count, count,
countGlobalVersions,
countVersions,
create, create,
createGlobal, createGlobal,
createGlobalVersion, createGlobalVersion,
@@ -114,6 +116,8 @@ export function sqliteAdapter(args: Args): DatabaseAdapterObj<SQLiteAdapter> {
convertPathToJSONTraversal, convertPathToJSONTraversal,
count, count,
countDistinct, countDistinct,
countGlobalVersions,
countVersions,
create, create,
createGlobal, createGlobal,
createGlobalVersion, createGlobalVersion,

View File

@@ -4,6 +4,8 @@ import {
beginTransaction, beginTransaction,
commitTransaction, commitTransaction,
count, count,
countGlobalVersions,
countVersions,
create, create,
createGlobal, createGlobal,
createGlobalVersion, createGlobalVersion,
@@ -127,6 +129,8 @@ export function vercelPostgresAdapter(args: Args = {}): DatabaseAdapterObj<Verce
convertPathToJSONTraversal, convertPathToJSONTraversal,
count, count,
countDistinct, countDistinct,
countGlobalVersions,
countVersions,
create, create,
createGlobal, createGlobal,
createGlobalVersion, createGlobalVersion,

View File

@@ -0,0 +1,42 @@
import type { CountGlobalVersions, SanitizedGlobalConfig } from 'payload'
import { buildVersionGlobalFields } from 'payload'
import toSnakeCase from 'to-snake-case'
import type { DrizzleAdapter } from './types.js'
import buildQuery from './queries/buildQuery.js'
export const countGlobalVersions: CountGlobalVersions = async function countGlobalVersions(
this: DrizzleAdapter,
{ global, locale, req, where: whereArg },
) {
const globalConfig: SanitizedGlobalConfig = this.payload.globals.config.find(
({ slug }) => slug === global,
)
const tableName = this.tableNameMap.get(
`_${toSnakeCase(globalConfig.slug)}${this.versionsSuffix}`,
)
const db = this.sessions[await req?.transactionID]?.db || this.drizzle
const fields = buildVersionGlobalFields(this.payload.config, globalConfig)
const { joins, where } = buildQuery({
adapter: this,
fields,
locale,
tableName,
where: whereArg,
})
const countResult = await this.countDistinct({
db,
joins,
tableName,
where,
})
return { totalDocs: countResult }
}

View File

@@ -0,0 +1,40 @@
import type { CountVersions, SanitizedCollectionConfig } from 'payload'
import { buildVersionCollectionFields } from 'payload'
import toSnakeCase from 'to-snake-case'
import type { DrizzleAdapter } from './types.js'
import buildQuery from './queries/buildQuery.js'
export const countVersions: CountVersions = async function countVersions(
this: DrizzleAdapter,
{ collection, locale, req, where: whereArg },
) {
const collectionConfig: SanitizedCollectionConfig = this.payload.collections[collection].config
const tableName = this.tableNameMap.get(
`_${toSnakeCase(collectionConfig.slug)}${this.versionsSuffix}`,
)
const db = this.sessions[await req?.transactionID]?.db || this.drizzle
const fields = buildVersionCollectionFields(this.payload.config, collectionConfig)
const { joins, where } = buildQuery({
adapter: this,
fields,
locale,
tableName,
where: whereArg,
})
const countResult = await this.countDistinct({
db,
joins,
tableName,
where,
})
return { totalDocs: countResult }
}

View File

@@ -1,4 +1,6 @@
export { count } from './count.js' export { count } from './count.js'
export { countGlobalVersions } from './countGlobalVersions.js'
export { countVersions } from './countVersions.js'
export { create } from './create.js' export { create } from './create.js'
export { createGlobal } from './createGlobal.js' export { createGlobal } from './createGlobal.js'
export { createGlobalVersion } from './createGlobalVersion.js' export { createGlobalVersion } from './createGlobalVersion.js'

View File

@@ -391,8 +391,8 @@ export const upsertRow = async <T extends Record<string, unknown> | TypeWithID>(
id, id,
errors: [ errors: [
{ {
field: fieldName,
message: req.t('error:valueMustBeUnique'), message: req.t('error:valueMustBeUnique'),
path: fieldName,
}, },
], ],
}, },

View File

@@ -1,7 +1,8 @@
import type { DocumentTabConfig, DocumentTabProps } from 'payload' import type { DocumentTabConfig, DocumentTabProps } from 'payload'
import type React from 'react'
import { getCreateMappedComponent, RenderComponent } from '@payloadcms/ui/shared' import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
import React, { Fragment } from 'react' import { Fragment } from 'react'
import './index.scss' import './index.scss'
import { DocumentTabLink } from './TabLink.js' import { DocumentTabLink } from './TabLink.js'
@@ -59,17 +60,6 @@ export const DocumentTab: React.FC<
}) })
: 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}
@@ -82,12 +72,21 @@ export const DocumentTab: React.FC<
> >
<span className={`${baseClass}__label`}> <span className={`${baseClass}__label`}>
{labelToRender} {labelToRender}
{mappedPin && ( {Pill || Pill_Component ? (
<Fragment> <Fragment>
&nbsp; &nbsp;
<RenderComponent mappedComponent={mappedPin} /> <RenderServerComponent
Component={Pill}
Fallback={Pill_Component}
importMap={payload.importMap}
serverProps={{
i18n,
payload,
permissions,
}}
/>
</Fragment> </Fragment>
)} ) : null}
</span> </span>
</DocumentTabLink> </DocumentTabLink>
) )

View File

@@ -6,7 +6,7 @@ import type {
SanitizedGlobalConfig, SanitizedGlobalConfig,
} from 'payload' } from 'payload'
import { getCreateMappedComponent, RenderComponent } from '@payloadcms/ui/shared' import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
import React from 'react' import React from 'react'
import { getCustomViews } from './getCustomViews.js' import { getCustomViews } from './getCustomViews.js'
@@ -80,33 +80,21 @@ export const DocumentTabs: React.FC<{
const { path, tab } = CustomView const { path, tab } = CustomView
if (tab.Component) { if (tab.Component) {
const createMappedComponent = getCreateMappedComponent({
importMap: payload.importMap,
serverProps: {
i18n,
payload,
permissions,
...props,
key: `tab-custom-${index}`,
path,
},
})
const mappedTab = createMappedComponent(
tab.Component,
undefined,
undefined,
'tab.Component',
)
return ( return (
<RenderComponent <RenderServerComponent
clientProps={{ clientProps={{
key: `tab-custom-${index}`,
path, path,
}} }}
Component={tab.Component}
importMap={payload.importMap}
key={`tab-custom-${index}`} key={`tab-custom-${index}`}
mappedComponent={mappedTab} serverProps={{
collectionConfig,
globalConfig,
i18n,
payload,
permissions,
}}
/> />
) )
} }
@@ -121,6 +109,7 @@ export const DocumentTabs: React.FC<{
/> />
) )
} }
return null return null
})} })}
</ul> </ul>

View File

@@ -5,14 +5,11 @@ import React from 'react'
import { baseClass } from '../../Tab/index.js' import { baseClass } from '../../Tab/index.js'
export const VersionsPill: React.FC = () => { export const VersionsPill: React.FC = () => {
const { versions } = useDocumentInfo() const { versionCount } = useDocumentInfo()
// don't count snapshots if (!versionCount) {
const totalVersions = versions?.docs.filter((version) => !version.snapshot).length || 0
if (!versions?.totalDocs) {
return null return null
} }
return <span className={`${baseClass}__count`}>{totalVersions}</span> return <span className={`${baseClass}__count`}>{versionCount}</span>
} }

View File

@@ -7,7 +7,7 @@ import type {
} from 'payload' } from 'payload'
import { Gutter, RenderTitle } from '@payloadcms/ui' import { Gutter, RenderTitle } from '@payloadcms/ui'
import React, { Fragment } from 'react' import React from 'react'
import './index.scss' import './index.scss'
import { DocumentTabs } from './Tabs/index.js' import { DocumentTabs } from './Tabs/index.js'
@@ -16,32 +16,25 @@ const baseClass = `doc-header`
export const DocumentHeader: React.FC<{ export const DocumentHeader: React.FC<{
collectionConfig?: SanitizedCollectionConfig collectionConfig?: SanitizedCollectionConfig
customHeader?: React.ReactNode
globalConfig?: SanitizedGlobalConfig globalConfig?: SanitizedGlobalConfig
hideTabs?: boolean hideTabs?: boolean
i18n: I18n i18n: I18n
payload: Payload payload: Payload
permissions: Permissions permissions: Permissions
}> = (props) => { }> = (props) => {
const { collectionConfig, customHeader, globalConfig, hideTabs, i18n, payload, permissions } = const { collectionConfig, globalConfig, hideTabs, i18n, payload, permissions } = props
props
return ( return (
<Gutter className={baseClass}> <Gutter className={baseClass}>
{customHeader && customHeader} <RenderTitle className={`${baseClass}__title`} />
{!customHeader && ( {!hideTabs && (
<Fragment> <DocumentTabs
<RenderTitle className={`${baseClass}__title`} /> collectionConfig={collectionConfig}
{!hideTabs && ( globalConfig={globalConfig}
<DocumentTabs i18n={i18n}
collectionConfig={collectionConfig} payload={payload}
globalConfig={globalConfig} permissions={permissions}
i18n={i18n} />
payload={payload}
permissions={permissions}
/>
)}
</Fragment>
)} )}
</Gutter> </Gutter>
) )

View File

@@ -1,114 +0,0 @@
'use client'
import type { FieldPermissions, LoginWithUsernameOptions } from 'payload'
import { EmailField, RenderFields, TextField, useTranslation } from '@payloadcms/ui'
import { email, username } from 'payload/shared'
import React from 'react'
type Props = {
readonly loginWithUsername?: false | LoginWithUsernameOptions
}
function EmailFieldComponent(props: Props) {
const { loginWithUsername } = props
const { t } = useTranslation()
const requireEmail = !loginWithUsername || (loginWithUsername && loginWithUsername.requireEmail)
const showEmailField =
!loginWithUsername || loginWithUsername?.requireEmail || loginWithUsername?.allowEmailLogin
if (showEmailField) {
return (
<EmailField
autoComplete="off"
field={{
name: 'email',
label: t('general:email'),
required: requireEmail,
}}
validate={email}
/>
)
}
return null
}
function UsernameFieldComponent(props: Props) {
const { loginWithUsername } = props
const { t } = useTranslation()
const requireUsername = loginWithUsername && loginWithUsername.requireUsername
const showUsernameField = Boolean(loginWithUsername)
if (showUsernameField) {
return (
<TextField
field={{
name: 'username',
label: t('authentication:username'),
required: requireUsername,
}}
validate={username}
/>
)
}
return null
}
type RenderEmailAndUsernameFieldsProps = {
className?: string
loginWithUsername?: false | LoginWithUsernameOptions
operation?: 'create' | 'update'
permissions?: {
[fieldName: string]: FieldPermissions
}
readOnly: boolean
}
export function RenderEmailAndUsernameFields(props: RenderEmailAndUsernameFieldsProps) {
const { className, loginWithUsername, operation, permissions, readOnly } = props
return (
<RenderFields
className={className}
fields={[
{
name: 'email',
type: 'text',
admin: {
autoComplete: 'off',
components: {
Field: {
type: 'client',
Component: null,
RenderedComponent: <EmailFieldComponent loginWithUsername={loginWithUsername} />,
},
},
},
localized: false,
},
{
name: 'username',
type: 'text',
admin: {
components: {
Field: {
type: 'client',
Component: null,
RenderedComponent: <UsernameFieldComponent loginWithUsername={loginWithUsername} />,
},
},
},
localized: false,
},
]}
forceRender
operation={operation}
path=""
permissions={permissions}
readOnly={readOnly}
schemaPath=""
/>
)
}

View File

@@ -1,6 +1,7 @@
import type { ServerProps } from 'payload' import type { ServerProps } from 'payload'
import { getCreateMappedComponent, PayloadLogo, RenderComponent } from '@payloadcms/ui/shared' import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
import { PayloadLogo } 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,20 +17,20 @@ export const Logo: React.FC<ServerProps> = (props) => {
} = {}, } = {},
} = payload.config } = payload.config
const createMappedComponent = getCreateMappedComponent({ return (
importMap: payload.importMap, <RenderServerComponent
serverProps: { Component={CustomLogo}
i18n, Fallback={PayloadLogo}
locale, importMap={payload.importMap}
params, serverProps={{
payload, i18n,
permissions, locale,
searchParams, params,
user, payload,
}, permissions,
}) searchParams,
user,
const mappedCustomLogo = createMappedComponent(CustomLogo, undefined, PayloadLogo, 'CustomLogo') }}
/>
return <RenderComponent mappedComponent={mappedCustomLogo} /> )
} }

View File

@@ -1,32 +1,23 @@
'use client' 'use client'
import type { EntityToGroup } from '@payloadcms/ui/shared' import type { groupNavItems } from '@payloadcms/ui/shared'
import { getTranslation } from '@payloadcms/translations' import { getTranslation } from '@payloadcms/translations'
import { import { NavGroup, useConfig, useNav, useTranslation } from '@payloadcms/ui'
NavGroup, import { EntityType, formatAdminURL } from '@payloadcms/ui/shared'
useAuth,
useConfig,
useEntityVisibility,
useNav,
useTranslation,
} from '@payloadcms/ui'
import { EntityType, formatAdminURL, groupNavItems } from '@payloadcms/ui/shared'
import LinkWithDefault from 'next/link.js' import LinkWithDefault from 'next/link.js'
import { usePathname } from 'next/navigation.js' import { usePathname } from 'next/navigation.js'
import React, { Fragment } from 'react' import React, { Fragment } from 'react'
const baseClass = 'nav' const baseClass = 'nav'
export const DefaultNavClient: React.FC = () => { export const DefaultNavClient: React.FC<{
const { permissions } = useAuth() groups: ReturnType<typeof groupNavItems>
const { isEntityVisible } = useEntityVisibility() }> = ({ groups }) => {
const pathname = usePathname() const pathname = usePathname()
const { const {
config: { config: {
collections,
globals,
routes: { admin: adminRoute }, routes: { admin: adminRoute },
}, },
} = useConfig() } = useConfig()
@@ -34,53 +25,23 @@ export const DefaultNavClient: React.FC = () => {
const { i18n } = useTranslation() const { i18n } = useTranslation()
const { navOpen } = useNav() const { navOpen } = useNav()
const groups = groupNavItems(
[
...collections
.filter(({ slug }) => isEntityVisible({ collectionSlug: slug }))
.map((collection) => {
const entityToGroup: EntityToGroup = {
type: EntityType.collection,
entity: collection,
}
return entityToGroup
}),
...globals
.filter(({ slug }) => isEntityVisible({ globalSlug: slug }))
.map((global) => {
const entityToGroup: EntityToGroup = {
type: EntityType.global,
entity: global,
}
return entityToGroup
}),
],
permissions,
i18n,
)
return ( return (
<Fragment> <Fragment>
{groups.map(({ entities, label }, key) => { {groups.map(({ entities, label }, key) => {
return ( return (
<NavGroup key={key} label={label}> <NavGroup key={key} label={label}>
{entities.map(({ type, entity }, i) => { {entities.map(({ slug, type, label }, i) => {
let entityLabel: string
let href: string let href: string
let id: string let id: string
if (type === EntityType.collection) { if (type === EntityType.collection) {
href = formatAdminURL({ adminRoute, path: `/collections/${entity.slug}` }) href = formatAdminURL({ adminRoute, path: `/collections/${slug}` })
entityLabel = getTranslation(entity.labels.plural, i18n) id = `nav-${slug}`
id = `nav-${entity.slug}`
} }
if (type === EntityType.global) { if (type === EntityType.global) {
href = formatAdminURL({ adminRoute, path: `/globals/${entity.slug}` }) href = formatAdminURL({ adminRoute, path: `/globals/${slug}` })
entityLabel = getTranslation(entity.label, i18n) id = `nav-global-${slug}`
id = `nav-global-${entity.slug}`
} }
const Link = (LinkWithDefault.default || const Link = (LinkWithDefault.default ||
@@ -102,7 +63,7 @@ export const DefaultNavClient: React.FC = () => {
tabIndex={!navOpen ? -1 : undefined} tabIndex={!navOpen ? -1 : undefined}
> >
{activeCollection && <div className={`${baseClass}__link-indicator`} />} {activeCollection && <div className={`${baseClass}__link-indicator`} />}
<span className={`${baseClass}__link-label`}>{entityLabel}</span> <span className={`${baseClass}__link-label`}>{getTranslation(label, i18n)}</span>
</LinkElement> </LinkElement>
) )
})} })}

View File

@@ -1,7 +1,9 @@
import type { EntityToGroup } from '@payloadcms/ui/shared'
import type { ServerProps } from 'payload' import type { ServerProps } from 'payload'
import { Logout } from '@payloadcms/ui' import { Logout } from '@payloadcms/ui'
import { getCreateMappedComponent, RenderComponent } from '@payloadcms/ui/shared' import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
import { EntityType, groupNavItems } from '@payloadcms/ui/shared'
import React from 'react' import React from 'react'
import './index.scss' import './index.scss'
@@ -15,7 +17,7 @@ import { DefaultNavClient } from './index.client.js'
export type NavProps = ServerProps export type NavProps = ServerProps
export const DefaultNav: React.FC<NavProps> = (props) => { export const DefaultNav: React.FC<NavProps> = (props) => {
const { i18n, locale, params, payload, permissions, searchParams, user } = props const { i18n, locale, params, payload, permissions, searchParams, user, visibleEntities } = props
if (!payload?.config) { if (!payload?.config) {
return null return null
@@ -23,44 +25,82 @@ export const DefaultNav: React.FC<NavProps> = (props) => {
const { const {
admin: { admin: {
components: { afterNavLinks, beforeNavLinks }, components: { afterNavLinks, beforeNavLinks, logout },
}, },
collections,
globals,
} = payload.config } = payload.config
const createMappedComponent = getCreateMappedComponent({ const groups = groupNavItems(
importMap: payload.importMap, [
serverProps: { ...collections
i18n, .filter(({ slug }) => visibleEntities.collections.includes(slug))
locale, .map(
params, (collection) =>
payload, ({
permissions, type: EntityType.collection,
searchParams, entity: collection,
user, }) satisfies EntityToGroup,
}, ),
}) ...globals
.filter(({ slug }) => visibleEntities.globals.includes(slug))
const mappedBeforeNavLinks = createMappedComponent( .map(
beforeNavLinks, (global) =>
undefined, ({
undefined, type: EntityType.global,
'beforeNavLinks', entity: global,
) }) satisfies EntityToGroup,
const mappedAfterNavLinks = createMappedComponent( ),
afterNavLinks, ],
undefined, permissions,
undefined, i18n,
'afterNavLinks',
) )
return ( return (
<NavWrapper baseClass={baseClass}> <NavWrapper baseClass={baseClass}>
<nav className={`${baseClass}__wrap`}> <nav className={`${baseClass}__wrap`}>
<RenderComponent mappedComponent={mappedBeforeNavLinks} /> <RenderServerComponent
<DefaultNavClient /> Component={beforeNavLinks}
<RenderComponent mappedComponent={mappedAfterNavLinks} /> importMap={payload.importMap}
serverProps={{
i18n,
locale,
params,
payload,
permissions,
searchParams,
user,
}}
/>
<DefaultNavClient groups={groups} />
<RenderServerComponent
Component={afterNavLinks}
importMap={payload.importMap}
serverProps={{
i18n,
locale,
params,
payload,
permissions,
searchParams,
user,
}}
/>
<div className={`${baseClass}__controls`}> <div className={`${baseClass}__controls`}>
<Logout /> <RenderServerComponent
Component={logout?.Button}
Fallback={Logout}
importMap={payload.importMap}
serverProps={{
i18n,
locale,
params,
payload,
permissions,
searchParams,
user,
}}
/>
</div> </div>
</nav> </nav>
<div className={`${baseClass}__header`}> <div className={`${baseClass}__header`}>

View File

@@ -1 +1,2 @@
export { metadata, RootLayout } from '../layouts/Root/index.js' export { metadata, RootLayout } from '../layouts/Root/index.js'
export { handleServerFunctions } from '../utilities/handleServerFunctions.js'

View File

@@ -1,3 +1,4 @@
// NOTICE: Server-only utilities, do not import anything client-side here.
export { addDataAndFileToRequest } from '../utilities/addDataAndFileToRequest.js' export { addDataAndFileToRequest } from '../utilities/addDataAndFileToRequest.js'
export { addLocalesToRequestFromData, sanitizeLocales } from '../utilities/addLocalesToRequest.js' export { addLocalesToRequestFromData, sanitizeLocales } from '../utilities/addLocalesToRequest.js'
export { createPayloadRequest } from '../utilities/createPayloadRequest.js' export { createPayloadRequest } from '../utilities/createPayloadRequest.js'

View File

@@ -1,4 +1,2 @@
export { DefaultEditView as EditView } from '../views/Edit/Default/index.js'
export { DefaultListView as ListView } from '../views/List/Default/index.js'
export { NotFoundPage } from '../views/NotFound/index.js' export { NotFoundPage } from '../views/NotFound/index.js'
export { generatePageMetadata, type GenerateViewMetadata, RootPage } from '../views/Root/index.js' export { generatePageMetadata, type GenerateViewMetadata, RootPage } from '../views/Root/index.js'

View File

@@ -22,6 +22,7 @@ type ProcessMultipart = (args: {
export const processMultipart: ProcessMultipart = async ({ options, request }) => { export const processMultipart: ProcessMultipart = async ({ options, request }) => {
let parsingRequest = true let parsingRequest = true
let shouldAbortProccessing = false
let fileCount = 0 let fileCount = 0
let filesCompleted = 0 let filesCompleted = 0
let allFilesHaveResolved: (value?: unknown) => void let allFilesHaveResolved: (value?: unknown) => void
@@ -42,14 +43,16 @@ export const processMultipart: ProcessMultipart = async ({ options, request }) =
headersObject[name] = value headersObject[name] = value
}) })
function abortAndDestroyFile(file: Readable, err: APIError) { const reader = request.body.getReader()
file.destroy()
parsingRequest = false
failedResolvingFiles(err)
}
const busboy = Busboy({ ...options, headers: headersObject }) const busboy = Busboy({ ...options, headers: headersObject })
function abortAndDestroyFile(file: Readable, err: APIError) {
file.destroy()
shouldAbortProccessing = true
failedResolvingFiles(err)
}
// Build multipart req.body fields // Build multipart req.body fields
busboy.on('field', (field, val) => { busboy.on('field', (field, val) => {
result.fields = buildFields(result.fields, field, val) result.fields = buildFields(result.fields, field, val)
@@ -136,7 +139,7 @@ export const processMultipart: ProcessMultipart = async ({ options, request }) =
mimetype: mime, mimetype: mime,
size, size,
tempFilePath: getFilePath(), tempFilePath: getFilePath(),
truncated: Boolean('truncated' in file && file.truncated), truncated: Boolean('truncated' in file && file.truncated) || false,
}, },
options, options,
), ),
@@ -164,8 +167,6 @@ export const processMultipart: ProcessMultipart = async ({ options, request }) =
uploadTimer.set() uploadTimer.set()
}) })
// TODO: Valid eslint error - this will likely be a floating promise. Evaluate if we need to handle this differently.
busboy.on('finish', async () => { busboy.on('finish', async () => {
debugLog(options, `Busboy finished parsing request.`) debugLog(options, `Busboy finished parsing request.`)
if (options.parseNested) { if (options.parseNested) {
@@ -190,14 +191,10 @@ export const processMultipart: ProcessMultipart = async ({ options, request }) =
'error', 'error',
(err = new APIError('Busboy error parsing multipart request', httpStatus.BAD_REQUEST)) => { (err = new APIError('Busboy error parsing multipart request', httpStatus.BAD_REQUEST)) => {
debugLog(options, `Busboy error`) debugLog(options, `Busboy error`)
parsingRequest = false
throw err throw err
}, },
) )
const reader = request.body.getReader()
// Start parsing request
while (parsingRequest) { while (parsingRequest) {
const { done, value } = await reader.read() const { done, value } = await reader.read()
@@ -205,7 +202,7 @@ export const processMultipart: ProcessMultipart = async ({ options, request }) =
parsingRequest = false parsingRequest = false
} }
if (value) { if (value && !shouldAbortProccessing) {
busboy.write(value) busboy.write(value)
} }
} }

View File

@@ -0,0 +1,30 @@
import type { Config, ImportMap } from 'payload'
import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
import '@payloadcms/ui/scss/app.scss'
import React from 'react'
type Args = {
readonly children: React.ReactNode
readonly importMap: ImportMap
readonly providers: Config['admin']['components']['providers']
}
export function NestProviders({ children, importMap, providers }: Args): React.ReactNode {
return (
<RenderServerComponent
clientProps={{
children:
providers.length > 1 ? (
<NestProviders importMap={importMap} providers={providers.slice(1)}>
{children}
</NestProviders>
) : (
children
),
}}
Component={providers[0]}
importMap={importMap}
/>
)
}

View File

@@ -1,20 +1,19 @@
import type { AcceptedLanguages } from '@payloadcms/translations' import type { AcceptedLanguages } from '@payloadcms/translations'
import type { CustomVersionParser, ImportMap, SanitizedConfig } from 'payload' import type { CustomVersionParser, ImportMap, SanitizedConfig, ServerFunctionClient } from 'payload'
import { rtlLanguages } from '@payloadcms/translations' import { 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 { 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 { checkDependencies, parseCookies } from 'payload' import { checkDependencies, parseCookies } from 'payload'
import React from 'react' import React from 'react'
import { getClientConfig } from '../../utilities/getClientConfig.js'
import { getPayloadHMR } from '../../utilities/getPayloadHMR.js' import { getPayloadHMR } from '../../utilities/getPayloadHMR.js'
import { getRequestLanguage } from '../../utilities/getRequestLanguage.js' import { getRequestLanguage } from '../../utilities/getRequestLanguage.js'
import { getRequestTheme } from '../../utilities/getRequestTheme.js' import { getRequestTheme } from '../../utilities/getRequestTheme.js'
import { initReq } from '../../utilities/initReq.js' import { initReq } from '../../utilities/initReq.js'
import { DefaultEditView } from '../../views/Edit/Default/index.js' import { NestProviders } from './NestProviders.js'
import { DefaultListView } from '../../views/List/Default/index.js'
export const metadata = { export const metadata = {
description: 'Generated by Next.js', description: 'Generated by Next.js',
@@ -41,11 +40,12 @@ let checkedDependencies = false
export const RootLayout = async ({ export const RootLayout = async ({
children, children,
config: configPromise, config: configPromise,
importMap, serverFunction,
}: { }: {
readonly children: React.ReactNode readonly children: React.ReactNode
readonly config: Promise<SanitizedConfig> readonly config: Promise<SanitizedConfig>
readonly importMap: ImportMap readonly importMap: ImportMap
readonly serverFunction: ServerFunctionClient
}) => { }) => {
if ( if (
process.env.NODE_ENV !== 'production' && process.env.NODE_ENV !== 'production' &&
@@ -103,16 +103,6 @@ export const RootLayout = async ({
const { i18n, permissions, req, user } = await initReq(config) const { i18n, permissions, req, user } = await initReq(config)
const { clientConfig, render } = await createClientConfig({
children,
config,
DefaultEditView,
DefaultListView,
i18n,
importMap,
payload,
})
const dir = (rtlLanguages as unknown as AcceptedLanguages[]).includes(languageCode) const dir = (rtlLanguages as unknown as AcceptedLanguages[]).includes(languageCode)
? 'RTL' ? 'RTL'
: 'LTR' : 'LTR'
@@ -174,23 +164,39 @@ export const RootLayout = async ({
const isNavOpen = navPreferences?.value?.open ?? true const isNavOpen = navPreferences?.value?.open ?? true
const clientConfig = await getClientConfig({
config,
i18n,
})
return ( return (
<html data-theme={theme} dir={dir} lang={languageCode}> <html data-theme={theme} dir={dir} lang={languageCode}>
<body> <body>
<RootProvider <RootProvider
config={clientConfig} config={clientConfig}
dateFNSKey={i18n.dateFNSKey} dateFNSKey={i18n.dateFNSKey}
fallbackLang={clientConfig.i18n.fallbackLanguage} fallbackLang={config.i18n.fallbackLanguage}
isNavOpen={isNavOpen} isNavOpen={isNavOpen}
languageCode={languageCode} languageCode={languageCode}
languageOptions={languageOptions} languageOptions={languageOptions}
permissions={permissions} permissions={permissions}
serverFunction={serverFunction}
switchLanguageServerAction={switchLanguageServerAction} switchLanguageServerAction={switchLanguageServerAction}
theme={theme} theme={theme}
translations={i18n.translations} translations={i18n.translations}
user={user} user={user}
> >
{render} {Array.isArray(config.admin?.components?.providers) &&
config.admin?.components?.providers.length > 0 ? (
<NestProviders
importMap={payload.importMap}
providers={config.admin?.components?.providers}
>
{children}
</NestProviders>
) : (
children
)}
</RootProvider> </RootProvider>
<div id="portal" /> <div id="portal" />
</body> </body>

View File

@@ -1,50 +0,0 @@
import type { PayloadRequest } from 'payload'
import { buildFormState as buildFormStateFn } from '@payloadcms/ui/utilities/buildFormState'
import httpStatus from 'http-status'
import { headersWithCors } from '../../utilities/headersWithCors.js'
import { routeError } from './routeError.js'
export const buildFormState = async ({ req }: { req: PayloadRequest }) => {
const headers = headersWithCors({
headers: new Headers(),
req,
})
try {
const result = await buildFormStateFn({ req })
return Response.json(result, {
headers,
status: httpStatus.OK,
})
} catch (err) {
req.payload.logger.error({ err, msg: `There was an error building form state` })
if (err.message === 'Could not find field schema for given path') {
return Response.json(
{
message: err.message,
},
{
headers,
status: httpStatus.BAD_REQUEST,
},
)
}
if (err.message === 'Unauthorized') {
return Response.json(null, {
headers,
status: httpStatus.UNAUTHORIZED,
})
}
return routeError({
config: req.payload.config,
err,
req,
})
}
}

View File

@@ -26,7 +26,6 @@ import { registerFirstUser } from './auth/registerFirstUser.js'
import { resetPassword } from './auth/resetPassword.js' import { resetPassword } from './auth/resetPassword.js'
import { unlock } from './auth/unlock.js' import { unlock } from './auth/unlock.js'
import { verifyEmail } from './auth/verifyEmail.js' import { verifyEmail } from './auth/verifyEmail.js'
import { buildFormState } from './buildFormState.js'
import { endpointsAreDisabled } from './checkEndpoints.js' import { endpointsAreDisabled } from './checkEndpoints.js'
import { count } from './collections/count.js' import { count } from './collections/count.js'
import { create } from './collections/create.js' import { create } from './collections/create.js'
@@ -110,9 +109,6 @@ const endpoints = {
access, access,
og: generateOGImage, og: generateOGImage,
}, },
POST: {
'form-state': buildFormState,
},
}, },
} }
@@ -575,10 +571,6 @@ export const POST =
res = new Response('Route Not Found', { status: 404 }) res = new Response('Route Not Found', { status: 404 })
} }
} }
} else if (slug.length === 1 && slug1 in endpoints.root.POST) {
await addDataAndFileToRequest(req)
addLocalesToRequestFromData(req)
res = await endpoints.root.POST[slug1]({ req })
} }
if (res instanceof Response) { if (res instanceof Response) {

View File

@@ -1,15 +1,25 @@
import type { MappedComponent } from 'payload' import type { ImportMap, PayloadComponent } from 'payload'
import { RenderComponent } from '@payloadcms/ui/shared' import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
import React from 'react' import React from 'react'
export const OGImage: React.FC<{ export const OGImage: React.FC<{
description?: string description?: string
Fallback: React.ComponentType
fontFamily?: string fontFamily?: string
Icon: MappedComponent Icon: PayloadComponent
importMap: ImportMap
leader?: string leader?: string
title?: string title?: string
}> = ({ description, fontFamily = 'Arial, sans-serif', Icon, leader, title }) => { }> = ({
description,
Fallback,
fontFamily = 'Arial, sans-serif',
Icon,
importMap,
leader,
title,
}) => {
return ( return (
<div <div
style={{ style={{
@@ -85,11 +95,13 @@ export const OGImage: React.FC<{
width: '38px', width: '38px',
}} }}
> >
<RenderComponent <RenderServerComponent
clientProps={{ clientProps={{
fill: 'white', fill: 'white',
}} }}
mappedComponent={Icon} Component={Icon}
Fallback={Fallback}
importMap={importMap}
/> />
</div> </div>
</div> </div>

View File

@@ -1,6 +1,6 @@
import type { PayloadRequest } from 'payload' import type { PayloadRequest } from 'payload'
import { getCreateMappedComponent, PayloadIcon } from '@payloadcms/ui/shared' import { PayloadIcon } 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'
@@ -33,18 +33,6 @@ export const generateOGImage = async ({ req }: { req: PayloadRequest }) => {
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 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
try { try {
@@ -62,8 +50,10 @@ export const generateOGImage = async ({ req }: { req: PayloadRequest }) => {
( (
<OGImage <OGImage
description={description} description={description}
Fallback={PayloadIcon}
fontFamily={fontFamily} fontFamily={fontFamily}
Icon={mappedIcon} Icon={config.admin?.components?.graphics?.Icon}
importMap={req.payload.importMap}
leader={leader} leader={leader}
title={title} title={title}
/> />

View File

@@ -1,73 +1,12 @@
import type { Collection, ErrorResult, PayloadRequest, SanitizedConfig } from 'payload' import type { Collection, ErrorResult, PayloadRequest, SanitizedConfig } from 'payload'
import httpStatus from 'http-status' import httpStatus from 'http-status'
import { APIError, APIErrorName, ValidationErrorName } from 'payload' import { APIError, formatErrors } from 'payload'
import { getPayloadHMR } from '../../utilities/getPayloadHMR.js' import { getPayloadHMR } from '../../utilities/getPayloadHMR.js'
import { headersWithCors } from '../../utilities/headersWithCors.js' import { headersWithCors } from '../../utilities/headersWithCors.js'
import { mergeHeaders } from '../../utilities/mergeHeaders.js' import { mergeHeaders } from '../../utilities/mergeHeaders.js'
const formatErrors = (incoming: { [key: string]: unknown } | APIError): ErrorResult => {
if (incoming) {
// Cannot use `instanceof` to check error type: https://github.com/microsoft/TypeScript/issues/13965
// Instead, get the prototype of the incoming error and check its constructor name
const proto = Object.getPrototypeOf(incoming)
// Payload 'ValidationError' and 'APIError'
if (
(proto.constructor.name === ValidationErrorName || proto.constructor.name === APIErrorName) &&
incoming.data
) {
return {
errors: [
{
name: incoming.name,
data: incoming.data,
message: incoming.message,
},
],
}
}
// Mongoose 'ValidationError': https://mongoosejs.com/docs/api/error.html#Error.ValidationError
if (proto.constructor.name === ValidationErrorName && 'errors' in incoming && incoming.errors) {
return {
errors: Object.keys(incoming.errors).reduce((acc, key) => {
acc.push({
field: incoming.errors[key].path,
message: incoming.errors[key].message,
})
return acc
}, []),
}
}
if (Array.isArray(incoming.message)) {
return {
errors: incoming.message,
}
}
if (incoming.name) {
return {
errors: [
{
message: incoming.message,
},
],
}
}
}
return {
errors: [
{
message: 'An unknown error occurred.',
},
],
}
}
export const routeError = async ({ export const routeError = async ({
collection, collection,
config: configArg, config: configArg,

View File

@@ -1,7 +1,13 @@
import type { MappedComponent, ServerProps, VisibleEntities } from 'payload' import type { CustomComponent, ServerProps, VisibleEntities } from 'payload'
import { AppHeader, BulkUploadProvider, EntityVisibilityProvider, NavToggler } from '@payloadcms/ui' import {
import { getCreateMappedComponent, RenderComponent } from '@payloadcms/ui/shared' ActionsProvider,
AppHeader,
BulkUploadProvider,
EntityVisibilityProvider,
NavToggler,
} from '@payloadcms/ui'
import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
import React from 'react' import React from 'react'
import { DefaultNav } from '../../elements/Nav/index.js' import { DefaultNav } from '../../elements/Nav/index.js'
@@ -14,6 +20,7 @@ const baseClass = 'template-default'
export type DefaultTemplateProps = { export type DefaultTemplateProps = {
children?: React.ReactNode children?: React.ReactNode
className?: string className?: string
viewActions?: CustomComponent[]
visibleEntities: VisibleEntities visibleEntities: VisibleEntities
} & ServerProps } & ServerProps
@@ -27,10 +34,13 @@ export const DefaultTemplate: React.FC<DefaultTemplateProps> = ({
permissions, permissions,
searchParams, searchParams,
user, user,
viewActions,
visibleEntities, visibleEntities,
}) => { }) => {
const { const {
admin: { admin: {
avatar,
components,
components: { header: CustomHeader, Nav: CustomNav } = { components: { header: CustomHeader, Nav: CustomNav } = {
header: undefined, header: undefined,
Nav: undefined, Nav: undefined,
@@ -38,54 +48,98 @@ export const DefaultTemplate: React.FC<DefaultTemplateProps> = ({
} = {}, } = {},
} = payload.config || {} } = payload.config || {}
const createMappedComponent = getCreateMappedComponent({ const { Actions } = React.useMemo<{
importMap: payload.importMap, Actions: Record<string, React.ReactNode>
serverProps: { }>(() => {
i18n, return {
locale, Actions: viewActions
params, ? viewActions.reduce((acc, action) => {
payload, if (action) {
permissions, if (typeof action === 'object') {
searchParams, acc[action.path] = (
user, <RenderServerComponent Component={action} importMap={payload.importMap} />
}, )
}) } else {
acc[action] = (
<RenderServerComponent Component={action} importMap={payload.importMap} />
)
}
}
const MappedDefaultNav: MappedComponent = createMappedComponent( return acc
CustomNav, }, {})
undefined, : undefined,
DefaultNav, }
'CustomNav', }, [viewActions, payload])
)
const MappedCustomHeader = createMappedComponent(
CustomHeader,
undefined,
undefined,
'CustomHeader',
)
return ( return (
<EntityVisibilityProvider visibleEntities={visibleEntities}> <EntityVisibilityProvider visibleEntities={visibleEntities}>
<BulkUploadProvider> <BulkUploadProvider>
<RenderComponent mappedComponent={MappedCustomHeader} /> <ActionsProvider Actions={Actions}>
<div style={{ position: 'relative' }}> <RenderServerComponent
<div className={`${baseClass}__nav-toggler-wrapper`} id="nav-toggler"> clientProps={{ clientProps: { visibleEntities } }}
<div className={`${baseClass}__nav-toggler-container`} id="nav-toggler"> Component={CustomHeader}
<NavToggler className={`${baseClass}__nav-toggler`}> importMap={payload.importMap}
<NavHamburger /> serverProps={{
</NavToggler> i18n,
locale,
params,
payload,
permissions,
searchParams,
user,
visibleEntities,
}}
/>
<div style={{ position: 'relative' }}>
<div className={`${baseClass}__nav-toggler-wrapper`} id="nav-toggler">
<div className={`${baseClass}__nav-toggler-container`} id="nav-toggler">
<NavToggler className={`${baseClass}__nav-toggler`}>
<NavHamburger />
</NavToggler>
</div>
</div> </div>
<Wrapper baseClass={baseClass} className={className}>
<RenderServerComponent
clientProps={{ clientProps: { visibleEntities } }}
Component={CustomNav}
Fallback={DefaultNav}
importMap={payload.importMap}
serverProps={{
i18n,
locale,
params,
payload,
permissions,
searchParams,
user,
visibleEntities,
}}
/>
<div className={`${baseClass}__wrap`}>
<AppHeader
CustomAvatar={
avatar !== 'gravatar' && avatar !== 'default' ? (
<RenderServerComponent
Component={avatar.Component}
importMap={payload.importMap}
/>
) : undefined
}
CustomIcon={
components?.graphics?.Icon ? (
<RenderServerComponent
Component={components.graphics.Icon}
importMap={payload.importMap}
/>
) : undefined
}
/>
{children}
</div>
</Wrapper>
</div> </div>
<Wrapper baseClass={baseClass} className={className}> </ActionsProvider>
<RenderComponent mappedComponent={MappedDefaultNav} />
<div className={`${baseClass}__wrap`}>
<AppHeader />
{children}
</div>
</Wrapper>
</div>
</BulkUploadProvider> </BulkUploadProvider>
</EntityVisibilityProvider> </EntityVisibilityProvider>
) )

View File

@@ -0,0 +1,18 @@
import type { I18nClient } from '@payloadcms/translations'
import type { ClientConfig, SanitizedConfig } from 'payload'
import { createClientConfig } from 'payload'
import { cache } from 'react'
export const getClientConfig = cache(
async (args: { config: SanitizedConfig; i18n: I18nClient }): Promise<ClientConfig> => {
const { config, i18n } = args
const clientConfig = createClientConfig({
config,
i18n,
})
return Promise.resolve(clientConfig)
},
)

View File

@@ -0,0 +1,37 @@
import type { ServerFunction, ServerFunctionHandler } from 'payload'
import { buildFormStateHandler } from '@payloadcms/ui/utilities/buildFormState'
import { buildTableStateHandler } from '@payloadcms/ui/utilities/buildTableState'
import { renderDocumentHandler } from '../views/Document/handleServerFunction.js'
import { renderDocumentSlotsHandler } from '../views/Document/renderDocumentSlots.js'
import { renderListHandler } from '../views/List/handleServerFunction.js'
import { initReq } from './initReq.js'
export const handleServerFunctions: ServerFunctionHandler = async (args) => {
const { name: fnKey, args: fnArgs, config: configPromise, importMap } = args
const { req } = await initReq(configPromise)
const augmentedArgs: Parameters<ServerFunction>[0] = {
...fnArgs,
importMap,
req,
}
const serverFunctions = {
'form-state': buildFormStateHandler as any as ServerFunction,
'render-document': renderDocumentHandler as any as ServerFunction,
'render-document-slots': renderDocumentSlotsHandler as any as ServerFunction,
'render-list': renderListHandler as any as ServerFunction,
'table-state': buildTableStateHandler as any as ServerFunction,
}
const fn = serverFunctions[fnKey]
if (!fn) {
throw new Error(`Unknown Server Function: ${fnKey}`)
}
return fn(augmentedArgs)
}

View File

@@ -1,4 +1,5 @@
import type { InitPageResult, Locale, PayloadRequest, VisibleEntities } from 'payload' import type { I18n } from '@payloadcms/translations'
import type { InitPageResult, Locale, VisibleEntities } from 'payload'
import { findLocaleFromCode } from '@payloadcms/ui/shared' import { findLocaleFromCode } from '@payloadcms/ui/shared'
import { headers as getHeaders } from 'next/headers.js' import { headers as getHeaders } from 'next/headers.js'
@@ -47,13 +48,13 @@ export const initPage = async ({
req: { req: {
headers, headers,
host: headers.get('host'), host: headers.get('host'),
i18n, i18n: i18n as I18n,
query: qs.parse(queryString, { query: qs.parse(queryString, {
depth: 10, depth: 10,
ignoreQueryPrefix: true, ignoreQueryPrefix: true,
}), }),
url: `${payload.config.serverURL}${route}${searchParams ? queryString : ''}`, url: `${payload.config.serverURL}${route}${searchParams ? queryString : ''}`,
} as PayloadRequest, },
}, },
payload, payload,
) )

View File

@@ -1,4 +1,4 @@
import type { I18nClient } from '@payloadcms/translations' import type { I18n, I18nClient } from '@payloadcms/translations'
import type { PayloadRequest, Permissions, SanitizedConfig, User } from 'payload' import type { PayloadRequest, Permissions, SanitizedConfig, User } from 'payload'
import { initI18n } from '@payloadcms/translations' import { initI18n } from '@payloadcms/translations'
@@ -16,7 +16,10 @@ type Result = {
user: User user: User
} }
export const initReq = cache(async function (config: SanitizedConfig): Promise<Result> { export const initReq = cache(async function (
configPromise: Promise<SanitizedConfig> | SanitizedConfig,
): Promise<Result> {
const config = await configPromise
const payload = await getPayloadHMR({ config }) const payload = await getPayloadHMR({ config })
const headers = await getHeaders() const headers = await getHeaders()
@@ -40,9 +43,9 @@ export const initReq = cache(async function (config: SanitizedConfig): Promise<R
req: { req: {
headers, headers,
host: headers.get('host'), host: headers.get('host'),
i18n, i18n: i18n as I18n,
url: `${payload.config.serverURL}`, url: `${payload.config.serverURL}`,
} as PayloadRequest, },
}, },
payload, payload,
) )

View File

@@ -15,7 +15,6 @@ export const LocaleSelector: React.FC<{
<SelectField <SelectField
field={{ field={{
name: 'locale', name: 'locale',
_path: 'locale',
label: t('general:locale'), label: t('general:locale'),
options: localeOptions, options: localeOptions,
}} }}

View File

@@ -9,7 +9,7 @@ import {
Gutter, Gutter,
MinimizeMaximizeIcon, MinimizeMaximizeIcon,
NumberField, NumberField,
SetViewActions, SetDocumentStepNav,
useConfig, useConfig,
useDocumentInfo, useDocumentInfo,
useLocale, useLocale,
@@ -19,7 +19,6 @@ import { useSearchParams } from 'next/navigation.js'
import * as React from 'react' import * as React from 'react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { SetDocumentStepNav } from '../Edit/Default/SetDocumentStepNav/index.js'
import './index.scss' import './index.scss'
import { LocaleSelector } from './LocaleSelector/index.js' import { LocaleSelector } from './LocaleSelector/index.js'
import { RenderJSON } from './RenderJSON/index.js' import { RenderJSON } from './RenderJSON/index.js'
@@ -42,8 +41,8 @@ export const APIViewClient: React.FC = () => {
getEntityConfig, getEntityConfig,
} = useConfig() } = useConfig()
const collectionClientConfig = getEntityConfig({ collectionSlug }) as ClientCollectionConfig const collectionConfig = getEntityConfig({ collectionSlug }) as ClientCollectionConfig
const globalClientConfig = getEntityConfig({ globalSlug }) as ClientGlobalConfig const globalConfig = getEntityConfig({ globalSlug }) as ClientGlobalConfig
const localeOptions = const localeOptions =
localization && localization &&
@@ -52,13 +51,13 @@ export const APIViewClient: React.FC = () => {
let draftsEnabled: boolean = false let draftsEnabled: boolean = false
let docEndpoint: string = '' let docEndpoint: string = ''
if (collectionClientConfig) { if (collectionConfig) {
draftsEnabled = Boolean(collectionClientConfig.versions?.drafts) draftsEnabled = Boolean(collectionConfig.versions?.drafts)
docEndpoint = `/${collectionSlug}/${id}` docEndpoint = `/${collectionSlug}/${id}`
} }
if (globalClientConfig) { if (globalConfig) {
draftsEnabled = Boolean(globalClientConfig.versions?.drafts) draftsEnabled = Boolean(globalConfig.versions?.drafts)
docEndpoint = `/globals/${globalSlug}` docEndpoint = `/globals/${globalSlug}`
} }
@@ -111,19 +110,13 @@ export const APIViewClient: React.FC = () => {
> >
<SetDocumentStepNav <SetDocumentStepNav
collectionSlug={collectionSlug} collectionSlug={collectionSlug}
globalLabel={globalClientConfig?.label} globalLabel={globalConfig?.label}
globalSlug={globalSlug} globalSlug={globalSlug}
id={id} id={id}
pluralLabel={collectionClientConfig ? collectionClientConfig?.labels?.plural : undefined} pluralLabel={collectionConfig ? collectionConfig?.labels?.plural : undefined}
useAsTitle={collectionClientConfig ? collectionClientConfig?.admin?.useAsTitle : undefined} useAsTitle={collectionConfig ? collectionConfig?.admin?.useAsTitle : undefined}
view="API" view="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`}>

View File

@@ -22,7 +22,7 @@ export const Settings: React.FC<{
<div className={[baseClass, className].filter(Boolean).join(' ')}> <div className={[baseClass, className].filter(Boolean).join(' ')}>
<h3>{i18n.t('general:payloadSettings')}</h3> <h3>{i18n.t('general:payloadSettings')}</h3>
<div className={`${baseClass}__language`}> <div className={`${baseClass}__language`}>
<FieldLabel field={null} htmlFor="language-select" label={i18n.t('general:language')} /> <FieldLabel htmlFor="language-select" label={i18n.t('general:language')} />
<LanguageSelector languageOptions={languageOptions} /> <LanguageSelector languageOptions={languageOptions} />
</div> </div>
{theme === 'all' && <ToggleTheme />} {theme === 'all' && <ToggleTheme />}

View File

@@ -1,18 +1,17 @@
import type { AdminViewProps } from 'payload' import type { AdminViewProps } from 'payload'
import { import { DocumentInfoProvider, EditDepthProvider, HydrateAuthProvider } from '@payloadcms/ui'
DocumentInfoProvider, import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
EditDepthProvider, import { buildFormState } from '@payloadcms/ui/utilities/buildFormState'
HydrateAuthProvider,
RenderComponent,
} from '@payloadcms/ui'
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'
import { DocumentHeader } from '../../elements/DocumentHeader/index.js' import { DocumentHeader } from '../../elements/DocumentHeader/index.js'
import { getDocPreferences } from '../Document/getDocPreferences.js'
import { getDocumentData } from '../Document/getDocumentData.js' import { getDocumentData } from '../Document/getDocumentData.js'
import { getDocumentPermissions } from '../Document/getDocumentPermissions.js' import { getDocumentPermissions } from '../Document/getDocumentPermissions.js'
import { getIsLocked } from '../Document/getIsLocked.js'
import { getVersions } from '../Document/getVersions.js'
import { EditView } from '../Edit/index.js' import { EditView } from '../Edit/index.js'
import { AccountClient } from './index.client.js' import { AccountClient } from './index.client.js'
import { Settings } from './Settings/index.js' import { Settings } from './Settings/index.js'
@@ -50,57 +49,91 @@ export const Account: React.FC<AdminViewProps> = async ({
const collectionConfig = config.collections.find((collection) => collection.slug === userSlug) const collectionConfig = config.collections.find((collection) => collection.slug === userSlug)
if (collectionConfig && user?.id) { if (collectionConfig && user?.id) {
// Fetch the data required for the view
const data = await getDocumentData({
id: user.id,
collectionSlug: collectionConfig.slug,
locale,
payload,
user,
})
if (!data) {
throw new Error('not-found')
}
// Get document preferences
const docPreferences = await getDocPreferences({
id: user.id,
collectionSlug: collectionConfig.slug,
payload,
user,
})
// Get permissions
const { docPermissions, hasPublishPermission, hasSavePermission } = const { docPermissions, hasPublishPermission, hasSavePermission } =
await getDocumentPermissions({ await getDocumentPermissions({
id: user.id, id: user.id,
collectionConfig, collectionConfig,
data: user, data,
req, req,
}) })
const { data, formState } = await getDocumentData({ // Build initial form state from data
const { state: formState } = await buildFormState({
id: user.id,
collectionSlug: collectionConfig.slug,
data,
docPermissions,
docPreferences,
locale: locale?.code,
operation: 'update',
renderAllFields: true,
req,
schemaPath: collectionConfig.slug,
})
// Fetch document lock state
const { currentEditor, isLocked, lastUpdateTime } = await getIsLocked({
id: user.id, id: user.id,
collectionConfig, collectionConfig,
locale, isEditing: true,
req, payload: req.payload,
user,
}) })
const createMappedComponent = getCreateMappedComponent({ // Get all versions required for UI
importMap: payload.importMap, const { hasPublishedDoc, mostRecentVersionIsAutosaved, unpublishedVersionCount, versionCount } =
serverProps: { await getVersions({
i18n, id: user.id,
initPageResult, collectionConfig,
locale, docPermissions,
params, locale: locale?.code,
payload, payload,
permissions,
routeSegments: [],
searchParams,
user, user,
}, })
})
const mappedAccountComponent = createMappedComponent(
CustomAccountComponent?.Component,
undefined,
EditView,
'CustomAccountComponent.Component',
)
return ( return (
<DocumentInfoProvider <DocumentInfoProvider
AfterFields={<Settings i18n={i18n} languageOptions={languageOptions} theme={theme} />} AfterFields={<Settings i18n={i18n} languageOptions={languageOptions} theme={theme} />}
apiURL={`${serverURL}${api}/${userSlug}${user?.id ? `/${user.id}` : ''}`} apiURL={`${serverURL}${api}/${userSlug}${user?.id ? `/${user.id}` : ''}`}
collectionSlug={userSlug} collectionSlug={userSlug}
currentEditor={currentEditor}
docPermissions={docPermissions} docPermissions={docPermissions}
hasPublishedDoc={hasPublishedDoc}
hasPublishPermission={hasPublishPermission} hasPublishPermission={hasPublishPermission}
hasSavePermission={hasSavePermission} hasSavePermission={hasSavePermission}
id={user?.id} id={user?.id}
initialData={data} initialData={data}
initialState={formState} initialState={formState}
isEditing isEditing
isLocked={isLocked}
lastUpdateTime={lastUpdateTime}
mostRecentVersionIsAutosaved={mostRecentVersionIsAutosaved}
unpublishedVersionCount={unpublishedVersionCount}
versionCount={versionCount}
> >
<EditDepthProvider depth={1}> <EditDepthProvider>
<DocumentHeader <DocumentHeader
collectionConfig={collectionConfig} collectionConfig={collectionConfig}
hideTabs hideTabs
@@ -109,7 +142,22 @@ export const Account: React.FC<AdminViewProps> = async ({
permissions={permissions} permissions={permissions}
/> />
<HydrateAuthProvider permissions={permissions} /> <HydrateAuthProvider permissions={permissions} />
<RenderComponent mappedComponent={mappedAccountComponent} /> <RenderServerComponent
Component={CustomAccountComponent}
importMap={payload.importMap}
serverProps={{
i18n,
initPageResult,
locale,
params,
payload,
permissions,
routeSegments: [],
searchParams,
user,
}}
/>
<EditView />
<AccountClient /> <AccountClient />
</EditDepthProvider> </EditDepthProvider>
</DocumentInfoProvider> </DocumentInfoProvider>

View File

@@ -1,27 +1,34 @@
'use client' 'use client'
import type { FormProps, UserWithToken } from '@payloadcms/ui' import type { FormProps, UserWithToken } from '@payloadcms/ui'
import type { ClientCollectionConfig, FormState, LoginWithUsernameOptions } from 'payload' import type {
ClientCollectionConfig,
DocumentPermissions,
DocumentPreferences,
FormState,
LoginWithUsernameOptions,
} from 'payload'
import { import {
ConfirmPasswordField, ConfirmPasswordField,
EmailAndUsernameFields,
Form, Form,
FormSubmit, FormSubmit,
PasswordField, PasswordField,
RenderFields, RenderFields,
useAuth, useAuth,
useConfig, useConfig,
useServerFunctions,
useTranslation, useTranslation,
} from '@payloadcms/ui' } from '@payloadcms/ui'
import { getFormState } from '@payloadcms/ui/shared'
import React from 'react' import React from 'react'
import { RenderEmailAndUsernameFields } from '../../elements/EmailAndUsername/index.js'
export const CreateFirstUserClient: React.FC<{ export const CreateFirstUserClient: React.FC<{
docPermissions: DocumentPermissions
docPreferences: DocumentPreferences
initialState: FormState initialState: FormState
loginWithUsername?: false | LoginWithUsernameOptions loginWithUsername?: false | LoginWithUsernameOptions
userSlug: string userSlug: string
}> = ({ initialState, loginWithUsername, userSlug }) => { }> = ({ docPermissions, docPreferences, initialState, loginWithUsername, userSlug }) => {
const { const {
config: { config: {
routes: { admin, api: apiRoute }, routes: { admin, api: apiRoute },
@@ -30,6 +37,8 @@ export const CreateFirstUserClient: React.FC<{
getEntityConfig, getEntityConfig,
} = useConfig() } = useConfig()
const { getFormState } = useServerFunctions()
const { t } = useTranslation() const { t } = useTranslation()
const { setUser } = useAuth() const { setUser } = useAuth()
@@ -38,18 +47,17 @@ export const CreateFirstUserClient: React.FC<{
const onChange: FormProps['onChange'][0] = React.useCallback( const onChange: FormProps['onChange'][0] = React.useCallback(
async ({ formState: prevFormState }) => { async ({ formState: prevFormState }) => {
const { state } = await getFormState({ const { state } = await getFormState({
apiRoute, collectionSlug: userSlug,
body: { docPermissions,
collectionSlug: userSlug, docPreferences,
formState: prevFormState, formState: prevFormState,
operation: 'create', operation: 'create',
schemaPath: `_${userSlug}.auth`, schemaPath: `_${userSlug}.auth`,
},
serverURL,
}) })
return state return state
}, },
[apiRoute, userSlug, serverURL], [userSlug, getFormState, docPermissions, docPreferences],
) )
const handleFirstRegister = (data: UserWithToken) => { const handleFirstRegister = (data: UserWithToken) => {
@@ -66,14 +74,15 @@ export const CreateFirstUserClient: React.FC<{
redirect={admin} redirect={admin}
validationOperation="create" validationOperation="create"
> >
<RenderEmailAndUsernameFields <EmailAndUsernameFields
className="emailAndUsername" className="emailAndUsername"
loginWithUsername={loginWithUsername} loginWithUsername={loginWithUsername}
operation="create" operation="create"
readOnly={false} readOnly={false}
t={t}
/> />
<PasswordField <PasswordField
autoComplete={'off'} autoComplete="off"
field={{ field={{
name: 'password', name: 'password',
label: t('authentication:newPassword'), label: t('authentication:newPassword'),
@@ -84,10 +93,11 @@ export const CreateFirstUserClient: React.FC<{
<RenderFields <RenderFields
fields={collectionConfig.fields} fields={collectionConfig.fields}
forceRender forceRender
operation="create" parentIndexPath=""
path="" parentPath=""
parentSchemaPath={userSlug}
permissions={null}
readOnly={false} readOnly={false}
schemaPath={userSlug}
/> />
<FormSubmit size="large">{t('general:create')}</FormSubmit> <FormSubmit size="large">{t('general:create')}</FormSubmit>
</Form> </Form>

View File

@@ -1,8 +1,11 @@
import type { AdminViewProps } from 'payload' import type { AdminViewProps } from 'payload'
import { buildFormState } from '@payloadcms/ui/utilities/buildFormState'
import React from 'react' import React from 'react'
import { getDocPreferences } from '../Document/getDocPreferences.js'
import { getDocumentData } from '../Document/getDocumentData.js' import { getDocumentData } from '../Document/getDocumentData.js'
import { getDocumentPermissions } from '../Document/getDocumentPermissions.js'
import { CreateFirstUserClient } from './index.client.js' import { CreateFirstUserClient } from './index.client.js'
import './index.scss' import './index.scss'
@@ -26,11 +29,39 @@ export const CreateFirstUserView: React.FC<AdminViewProps> = async ({ initPageRe
const { auth: authOptions } = collectionConfig const { auth: authOptions } = collectionConfig
const loginWithUsername = authOptions.loginWithUsername const loginWithUsername = authOptions.loginWithUsername
const { formState } = await getDocumentData({ // Fetch the data required for the view
collectionConfig, const data = await getDocumentData({
collectionSlug: collectionConfig.slug,
locale, locale,
payload: req.payload,
user: req.user,
})
// Get document preferences
const docPreferences = await getDocPreferences({
collectionSlug: collectionConfig.slug,
payload: req.payload,
user: req.user,
})
// Get permissions
const { docPermissions } = await getDocumentPermissions({
collectionConfig,
data,
req, req,
schemaPath: `_${collectionConfig.slug}.auth`, })
// Build initial form state from data
const { state: formState } = await buildFormState({
collectionSlug: collectionConfig.slug,
data,
docPermissions,
docPreferences,
locale: locale?.code,
operation: 'create',
renderAllFields: true,
req,
schemaPath: collectionConfig.slug,
}) })
return ( return (
@@ -38,6 +69,8 @@ export const CreateFirstUserView: React.FC<AdminViewProps> = async ({ initPageRe
<h1>{req.t('general:welcome')}</h1> <h1>{req.t('general:welcome')}</h1>
<p>{req.t('authentication:beginCreateFirstUser')}</p> <p>{req.t('authentication:beginCreateFirstUser')}</p>
<CreateFirstUserClient <CreateFirstUserClient
docPermissions={docPermissions}
docPreferences={docPreferences}
initialState={formState} initialState={formState}
loginWithUsername={loginWithUsername} loginWithUsername={loginWithUsername}
userSlug={userSlug} userSlug={userSlug}

View File

@@ -2,13 +2,9 @@ import type { groupNavItems } from '@payloadcms/ui/shared'
import type { ClientUser, Permissions, ServerProps, VisibleEntities } from 'payload' import type { ClientUser, Permissions, ServerProps, VisibleEntities } from 'payload'
import { getTranslation } from '@payloadcms/translations' import { getTranslation } from '@payloadcms/translations'
import { Button, Card, Gutter, Locked, SetStepNav, SetViewActions } from '@payloadcms/ui' import { Button, Card, Gutter, Locked } from '@payloadcms/ui'
import { import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
EntityType, import { EntityType, formatAdminURL } from '@payloadcms/ui/shared'
formatAdminURL,
getCreateMappedComponent,
RenderComponent,
} from '@payloadcms/ui/shared'
import React, { Fragment } from 'react' import React, { Fragment } from 'react'
import './index.scss' import './index.scss'
@@ -50,41 +46,25 @@ export const DefaultDashboard: React.FC<DashboardProps> = (props) => {
user, user,
} = props } = props
const createMappedComponent = getCreateMappedComponent({
importMap: payload.importMap,
serverProps: {
i18n,
locale,
params,
payload,
permissions,
searchParams,
user,
},
})
const mappedBeforeDashboards = createMappedComponent(
beforeDashboard,
undefined,
undefined,
'beforeDashboard',
)
const mappedAfterDashboards = createMappedComponent(
afterDashboard,
undefined,
undefined,
'afterDashboard',
)
return ( return (
<div className={baseClass}> <div className={baseClass}>
<SetStepNav nav={[]} />
<SetViewActions actions={[]} />
<Gutter className={`${baseClass}__wrap`}> <Gutter className={`${baseClass}__wrap`}>
<RenderComponent mappedComponent={mappedBeforeDashboards} /> {beforeDashboard && (
<RenderServerComponent
Component={beforeDashboard}
importMap={payload.importMap}
serverProps={{
i18n,
locale,
params,
payload,
permissions,
searchParams,
user,
}}
/>
)}
<Fragment> <Fragment>
<SetViewActions actions={[]} />
{!navGroups || navGroups?.length === 0 ? ( {!navGroups || navGroups?.length === 0 ? (
<p>no nav groups....</p> <p>no nav groups....</p>
) : ( ) : (
@@ -93,7 +73,7 @@ export const DefaultDashboard: React.FC<DashboardProps> = (props) => {
<div className={`${baseClass}__group`} key={groupIndex}> <div className={`${baseClass}__group`} key={groupIndex}>
<h2 className={`${baseClass}__label`}>{label}</h2> <h2 className={`${baseClass}__label`}>{label}</h2>
<ul className={`${baseClass}__card-list`}> <ul className={`${baseClass}__card-list`}>
{entities.map(({ type, entity }, entityIndex) => { {entities.map(({ slug, type, label }, entityIndex) => {
let title: string let title: string
let buttonAriaLabel: string let buttonAriaLabel: string
let createHREF: string let createHREF: string
@@ -103,38 +83,34 @@ export const DefaultDashboard: React.FC<DashboardProps> = (props) => {
let userEditing = null let userEditing = null
if (type === EntityType.collection) { if (type === EntityType.collection) {
title = getTranslation(entity.labels.plural, i18n) title = getTranslation(label, i18n)
buttonAriaLabel = t('general:showAllLabel', { label: title }) buttonAriaLabel = t('general:showAllLabel', { label: title })
href = formatAdminURL({ adminRoute, path: `/collections/${entity.slug}` }) href = formatAdminURL({ adminRoute, path: `/collections/${slug}` })
createHREF = formatAdminURL({ createHREF = formatAdminURL({
adminRoute, adminRoute,
path: `/collections/${entity.slug}/create`, path: `/collections/${slug}/create`,
}) })
hasCreatePermission = hasCreatePermission = permissions?.collections?.[slug]?.create?.permission
permissions?.collections?.[entity.slug]?.create?.permission
} }
if (type === EntityType.global) { if (type === EntityType.global) {
title = getTranslation(entity.label, i18n) title = getTranslation(label, i18n)
buttonAriaLabel = t('general:editLabel', { buttonAriaLabel = t('general:editLabel', {
label: getTranslation(entity.label, i18n), label: getTranslation(label, i18n),
}) })
href = formatAdminURL({ href = formatAdminURL({
adminRoute, adminRoute,
path: `/globals/${entity.slug}`, path: `/globals/${slug}`,
}) })
// Find the lock status for the global // Find the lock status for the global
const globalLockData = globalData.find( const globalLockData = globalData.find((global) => global.slug === slug)
(global) => global.slug === entity.slug,
)
if (globalLockData) { if (globalLockData) {
isLocked = globalLockData.data._isLocked isLocked = globalLockData.data._isLocked
userEditing = globalLockData.data._userEditing userEditing = globalLockData.data._userEditing
@@ -164,7 +140,7 @@ export const DefaultDashboard: React.FC<DashboardProps> = (props) => {
) : hasCreatePermission && type === EntityType.collection ? ( ) : hasCreatePermission && type === EntityType.collection ? (
<Button <Button
aria-label={t('general:createNewLabel', { aria-label={t('general:createNewLabel', {
label: getTranslation(entity.labels.singular, i18n), label,
})} })}
buttonStyle="icon-label" buttonStyle="icon-label"
el="link" el="link"
@@ -178,9 +154,9 @@ export const DefaultDashboard: React.FC<DashboardProps> = (props) => {
} }
buttonAriaLabel={buttonAriaLabel} buttonAriaLabel={buttonAriaLabel}
href={href} href={href}
id={`card-${entity.slug}`} id={`card-${slug}`}
Link={Link} Link={Link}
title={title} title={getTranslation(label, i18n)}
titleAs="h3" titleAs="h3"
/> />
</li> </li>
@@ -192,7 +168,21 @@ export const DefaultDashboard: React.FC<DashboardProps> = (props) => {
}) })
)} )}
</Fragment> </Fragment>
<RenderComponent mappedComponent={mappedAfterDashboards} /> {afterDashboard && (
<RenderServerComponent
Component={afterDashboard}
importMap={payload.importMap}
serverProps={{
i18n,
locale,
params,
payload,
permissions,
searchParams,
user,
}}
/>
)}
</Gutter> </Gutter>
</div> </div>
) )

View File

@@ -1,13 +1,9 @@
import type { EntityToGroup } from '@payloadcms/ui/shared' import type { EntityToGroup } from '@payloadcms/ui/shared'
import type { AdminViewProps } from 'payload' import type { AdminViewProps } from 'payload'
import { HydrateAuthProvider } from '@payloadcms/ui' import { HydrateAuthProvider, SetStepNav } from '@payloadcms/ui'
import { import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
EntityType, import { EntityType, groupNavItems } from '@payloadcms/ui/shared'
getCreateMappedComponent,
groupNavItems,
RenderComponent,
} 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'
@@ -111,39 +107,31 @@ export const Dashboard: React.FC<AdminViewProps> = async ({
i18n, i18n,
) )
const createMappedComponent = getCreateMappedComponent({
importMap: payload.importMap,
serverProps: {
globalData,
i18n,
Link,
locale,
navGroups,
params,
payload,
permissions,
searchParams,
user,
visibleEntities,
},
})
const mappedDashboardComponent = createMappedComponent(
CustomDashboardComponent?.Component,
undefined,
DefaultDashboard,
'CustomDashboardComponent.Component',
)
return ( return (
<Fragment> <Fragment>
<HydrateAuthProvider permissions={permissions} /> <HydrateAuthProvider permissions={permissions} />
<RenderComponent <SetStepNav nav={[]} />
<RenderServerComponent
clientProps={{ clientProps={{
Link, Link,
locale, locale,
}} }}
mappedComponent={mappedDashboardComponent} Component={CustomDashboardComponent}
Fallback={DefaultDashboard}
importMap={payload.importMap}
serverProps={{
globalData,
i18n,
Link,
locale,
navGroups,
params,
payload,
permissions,
searchParams,
user,
visibleEntities,
}}
/> />
</Fragment> </Fragment>
) )

View File

@@ -0,0 +1,60 @@
import type { DocumentPreferences, Payload, TypedUser } from 'payload'
type Args = {
collectionSlug?: string
globalSlug?: string
id?: number | string
payload: Payload
user: TypedUser
}
export const getDocPreferences = async ({
id,
collectionSlug,
globalSlug,
payload,
user,
}: Args): Promise<DocumentPreferences> => {
let preferencesKey
if (collectionSlug && id) {
preferencesKey = `collection-${collectionSlug}-${id}`
}
if (globalSlug) {
preferencesKey = `global-${globalSlug}`
}
if (preferencesKey) {
const preferencesResult = (await payload.find({
collection: 'payload-preferences',
depth: 0,
limit: 1,
where: {
and: [
{
key: {
equals: preferencesKey,
},
},
{
'user.relationTo': {
equals: user.collection,
},
},
{
'user.value': {
equals: user.id,
},
},
],
},
})) as unknown as { docs: { value: DocumentPreferences }[] }
if (preferencesResult?.docs?.[0]?.value) {
return preferencesResult.docs[0].value
}
}
return { fields: {} }
}

View File

@@ -0,0 +1,52 @@
import type { Locale, Payload, TypedUser, TypeWithID } from 'payload'
type Args = {
collectionSlug?: string
globalSlug?: string
id?: number | string
locale?: Locale
payload: Payload
user?: TypedUser
}
export const getDocumentData = async ({
id,
collectionSlug,
globalSlug,
locale,
payload,
user,
}: Args): Promise<null | Record<string, unknown> | TypeWithID> => {
let resolvedData: Record<string, unknown> | TypeWithID = null
try {
if (collectionSlug && id) {
resolvedData = await payload.findByID({
id,
collection: collectionSlug,
depth: 0,
draft: true,
fallbackLocale: null,
locale: locale?.code,
overrideAccess: false,
user,
})
}
if (globalSlug) {
resolvedData = await payload.findGlobal({
slug: globalSlug,
depth: 0,
draft: true,
fallbackLocale: null,
locale: locale?.code,
overrideAccess: false,
user,
})
}
} catch (_err) {
payload.logger.error(_err)
}
return resolvedData
}

View File

@@ -1,62 +0,0 @@
import type {
Data,
FormState,
Locale,
PayloadRequest,
SanitizedCollectionConfig,
SanitizedGlobalConfig,
} from 'payload'
import { buildFormState } from '@payloadcms/ui/utilities/buildFormState'
import { reduceFieldsToValues } from 'payload/shared'
export const getDocumentData = async (args: {
collectionConfig?: SanitizedCollectionConfig
globalConfig?: SanitizedGlobalConfig
id?: number | string
locale: Locale
req: PayloadRequest
schemaPath?: string
}): Promise<{
data: Data
formState: FormState
}> => {
const { id, collectionConfig, globalConfig, locale, req, schemaPath: schemaPathFromProps } = args
const schemaPath = schemaPathFromProps || collectionConfig?.slug || globalConfig?.slug
try {
const { state: formState } = await buildFormState({
req: {
...req,
data: {
id,
collectionSlug: collectionConfig?.slug,
globalSlug: globalConfig?.slug,
locale: locale?.code,
operation: (collectionConfig && id) || globalConfig ? 'update' : 'create',
schemaPath,
},
},
})
const data = reduceFieldsToValues(formState, true)
return {
data,
formState,
}
} catch (error) {
req.payload.logger.error({ err: error, msg: 'Error getting document data' })
return {
data: null,
formState: {
fields: {
initialValue: undefined,
valid: false,
value: undefined,
},
},
}
}
}

View File

@@ -0,0 +1,86 @@
import type {
Payload,
SanitizedCollectionConfig,
SanitizedGlobalConfig,
TypedUser,
Where,
} from 'payload'
type Args = {
collectionConfig?: SanitizedCollectionConfig
globalConfig?: SanitizedGlobalConfig
id?: number | string
isEditing: boolean
payload: Payload
user: TypedUser
}
type Result = Promise<{
currentEditor?: TypedUser
isLocked: boolean
lastUpdateTime?: number
}>
export const getIsLocked = async ({
id,
collectionConfig,
globalConfig,
isEditing,
payload,
user,
}: Args): Result => {
const entityConfig = collectionConfig || globalConfig
const entityHasLockingEnabled =
entityConfig?.lockDocuments !== undefined ? entityConfig?.lockDocuments : true
if (!entityHasLockingEnabled || !isEditing) {
return {
isLocked: false,
}
}
const where: Where = {}
if (globalConfig) {
where.globalSlug = {
equals: globalConfig.slug,
}
} else {
where.and = [
{
'document.value': {
equals: id,
},
},
{
'document.relationTo': {
equals: collectionConfig.slug,
},
},
]
}
const { docs } = await payload.find({
collection: 'payload-locked-documents',
depth: 1,
where,
})
if (docs.length > 0) {
const newEditor = docs[0].user?.value
const lastUpdateTime = new Date(docs[0].updatedAt).getTime()
if (newEditor?.id !== user.id) {
return {
currentEditor: newEditor,
isLocked: true,
lastUpdateTime,
}
}
}
return {
isLocked: false,
}
}

View File

@@ -0,0 +1,240 @@
import type {
DocumentPermissions,
Payload,
SanitizedCollectionConfig,
SanitizedGlobalConfig,
TypedUser,
} from 'payload'
type Args = {
collectionConfig?: SanitizedCollectionConfig
docPermissions: DocumentPermissions
globalConfig?: SanitizedGlobalConfig
id?: number | string
locale?: string
payload: Payload
user: TypedUser
}
type Result = Promise<{
hasPublishedDoc: boolean
mostRecentVersionIsAutosaved: boolean
unpublishedVersionCount: number
versionCount: number
}>
// TODO: in the future, we can parallelize some of these queries
// this will speed up the API by ~30-100ms or so
export const getVersions = async ({
id,
collectionConfig,
docPermissions,
globalConfig,
locale,
payload,
user,
}: Args): Result => {
let publishedQuery
let hasPublishedDoc = false
let mostRecentVersionIsAutosaved = false
let unpublishedVersionCount = 0
let versionCount = 0
const entityConfig = collectionConfig || globalConfig
const versionsConfig = entityConfig?.versions
const shouldFetchVersions = Boolean(versionsConfig && docPermissions?.readVersions?.permission)
if (!shouldFetchVersions) {
const hasPublishedDoc = Boolean((collectionConfig && id) || globalConfig)
return {
hasPublishedDoc,
mostRecentVersionIsAutosaved,
unpublishedVersionCount,
versionCount,
}
}
if (collectionConfig) {
if (!id) {
return {
hasPublishedDoc,
mostRecentVersionIsAutosaved,
unpublishedVersionCount,
versionCount,
}
}
if (versionsConfig?.drafts) {
publishedQuery = await payload.find({
collection: collectionConfig.slug,
depth: 0,
locale: locale || undefined,
user,
where: {
and: [
{
or: [
{
_status: {
equals: 'published',
},
},
{
_status: {
exists: false,
},
},
],
},
{
id: {
equals: id,
},
},
],
},
})
if (publishedQuery.docs?.[0]) {
hasPublishedDoc = true
}
if (versionsConfig.drafts?.autosave) {
const mostRecentVersion = await payload.findVersions({
collection: collectionConfig.slug,
depth: 0,
limit: 1,
user,
where: {
and: [
{
parent: {
equals: id,
},
},
],
},
})
if (
mostRecentVersion.docs[0] &&
'autosave' in mostRecentVersion.docs[0] &&
mostRecentVersion.docs[0].autosave
) {
mostRecentVersionIsAutosaved = true
}
}
if (publishedQuery.docs?.[0]?.updatedAt) {
;({ totalDocs: unpublishedVersionCount } = await payload.countVersions({
collection: collectionConfig.slug,
user,
where: {
and: [
{
parent: {
equals: id,
},
},
{
'version._status': {
equals: 'draft',
},
},
{
updatedAt: {
greater_than: publishedQuery.docs[0].updatedAt,
},
},
],
},
}))
}
}
;({ totalDocs: versionCount } = await payload.countVersions({
collection: collectionConfig.slug,
user,
where: {
and: [
{
parent: {
equals: id,
},
},
],
},
}))
}
if (globalConfig) {
if (versionsConfig?.drafts) {
publishedQuery = await payload.findGlobal({
slug: globalConfig.slug,
depth: 0,
locale,
user,
})
if (publishedQuery?._status === 'published') {
hasPublishedDoc = true
}
if (versionsConfig.drafts?.autosave) {
const mostRecentVersion = await payload.findGlobalVersions({
slug: globalConfig.slug,
limit: 1,
select: {
autosave: true,
},
user,
})
if (
mostRecentVersion.docs[0] &&
'autosave' in mostRecentVersion.docs[0] &&
mostRecentVersion.docs[0].autosave
) {
mostRecentVersionIsAutosaved = true
}
}
if (publishedQuery?.updatedAt) {
;({ totalDocs: unpublishedVersionCount } = await payload.countGlobalVersions({
depth: 0,
global: globalConfig.slug,
user,
where: {
and: [
{
'version._status': {
equals: 'draft',
},
},
{
updatedAt: {
greater_than: publishedQuery.updatedAt,
},
},
],
},
}))
}
}
;({ totalDocs: versionCount } = await payload.countGlobalVersions({
depth: 0,
global: globalConfig.slug,
user,
}))
}
return {
hasPublishedDoc,
mostRecentVersionIsAutosaved,
unpublishedVersionCount,
versionCount,
}
}

View File

@@ -10,8 +10,6 @@ import type {
} from 'payload' } from 'payload'
import type React from 'react' import type React from 'react'
import { notFound } from 'next/navigation.js'
import { APIView as DefaultAPIView } from '../API/index.js' import { APIView as DefaultAPIView } from '../API/index.js'
import { EditView as DefaultEditView } from '../Edit/index.js' import { EditView as DefaultEditView } from '../Edit/index.js'
import { LivePreviewView as DefaultLivePreviewView } from '../LivePreview/index.js' import { LivePreviewView as DefaultLivePreviewView } from '../LivePreview/index.js'
@@ -23,7 +21,7 @@ import { getCustomViewByRoute } from './getCustomViewByRoute.js'
export type ViewFromConfig<TProps extends object> = { export type ViewFromConfig<TProps extends object> = {
Component?: React.FC<TProps> Component?: React.FC<TProps>
payloadComponent?: PayloadComponent<TProps> ComponentConfig?: PayloadComponent<TProps>
} }
export const getViewsFromConfig = ({ export const getViewsFromConfig = ({
@@ -81,7 +79,7 @@ export const getViewsFromConfig = ({
routeSegments routeSegments
if (!overrideDocPermissions && !docPermissions?.read?.permission) { if (!overrideDocPermissions && !docPermissions?.read?.permission) {
notFound() throw new Error('not-found')
} else { } else {
// `../:id`, or `../create` // `../:id`, or `../create`
switch (routeSegments.length) { switch (routeSegments.length) {
@@ -94,7 +92,7 @@ export const getViewsFromConfig = ({
docPermissions?.create?.permission docPermissions?.create?.permission
) { ) {
CustomView = { CustomView = {
payloadComponent: getCustomViewByKey(views, 'default'), ComponentConfig: getCustomViewByKey(views, 'default'),
} }
DefaultView = { DefaultView = {
Component: DefaultEditView, Component: DefaultEditView,
@@ -132,11 +130,11 @@ export const getViewsFromConfig = ({
viewKey = customViewKey viewKey = customViewKey
CustomView = { CustomView = {
payloadComponent: CustomViewComponent, ComponentConfig: CustomViewComponent,
} }
} else { } else {
CustomView = { CustomView = {
payloadComponent: getCustomViewByKey(views, 'default'), ComponentConfig: getCustomViewByKey(views, 'default'),
} }
DefaultView = { DefaultView = {
@@ -156,7 +154,7 @@ export const getViewsFromConfig = ({
case 'api': { case 'api': {
if (collectionConfig?.admin?.hideAPIURL !== true) { if (collectionConfig?.admin?.hideAPIURL !== true) {
CustomView = { CustomView = {
payloadComponent: getCustomViewByKey(views, 'api'), ComponentConfig: getCustomViewByKey(views, 'api'),
} }
DefaultView = { DefaultView = {
Component: DefaultAPIView, Component: DefaultAPIView,
@@ -171,7 +169,7 @@ export const getViewsFromConfig = ({
Component: DefaultLivePreviewView, Component: DefaultLivePreviewView,
} }
CustomView = { CustomView = {
payloadComponent: getCustomViewByKey(views, 'livePreview'), ComponentConfig: getCustomViewByKey(views, 'livePreview'),
} }
} }
break break
@@ -180,7 +178,7 @@ export const getViewsFromConfig = ({
case 'versions': { case 'versions': {
if (!overrideDocPermissions && docPermissions?.readVersions?.permission) { if (!overrideDocPermissions && docPermissions?.readVersions?.permission) {
CustomView = { CustomView = {
payloadComponent: getCustomViewByKey(views, 'versions'), ComponentConfig: getCustomViewByKey(views, 'versions'),
} }
DefaultView = { DefaultView = {
Component: DefaultVersionsView, Component: DefaultVersionsView,
@@ -218,7 +216,7 @@ export const getViewsFromConfig = ({
viewKey = customViewKey viewKey = customViewKey
CustomView = { CustomView = {
payloadComponent: CustomViewComponent, ComponentConfig: CustomViewComponent,
} }
} }
@@ -233,7 +231,7 @@ export const getViewsFromConfig = ({
if (segment4 === 'versions') { if (segment4 === 'versions') {
if (!overrideDocPermissions && docPermissions?.readVersions?.permission) { if (!overrideDocPermissions && docPermissions?.readVersions?.permission) {
CustomView = { CustomView = {
payloadComponent: getCustomViewByKey(views, 'version'), ComponentConfig: getCustomViewByKey(views, 'version'),
} }
DefaultView = { DefaultView = {
Component: DefaultVersionView, Component: DefaultVersionView,
@@ -269,7 +267,7 @@ export const getViewsFromConfig = ({
viewKey = customViewKey viewKey = customViewKey
CustomView = { CustomView = {
payloadComponent: CustomViewComponent, ComponentConfig: CustomViewComponent,
} }
} }
} }
@@ -284,12 +282,12 @@ export const getViewsFromConfig = ({
const [globalEntity, globalSlug, segment3, ...remainingSegments] = routeSegments const [globalEntity, globalSlug, segment3, ...remainingSegments] = routeSegments
if (!overrideDocPermissions && !docPermissions?.read?.permission) { if (!overrideDocPermissions && !docPermissions?.read?.permission) {
notFound() throw new Error('not-found')
} else { } else {
switch (routeSegments.length) { switch (routeSegments.length) {
case 2: { case 2: {
CustomView = { CustomView = {
payloadComponent: getCustomViewByKey(views, 'default'), ComponentConfig: getCustomViewByKey(views, 'default'),
} }
DefaultView = { DefaultView = {
Component: DefaultEditView, Component: DefaultEditView,
@@ -303,7 +301,7 @@ export const getViewsFromConfig = ({
case 'api': { case 'api': {
if (globalConfig?.admin?.hideAPIURL !== true) { if (globalConfig?.admin?.hideAPIURL !== true) {
CustomView = { CustomView = {
payloadComponent: getCustomViewByKey(views, 'api'), ComponentConfig: getCustomViewByKey(views, 'api'),
} }
DefaultView = { DefaultView = {
Component: DefaultAPIView, Component: DefaultAPIView,
@@ -318,7 +316,7 @@ export const getViewsFromConfig = ({
Component: DefaultLivePreviewView, Component: DefaultLivePreviewView,
} }
CustomView = { CustomView = {
payloadComponent: getCustomViewByKey(views, 'livePreview'), ComponentConfig: getCustomViewByKey(views, 'livePreview'),
} }
} }
break break
@@ -327,7 +325,7 @@ export const getViewsFromConfig = ({
case 'versions': { case 'versions': {
if (!overrideDocPermissions && docPermissions?.readVersions?.permission) { if (!overrideDocPermissions && docPermissions?.readVersions?.permission) {
CustomView = { CustomView = {
payloadComponent: getCustomViewByKey(views, 'versions'), ComponentConfig: getCustomViewByKey(views, 'versions'),
} }
DefaultView = { DefaultView = {
@@ -362,7 +360,7 @@ export const getViewsFromConfig = ({
viewKey = customViewKey viewKey = customViewKey
CustomView = { CustomView = {
payloadComponent: CustomViewComponent, ComponentConfig: CustomViewComponent,
} }
} else { } else {
DefaultView = { DefaultView = {
@@ -385,7 +383,7 @@ export const getViewsFromConfig = ({
if (segment3 === 'versions') { if (segment3 === 'versions') {
if (!overrideDocPermissions && docPermissions?.readVersions?.permission) { if (!overrideDocPermissions && docPermissions?.readVersions?.permission) {
CustomView = { CustomView = {
payloadComponent: getCustomViewByKey(views, 'version'), ComponentConfig: getCustomViewByKey(views, 'version'),
} }
DefaultView = { DefaultView = {
Component: DefaultVersionView, Component: DefaultVersionView,
@@ -416,7 +414,7 @@ export const getViewsFromConfig = ({
viewKey = customViewKey viewKey = customViewKey
CustomView = { CustomView = {
payloadComponent: CustomViewComponent, ComponentConfig: CustomViewComponent,
} }
} }
} }

View File

@@ -0,0 +1,195 @@
import type { I18nClient } from '@payloadcms/translations'
import type {
ClientConfig,
Data,
DocumentPreferences,
FormState,
PayloadRequest,
SanitizedConfig,
VisibleEntities,
} from 'payload'
import { headers as getHeaders } from 'next/headers.js'
import { createClientConfig, getAccessResults, isEntityHidden, parseCookies } from 'payload'
import { renderDocument } from './index.js'
let cachedClientConfig = global._payload_clientConfig
if (!cachedClientConfig) {
cachedClientConfig = global._payload_clientConfig = null
}
export const getClientConfig = (args: {
config: SanitizedConfig
i18n: I18nClient
}): ClientConfig => {
const { config, i18n } = args
if (cachedClientConfig && process.env.NODE_ENV !== 'development') {
return cachedClientConfig
}
cachedClientConfig = createClientConfig({
config,
i18n,
})
return cachedClientConfig
}
type RenderDocumentResult = {
data: any
Document: React.ReactNode
preferences: DocumentPreferences
}
export const renderDocumentHandler = async (args: {
collectionSlug: string
disableActions?: boolean
docID: string
drawerSlug?: string
initialData?: Data
initialState?: FormState
redirectAfterDelete: boolean
redirectAfterDuplicate: boolean
req: PayloadRequest
}): Promise<RenderDocumentResult> => {
const {
collectionSlug,
disableActions,
docID,
drawerSlug,
initialData,
redirectAfterDelete,
redirectAfterDuplicate,
req,
req: {
i18n,
payload,
payload: { config },
user,
},
} = args
const headers = await getHeaders()
const cookies = parseCookies(headers)
const incomingUserSlug = user?.collection
const adminUserSlug = config.admin.user
// If we have a user slug, test it against the functions
if (incomingUserSlug) {
const adminAccessFunction = payload.collections[incomingUserSlug].config.access?.admin
// Run the admin access function from the config if it exists
if (adminAccessFunction) {
const canAccessAdmin = await adminAccessFunction({ req })
if (!canAccessAdmin) {
throw new Error('Unauthorized')
}
// Match the user collection to the global admin config
} else if (adminUserSlug !== incomingUserSlug) {
throw new Error('Unauthorized')
}
} else {
const hasUsers = await payload.find({
collection: adminUserSlug,
depth: 0,
limit: 1,
pagination: false,
})
// If there are users, we should not allow access because of /create-first-user
if (hasUsers.docs.length) {
throw new Error('Unauthorized')
}
}
const clientConfig = getClientConfig({
config,
i18n,
})
let preferences: DocumentPreferences
if (docID) {
const preferencesKey = `${collectionSlug}-edit-${docID}`
preferences = await payload
.find({
collection: 'payload-preferences',
depth: 0,
limit: 1,
where: {
and: [
{
key: {
equals: preferencesKey,
},
},
{
'user.relationTo': {
equals: user.collection,
},
},
{
'user.value': {
equals: user.id,
},
},
],
},
})
.then((res) => res.docs[0]?.value as DocumentPreferences)
}
const visibleEntities: VisibleEntities = {
collections: payload.config.collections
.map(({ slug, admin: { hidden } }) => (!isEntityHidden({ hidden, user }) ? slug : null))
.filter(Boolean),
globals: payload.config.globals
.map(({ slug, admin: { hidden } }) => (!isEntityHidden({ hidden, user }) ? slug : null))
.filter(Boolean),
}
const permissions = await getAccessResults({
req,
})
const { data, Document } = await renderDocument({
clientConfig,
disableActions,
drawerSlug,
importMap: payload.importMap,
initialData,
initPageResult: {
collectionConfig: payload.config.collections.find(
(collection) => collection.slug === collectionSlug,
),
cookies,
docID,
globalConfig: payload.config.globals.find((global) => global.slug === collectionSlug),
languageOptions: undefined, // TODO
permissions,
req,
translations: undefined, // TODO
visibleEntities,
},
params: {
segments: ['collections', collectionSlug, docID],
},
redirectAfterDelete,
redirectAfterDuplicate,
searchParams: {},
})
return {
data,
Document,
preferences,
}
}

View File

@@ -1,37 +1,52 @@
import type { import type {
AdminViewProps, AdminViewProps,
EditViewComponent, Data,
MappedComponent, PayloadComponent,
ServerProps,
ServerSideEditViewProps, ServerSideEditViewProps,
} from 'payload' } from 'payload'
import { DocumentInfoProvider, EditDepthProvider, HydrateAuthProvider } from '@payloadcms/ui' import { DocumentInfoProvider, EditDepthProvider, HydrateAuthProvider } from '@payloadcms/ui'
import { import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
formatAdminURL, import { formatAdminURL, isEditing as getIsEditing } from '@payloadcms/ui/shared'
getCreateMappedComponent, import { buildFormState } from '@payloadcms/ui/utilities/buildFormState'
isEditing as getIsEditing, import { isRedirectError } from 'next/dist/client/components/redirect.js'
RenderComponent,
} from '@payloadcms/ui/shared'
import { notFound, redirect } from 'next/navigation.js' import { notFound, redirect } from 'next/navigation.js'
import React from 'react' import React from 'react'
import type { GenerateEditViewMetadata } from './getMetaBySegment.js' import type { GenerateEditViewMetadata } from './getMetaBySegment.js'
import type { ViewFromConfig } from './getViewsFromConfig.js'
import { DocumentHeader } from '../../elements/DocumentHeader/index.js' import { DocumentHeader } from '../../elements/DocumentHeader/index.js'
import { NotFoundView } from '../NotFound/index.js' import { NotFoundView } from '../NotFound/index.js'
import { getDocPreferences } from './getDocPreferences.js'
import { getDocumentData } from './getDocumentData.js' import { getDocumentData } from './getDocumentData.js'
import { getDocumentPermissions } from './getDocumentPermissions.js' import { getDocumentPermissions } from './getDocumentPermissions.js'
import { getIsLocked } from './getIsLocked.js'
import { getMetaBySegment } from './getMetaBySegment.js' import { getMetaBySegment } from './getMetaBySegment.js'
import { getVersions } from './getVersions.js'
import { getViewsFromConfig } from './getViewsFromConfig.js' import { getViewsFromConfig } from './getViewsFromConfig.js'
import { renderDocumentSlots } from './renderDocumentSlots.js'
export const generateMetadata: GenerateEditViewMetadata = async (args) => getMetaBySegment(args) export const generateMetadata: GenerateEditViewMetadata = async (args) => getMetaBySegment(args)
export const Document: React.FC<AdminViewProps> = async ({ // This function will be responsible for rendering an Edit Document view
// it will be called on the server for Edit page views as well as
// called on-demand from document drawers
export const renderDocument = async ({
disableActions,
drawerSlug,
importMap, importMap,
initialData,
initPageResult, initPageResult,
params, params,
redirectAfterDelete,
redirectAfterDuplicate,
searchParams, searchParams,
}) => { }: AdminViewProps): Promise<{
data: Data
Document: React.ReactNode
}> => {
const { const {
collectionConfig, collectionConfig,
docID: id, docID: id,
@@ -57,54 +72,108 @@ export const Document: React.FC<AdminViewProps> = async ({
const segments = Array.isArray(params?.segments) ? params.segments : [] const segments = Array.isArray(params?.segments) ? params.segments : []
const collectionSlug = collectionConfig?.slug || undefined const collectionSlug = collectionConfig?.slug || undefined
const globalSlug = globalConfig?.slug || undefined const globalSlug = globalConfig?.slug || undefined
const isEditing = getIsEditing({ id, collectionSlug, globalSlug }) const isEditing = getIsEditing({ id, collectionSlug, globalSlug })
let RootViewOverride: MappedComponent<ServerSideEditViewProps> let RootViewOverride: PayloadComponent
let CustomView: MappedComponent<ServerSideEditViewProps> let CustomView: ViewFromConfig<ServerSideEditViewProps>
let DefaultView: MappedComponent<ServerSideEditViewProps> let DefaultView: ViewFromConfig<ServerSideEditViewProps>
let ErrorView: MappedComponent<AdminViewProps> let ErrorView: ViewFromConfig<AdminViewProps>
let apiURL: string let apiURL: string
const { data, formState } = await getDocumentData({ // Fetch the doc required for the view
id, const doc =
collectionConfig, initialData ||
globalConfig, (await getDocumentData({
locale, id,
req, collectionSlug,
}) globalSlug,
locale,
payload,
user,
}))
if (!data) { if (isEditing && !doc) {
notFound() throw new Error('not-found')
} }
const { docPermissions, hasPublishPermission, hasSavePermission } = await getDocumentPermissions({ const [
id, docPreferences,
collectionConfig, { docPermissions, hasPublishPermission, hasSavePermission },
data, { currentEditor, isLocked, lastUpdateTime },
globalConfig, ] = await Promise.all([
req, // Get document preferences
}) getDocPreferences({
id,
const createMappedComponent = getCreateMappedComponent({ collectionSlug,
importMap, globalSlug,
serverProps: {
i18n,
initPageResult,
locale,
params,
payload, payload,
permissions,
routeSegments: segments,
searchParams,
user, user,
}, }),
})
// Get permissions
getDocumentPermissions({
id,
collectionConfig,
data: doc,
globalConfig,
req,
}),
// Fetch document lock state
getIsLocked({
id,
collectionConfig,
globalConfig,
isEditing,
payload: req.payload,
user,
}),
])
const [
{ hasPublishedDoc, mostRecentVersionIsAutosaved, unpublishedVersionCount, versionCount },
{ state: formState },
] = await Promise.all([
getVersions({
id,
collectionConfig,
docPermissions,
globalConfig,
locale: locale?.code,
payload,
user,
}),
buildFormState({
id,
collectionSlug,
data: doc,
docPermissions,
docPreferences,
globalSlug,
locale: locale?.code,
operation: (collectionSlug && id) || globalSlug ? 'update' : 'create',
renderAllFields: true,
req,
schemaPath: collectionSlug || globalSlug,
}),
])
const serverProps: 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() throw new Error('not-found')
} }
const params = new URLSearchParams() const params = new URLSearchParams()
@@ -122,12 +191,7 @@ export const Document: React.FC<AdminViewProps> = async ({
RootViewOverride = RootViewOverride =
collectionConfig?.admin?.components?.views?.edit?.root && collectionConfig?.admin?.components?.views?.edit?.root &&
'Component' in collectionConfig.admin.components.views.edit.root 'Component' in collectionConfig.admin.components.views.edit.root
? createMappedComponent( ? collectionConfig?.admin?.components?.views?.edit?.root?.Component
collectionConfig?.admin?.components?.views?.edit?.root?.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?.root',
)
: null : null
if (!RootViewOverride) { if (!RootViewOverride) {
@@ -138,36 +202,21 @@ export const Document: React.FC<AdminViewProps> = async ({
routeSegments: segments, routeSegments: segments,
}) })
CustomView = createMappedComponent( CustomView = collectionViews?.CustomView
collectionViews?.CustomView?.payloadComponent, DefaultView = collectionViews?.DefaultView
undefined, ErrorView = collectionViews?.ErrorView
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 && !RootViewOverride && !ErrorView) { if (!CustomView && !DefaultView && !RootViewOverride && !ErrorView) {
ErrorView = createMappedComponent(undefined, undefined, NotFoundView, 'NotFoundView') ErrorView = {
Component: NotFoundView,
}
} }
} }
if (globalConfig) { if (globalConfig) {
if (!visibleEntities?.globals?.find((visibleSlug) => visibleSlug === globalSlug)) { if (!visibleEntities?.globals?.find((visibleSlug) => visibleSlug === globalSlug)) {
notFound() throw new Error('not-found')
} }
const params = new URLSearchParams({ const params = new URLSearchParams({
@@ -189,12 +238,7 @@ export const Document: React.FC<AdminViewProps> = async ({
RootViewOverride = RootViewOverride =
globalConfig?.admin?.components?.views?.edit?.root && globalConfig?.admin?.components?.views?.edit?.root &&
'Component' in globalConfig.admin.components.views.edit.root 'Component' in globalConfig.admin.components.views.edit.root
? createMappedComponent( ? globalConfig?.admin?.components?.views?.edit?.root?.Component
globalConfig?.admin?.components?.views?.edit?.root?.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,
'globalConfig?.admin?.components?.views?.edit?.root',
)
: null : null
if (!RootViewOverride) { if (!RootViewOverride) {
@@ -205,29 +249,14 @@ export const Document: React.FC<AdminViewProps> = async ({
routeSegments: segments, routeSegments: segments,
}) })
CustomView = createMappedComponent( CustomView = globalViews?.CustomView
globalViews?.CustomView?.payloadComponent, DefaultView = globalViews?.DefaultView
undefined, ErrorView = globalViews?.ErrorView
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 && !RootViewOverride && !ErrorView) { if (!CustomView && !DefaultView && !RootViewOverride && !ErrorView) {
ErrorView = createMappedComponent(undefined, undefined, NotFoundView, 'NotFoundView') ErrorView = {
Component: NotFoundView,
}
} }
} }
} }
@@ -240,13 +269,14 @@ export const Document: React.FC<AdminViewProps> = async ({
hasSavePermission && hasSavePermission &&
((collectionConfig?.versions?.drafts && collectionConfig?.versions?.drafts?.autosave) || ((collectionConfig?.versions?.drafts && collectionConfig?.versions?.drafts?.autosave) ||
(globalConfig?.versions?.drafts && globalConfig?.versions?.drafts?.autosave)) (globalConfig?.versions?.drafts && globalConfig?.versions?.drafts?.autosave))
const validateDraftData = const validateDraftData =
collectionConfig?.versions?.drafts && collectionConfig?.versions?.drafts?.validate collectionConfig?.versions?.drafts && collectionConfig?.versions?.drafts?.validate
if (shouldAutosave && !validateDraftData && !id && collectionSlug) { if (shouldAutosave && !validateDraftData && !id && collectionSlug) {
const doc = await payload.create({ const doc = await payload.create({
collection: collectionSlug, collection: collectionSlug,
data: {}, data: initialData || {},
depth: 0, depth: 0,
draft: true, draft: true,
fallbackLocale: null, fallbackLocale: null,
@@ -263,57 +293,96 @@ export const Document: React.FC<AdminViewProps> = async ({
}) })
redirect(redirectURL) redirect(redirectURL)
} else { } else {
notFound() throw new Error('not-found')
} }
} }
return ( const documentSlots = renderDocumentSlots({
<DocumentInfoProvider collectionConfig,
apiURL={apiURL} globalConfig,
collectionSlug={collectionConfig?.slug} hasSavePermission,
disableActions={false} permissions: docPermissions,
docPermissions={docPermissions} req,
globalSlug={globalConfig?.slug} })
hasPublishPermission={hasPublishPermission}
hasSavePermission={hasSavePermission} const clientProps = { formState, ...documentSlots }
id={id}
initialData={data} return {
initialState={formState} data: doc,
isEditing={isEditing} Document: (
key={locale?.code} <DocumentInfoProvider
> apiURL={apiURL}
{!RootViewOverride && ( collectionSlug={collectionConfig?.slug}
<DocumentHeader currentEditor={currentEditor}
collectionConfig={collectionConfig} disableActions={disableActions ?? false}
globalConfig={globalConfig} docPermissions={docPermissions}
i18n={i18n} globalSlug={globalConfig?.slug}
payload={payload} hasPublishedDoc={hasPublishedDoc}
permissions={permissions} hasPublishPermission={hasPublishPermission}
/> hasSavePermission={hasSavePermission}
)} id={id}
<HydrateAuthProvider permissions={permissions} /> initialData={doc}
{/** initialState={formState}
* After bumping the Next.js canary to 104, and React to 19.0.0-rc-06d0b89e-20240801" we have to deepCopy the permissions object (https://github.com/payloadcms/payload/pull/7541). isEditing={isEditing}
* If both HydrateClientUser and RenderCustomComponent receive the same permissions object (same object reference), we get a isLocked={isLocked}
* "TypeError: Cannot read properties of undefined (reading '$$typeof')" error when loading up some version views - for example a versions key={locale?.code}
* view in the draft-posts collection of the versions test suite. RenderCustomComponent is what renders the versions view. lastUpdateTime={lastUpdateTime}
* mostRecentVersionIsAutosaved={mostRecentVersionIsAutosaved}
* // TODO: Revisit this in the future and figure out why this is happening. Might be a React/Next.js bug. We don't know why it happens, and a future React/Next version might unbreak this (keep an eye on this and remove deepCopyObjectSimple if that's the case) redirectAfterDelete={redirectAfterDelete}
*/} redirectAfterDuplicate={redirectAfterDuplicate}
<EditDepthProvider unpublishedVersionCount={unpublishedVersionCount}
depth={1} versionCount={versionCount}
key={`${collectionSlug || globalSlug}${locale?.code ? `-${locale?.code}` : ''}`}
> >
{ErrorView ? ( {!RootViewOverride && !drawerSlug && (
<RenderComponent mappedComponent={ErrorView} /> <DocumentHeader
) : ( collectionConfig={collectionConfig}
<RenderComponent globalConfig={globalConfig}
mappedComponent={ i18n={i18n}
RootViewOverride ? RootViewOverride : CustomView ? CustomView : DefaultView payload={payload}
} permissions={permissions}
/> />
)} )}
</EditDepthProvider> <HydrateAuthProvider permissions={permissions} />
</DocumentInfoProvider> <EditDepthProvider>
) {ErrorView ? (
<RenderServerComponent
clientProps={clientProps}
Component={ErrorView.ComponentConfig || ErrorView.Component}
importMap={importMap}
serverProps={serverProps}
/>
) : (
<RenderServerComponent
clientProps={clientProps}
Component={
RootViewOverride
? RootViewOverride
: CustomView?.ComponentConfig || CustomView?.Component
? CustomView?.ComponentConfig || CustomView?.Component
: DefaultView?.ComponentConfig || DefaultView?.Component
}
importMap={importMap}
serverProps={serverProps}
/>
)}
</EditDepthProvider>
</DocumentInfoProvider>
),
}
}
export const Document: React.FC<AdminViewProps> = async (args) => {
try {
const { Document: RenderedDocument } = await renderDocument(args)
return RenderedDocument
} catch (error) {
if (isRedirectError(error)) {
throw error
}
args.initPageResult.req.payload.logger.error(error)
if (error.message === 'not-found') {
notFound()
}
}
} }

View File

@@ -0,0 +1,136 @@
import type {
DefaultServerFunctionArgs,
DocumentPermissions,
DocumentSlots,
PayloadRequest,
SanitizedCollectionConfig,
SanitizedGlobalConfig,
StaticDescription,
} from 'payload'
import { ViewDescription } from '@payloadcms/ui'
import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
import React from 'react'
import { getDocumentPermissions } from './getDocumentPermissions.js'
export const renderDocumentSlots: (args: {
collectionConfig?: SanitizedCollectionConfig
globalConfig?: SanitizedGlobalConfig
hasSavePermission: boolean
permissions: DocumentPermissions
req: PayloadRequest
}) => DocumentSlots = (args) => {
const { collectionConfig, globalConfig, hasSavePermission, req } = args
const components: DocumentSlots = {} as DocumentSlots
const unsavedDraftWithValidations = undefined
const isPreviewEnabled = collectionConfig?.admin?.preview || globalConfig?.admin?.preview
const CustomPreviewButton =
collectionConfig?.admin?.components?.edit?.PreviewButton ||
globalConfig?.admin?.components?.elements?.PreviewButton
if (isPreviewEnabled && CustomPreviewButton) {
components.PreviewButton = (
<RenderServerComponent Component={CustomPreviewButton} importMap={req.payload.importMap} />
)
}
const descriptionFromConfig =
collectionConfig?.admin?.description || globalConfig?.admin?.description
const staticDescription: StaticDescription =
typeof descriptionFromConfig === 'function'
? descriptionFromConfig({ t: req.i18n.t })
: descriptionFromConfig
const CustomDescription =
collectionConfig?.admin?.components?.Description ||
globalConfig?.admin?.components?.elements?.Description
const hasDescription = CustomDescription || staticDescription
if (hasDescription) {
components.Description = (
<RenderServerComponent
clientProps={{ description: staticDescription }}
Component={CustomDescription}
Fallback={ViewDescription}
importMap={req.payload.importMap}
/>
)
}
if (hasSavePermission) {
if (collectionConfig?.versions?.drafts || globalConfig?.versions?.drafts) {
const CustomPublishButton =
collectionConfig?.admin?.components?.edit?.PublishButton ||
globalConfig?.admin?.components?.elements?.PublishButton
if (CustomPublishButton) {
components.PublishButton = (
<RenderServerComponent
Component={CustomPublishButton}
importMap={req.payload.importMap}
/>
)
}
const CustomSaveDraftButton =
collectionConfig?.admin?.components?.edit?.SaveDraftButton ||
globalConfig?.admin?.components?.elements?.SaveDraftButton
const draftsEnabled =
(collectionConfig?.versions?.drafts && !collectionConfig?.versions?.drafts?.autosave) ||
(globalConfig?.versions?.drafts && !globalConfig?.versions?.drafts?.autosave)
if ((draftsEnabled || unsavedDraftWithValidations) && CustomSaveDraftButton) {
components.SaveDraftButton = (
<RenderServerComponent
Component={CustomSaveDraftButton}
importMap={req.payload.importMap}
/>
)
}
} else {
const CustomSaveButton =
collectionConfig?.admin?.components?.edit?.SaveButton ||
globalConfig?.admin?.components?.elements?.SaveButton
if (CustomSaveButton) {
components.SaveButton = (
<RenderServerComponent Component={CustomSaveButton} importMap={req.payload.importMap} />
)
}
}
}
return components
}
export const renderDocumentSlotsHandler = async (
args: { collectionSlug: string } & DefaultServerFunctionArgs,
) => {
const { collectionSlug, req } = args
const collectionConfig = req.payload.collections[collectionSlug]?.config
if (!collectionConfig) {
throw new Error(req.t('error:incorrectCollection'))
}
const { docPermissions, hasSavePermission } = await getDocumentPermissions({
collectionConfig,
data: {},
req,
})
return renderDocumentSlots({
collectionConfig,
hasSavePermission,
permissions: docPermissions,
req,
})
}

View File

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

View File

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

View File

@@ -105,9 +105,11 @@ export const ForgotPasswordForm: React.FC = () => {
/> />
) : ( ) : (
<EmailField <EmailField
autoComplete="email"
field={{ field={{
name: 'email', name: 'email',
admin: {
autoComplete: 'email',
},
label: t('general:email'), label: t('general:email'),
required: true, required: true,
}} }}

View File

@@ -1,259 +0,0 @@
'use client'
import type { ClientCollectionConfig } from 'payload'
import { getTranslation } from '@payloadcms/translations'
import {
Button,
DeleteMany,
EditMany,
Gutter,
ListControls,
ListHeader,
ListSelection,
Pagination,
PerPage,
PublishMany,
RelationshipProvider,
RenderComponent,
SelectionProvider,
SetViewActions,
StaggeredShimmers,
Table,
UnpublishMany,
useAuth,
useBulkUpload,
useConfig,
useEditDepth,
useListInfo,
useListQuery,
useModal,
useStepNav,
useTranslation,
useWindowInfo,
ViewDescription,
} from '@payloadcms/ui'
import LinkImport from 'next/link.js'
import { useRouter } from 'next/navigation.js'
import { formatFilesize, isNumber } from 'payload/shared'
import React, { Fragment, useEffect } from 'react'
import './index.scss'
const baseClass = 'collection-list'
const Link = (LinkImport.default || LinkImport) as unknown as typeof LinkImport.default
export const DefaultListView: React.FC = () => {
const { user } = useAuth()
const {
beforeActions,
collectionSlug,
disableBulkDelete,
disableBulkEdit,
hasCreatePermission,
Header,
newDocumentURL,
} = useListInfo()
const router = useRouter()
const { data, defaultLimit, handlePageChange, handlePerPageChange, params } = useListQuery()
const { openModal } = useModal()
const { setCollectionSlug, setOnSuccess } = useBulkUpload()
const { drawerSlug } = useBulkUpload()
const { getEntityConfig } = useConfig()
const collectionConfig = getEntityConfig({ collectionSlug }) as ClientCollectionConfig
const {
admin: {
components: {
afterList,
afterListTable,
beforeList,
beforeListTable,
Description,
views: {
list: { actions },
},
},
description,
},
fields,
labels,
} = collectionConfig
const { i18n, t } = useTranslation()
const drawerDepth = useEditDepth()
const { setStepNav } = useStepNav()
const {
breakpoints: { s: smallBreak },
} = useWindowInfo()
let docs = data.docs || []
const isUploadCollection = Boolean(collectionConfig.upload)
if (isUploadCollection) {
docs = docs?.map((doc) => {
return {
...doc,
filesize: formatFilesize(doc.filesize),
}
})
}
const openBulkUpload = React.useCallback(() => {
setCollectionSlug(collectionSlug)
openModal(drawerSlug)
setOnSuccess(() => router.refresh())
}, [router, collectionSlug, drawerSlug, openModal, setCollectionSlug, setOnSuccess])
useEffect(() => {
if (drawerDepth <= 1) {
setStepNav([
{
label: labels?.plural,
},
])
}
}, [setStepNav, labels, drawerDepth])
const isBulkUploadEnabled = isUploadCollection && collectionConfig.upload.bulkUpload
return (
<div className={`${baseClass} ${baseClass}--${collectionSlug}`}>
<SetViewActions actions={actions} />
<SelectionProvider docs={data.docs} totalDocs={data.totalDocs} user={user}>
<RenderComponent mappedComponent={beforeList} />
<Gutter className={`${baseClass}__wrap`}>
{Header || (
<ListHeader heading={getTranslation(labels?.plural, i18n)}>
{hasCreatePermission && (
<>
<Button
aria-label={i18n.t('general:createNewLabel', {
label: getTranslation(labels?.singular, i18n),
})}
buttonStyle="pill"
el={'link'}
Link={Link}
size="small"
to={newDocumentURL}
>
{i18n.t('general:createNew')}
</Button>
{isBulkUploadEnabled && (
<Button
aria-label={t('upload:bulkUpload')}
buttonStyle="pill"
onClick={openBulkUpload}
size="small"
>
{t('upload:bulkUpload')}
</Button>
)}
</>
)}
{!smallBreak && (
<ListSelection label={getTranslation(collectionConfig.labels.plural, i18n)} />
)}
{(description || Description) && (
<div className={`${baseClass}__sub-header`}>
<ViewDescription Description={Description} description={description} />
</div>
)}
</ListHeader>
)}
<ListControls collectionConfig={collectionConfig} fields={fields} />
<RenderComponent mappedComponent={beforeListTable} />
{!data.docs && (
<StaggeredShimmers
className={[`${baseClass}__shimmer`, `${baseClass}__shimmer--rows`].join(' ')}
count={6}
/>
)}
{data.docs && data.docs.length > 0 && (
<RelationshipProvider>
<Table
customCellContext={{
collectionSlug,
uploadConfig: collectionConfig.upload,
}}
data={docs}
fields={fields}
/>
</RelationshipProvider>
)}
{data.docs && data.docs.length === 0 && (
<div className={`${baseClass}__no-results`}>
<p>{i18n.t('general:noResults', { label: getTranslation(labels?.plural, i18n) })}</p>
{hasCreatePermission && newDocumentURL && (
<Button el="link" Link={Link} to={newDocumentURL}>
{i18n.t('general:createNewLabel', {
label: getTranslation(labels?.singular, i18n),
})}
</Button>
)}
</div>
)}
<RenderComponent mappedComponent={afterListTable} />
{data.docs && data.docs.length > 0 && (
<div className={`${baseClass}__page-controls`}>
<Pagination
hasNextPage={data.hasNextPage}
hasPrevPage={data.hasPrevPage}
limit={data.limit}
nextPage={data.nextPage}
numberOfNeighbors={1}
onChange={(page) => void handlePageChange(page)}
page={data.page}
prevPage={data.prevPage}
totalPages={data.totalPages}
/>
{data?.totalDocs > 0 && (
<Fragment>
<div className={`${baseClass}__page-info`}>
{data.page * data.limit - (data.limit - 1)}-
{data.totalPages > 1 && data.totalPages !== data.page
? data.limit * data.page
: data.totalDocs}{' '}
{i18n.t('general:of')} {data.totalDocs}
</div>
<PerPage
handleChange={(limit) => void handlePerPageChange(limit)}
limit={isNumber(params?.limit) ? Number(params.limit) : defaultLimit}
limits={collectionConfig?.admin?.pagination?.limits}
resetPage={data.totalDocs <= data.pagingCounter}
/>
{smallBreak && (
<div className={`${baseClass}__list-selection`}>
<ListSelection label={getTranslation(collectionConfig.labels.plural, i18n)} />
<div className={`${baseClass}__list-selection-actions`}>
{beforeActions && beforeActions}
{!disableBulkEdit && (
<Fragment>
<EditMany collection={collectionConfig} fields={fields} />
<PublishMany collection={collectionConfig} />
<UnpublishMany collection={collectionConfig} />
</Fragment>
)}
{!disableBulkDelete && <DeleteMany collection={collectionConfig} />}
</div>
</div>
)}
</Fragment>
)}
</div>
)}
</Gutter>
<RenderComponent mappedComponent={afterList} />
</SelectionProvider>
</div>
)
}

View File

@@ -0,0 +1,194 @@
import type { I18nClient } from '@payloadcms/translations'
import type { ListPreferences } from '@payloadcms/ui'
import type {
ClientConfig,
ListQuery,
PayloadRequest,
SanitizedConfig,
VisibleEntities,
} from 'payload'
import { headers as getHeaders } from 'next/headers.js'
import { createClientConfig, getAccessResults, isEntityHidden, parseCookies } from 'payload'
import { renderListView } from './index.js'
let cachedClientConfig = global._payload_clientConfig
if (!cachedClientConfig) {
cachedClientConfig = global._payload_clientConfig = null
}
export const getClientConfig = (args: {
config: SanitizedConfig
i18n: I18nClient
}): ClientConfig => {
const { config, i18n } = args
if (cachedClientConfig && process.env.NODE_ENV !== 'development') {
return cachedClientConfig
}
cachedClientConfig = createClientConfig({
config,
i18n,
})
return cachedClientConfig
}
type RenderListResult = {
List: React.ReactNode
preferences: ListPreferences
}
export const renderListHandler = async (args: {
collectionSlug: string
disableActions?: boolean
disableBulkDelete?: boolean
disableBulkEdit?: boolean
documentDrawerSlug: string
drawerSlug?: string
enableRowSelections: boolean
query: ListQuery
redirectAfterDelete: boolean
redirectAfterDuplicate: boolean
req: PayloadRequest
}): Promise<RenderListResult> => {
const {
collectionSlug,
disableActions,
disableBulkDelete,
disableBulkEdit,
drawerSlug,
enableRowSelections,
query,
redirectAfterDelete,
redirectAfterDuplicate,
req,
req: {
i18n,
payload,
payload: { config },
user,
},
} = args
const headers = await getHeaders()
const cookies = parseCookies(headers)
const incomingUserSlug = user?.collection
const adminUserSlug = config.admin.user
// If we have a user slug, test it against the functions
if (incomingUserSlug) {
const adminAccessFunction = payload.collections[incomingUserSlug].config.access?.admin
// Run the admin access function from the config if it exists
if (adminAccessFunction) {
const canAccessAdmin = await adminAccessFunction({ req })
if (!canAccessAdmin) {
throw new Error('Unauthorized')
}
// Match the user collection to the global admin config
} else if (adminUserSlug !== incomingUserSlug) {
throw new Error('Unauthorized')
}
} else {
const hasUsers = await payload.find({
collection: adminUserSlug,
depth: 0,
limit: 1,
pagination: false,
})
// If there are users, we should not allow access because of /create-first-user
if (hasUsers.docs.length) {
throw new Error('Unauthorized')
}
}
const clientConfig = getClientConfig({
config,
i18n,
})
const preferencesKey = `${collectionSlug}-list`
const preferences = await payload
.find({
collection: 'payload-preferences',
depth: 0,
limit: 1,
where: {
and: [
{
key: {
equals: preferencesKey,
},
},
{
'user.relationTo': {
equals: user.collection,
},
},
{
'user.value': {
equals: user.id,
},
},
],
},
})
.then((res) => res.docs[0]?.value as ListPreferences)
const visibleEntities: VisibleEntities = {
collections: payload.config.collections
.map(({ slug, admin: { hidden } }) => (!isEntityHidden({ hidden, user }) ? slug : null))
.filter(Boolean),
globals: payload.config.globals
.map(({ slug, admin: { hidden } }) => (!isEntityHidden({ hidden, user }) ? slug : null))
.filter(Boolean),
}
const permissions = await getAccessResults({
req,
})
const { List } = await renderListView({
clientConfig,
disableActions,
disableBulkDelete,
disableBulkEdit,
drawerSlug,
enableRowSelections,
importMap: payload.importMap,
initPageResult: {
collectionConfig: payload.config.collections.find(
(collection) => collection.slug === collectionSlug,
),
cookies,
globalConfig: payload.config.globals.find((global) => global.slug === collectionSlug),
languageOptions: undefined, // TODO
permissions,
req,
translations: undefined, // TODO
visibleEntities,
},
params: {
segments: ['collections', collectionSlug],
},
query,
redirectAfterDelete,
redirectAfterDuplicate,
searchParams: {},
})
return {
List,
preferences,
}
}

View File

@@ -1,32 +1,52 @@
import type { AdminViewProps, ClientCollectionConfig, Where } from 'payload' import type { ListPreferences, ListViewClientProps } from '@payloadcms/ui'
import type { AdminViewProps, ListQuery, Where } from 'payload'
import { import { DefaultListView, HydrateAuthProvider, ListQueryProvider } from '@payloadcms/ui'
HydrateAuthProvider, import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
ListInfoProvider, import { renderFilters, renderTable } from '@payloadcms/ui/rsc'
ListQueryProvider, import { formatAdminURL, mergeListSearchAndWhere } from '@payloadcms/ui/shared'
TableColumnsProvider,
} from '@payloadcms/ui'
import { formatAdminURL, getCreateMappedComponent, RenderComponent } from '@payloadcms/ui/shared'
import { createClientCollectionConfig } from '@payloadcms/ui/utilities/createClientConfig'
import { notFound } from 'next/navigation.js' import { notFound } from 'next/navigation.js'
import { deepCopyObjectSimple, mergeListSearchAndWhere } from 'payload'
import { isNumber } from 'payload/shared' import { isNumber } from 'payload/shared'
import React, { Fragment } from 'react' import React, { Fragment } from 'react'
import type { ListPreferences } from './Default/types.js' import { renderListViewSlots } from './renderListViewSlots.js'
import { DefaultEditView } from '../Edit/Default/index.js'
import { DefaultListView } from './Default/index.js'
export { generateListMetadata } from './meta.js' export { generateListMetadata } from './meta.js'
export const ListView: React.FC<AdminViewProps> = async ({ type ListViewArgs = {
initPageResult, customCellProps?: Record<string, any>
params, disableBulkDelete?: boolean
searchParams, disableBulkEdit?: boolean
}) => { enableRowSelections: boolean
query: ListQuery
} & AdminViewProps
export const renderListView = async (
args: ListViewArgs,
): Promise<{
List: React.ReactNode
}> => {
const {
clientConfig,
customCellProps,
disableBulkDelete,
disableBulkEdit,
drawerSlug,
enableRowSelections,
initPageResult,
params,
query: queryFromArgs,
searchParams,
} = args
const { const {
collectionConfig, collectionConfig,
collectionConfig: {
slug: collectionSlug,
admin: { useAsTitle },
defaultSort,
fields,
},
locale: fullLocale, locale: fullLocale,
permissions, permissions,
req, req,
@@ -35,18 +55,18 @@ export const ListView: React.FC<AdminViewProps> = async ({
locale, locale,
payload, payload,
payload: { config }, payload: { config },
query, query: queryFromReq,
user, user,
}, },
visibleEntities, visibleEntities,
} = initPageResult } = initPageResult
const collectionSlug = collectionConfig?.slug
if (!permissions?.collections?.[collectionSlug]?.read?.permission) { if (!permissions?.collections?.[collectionSlug]?.read?.permission) {
notFound() throw new Error('not-found')
} }
const query = queryFromArgs || queryFromReq
let listPreferences: ListPreferences let listPreferences: ListPreferences
const preferenceKey = `${collectionSlug}-list` const preferenceKey = `${collectionSlug}-list`
@@ -79,7 +99,7 @@ export const ListView: React.FC<AdminViewProps> = async ({
}, },
}) })
?.then((res) => res?.docs?.[0]?.value)) as ListPreferences ?.then((res) => res?.docs?.[0]?.value)) as ListPreferences
} catch (error) {} // eslint-disable-line no-empty } catch (_err) {} // eslint-disable-line no-empty
const { const {
routes: { admin: adminRoute }, routes: { admin: adminRoute },
@@ -87,20 +107,21 @@ export const ListView: React.FC<AdminViewProps> = async ({
if (collectionConfig) { if (collectionConfig) {
if (!visibleEntities.collections.includes(collectionSlug)) { if (!visibleEntities.collections.includes(collectionSlug)) {
return notFound() throw new Error('not-found')
} }
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,
query: { search: typeof query?.search === 'string' ? query.search : undefined,
search: typeof query?.search === 'string' ? query.search : undefined, where: (query?.where as Where) || undefined,
where: (query?.where as Where) || undefined,
},
}) })
const limit = isNumber(query?.limit) const limit = isNumber(query?.limit)
? Number(query.limit) ? Number(query.limit)
: listPreferences?.limit || collectionConfig.admin.pagination.defaultLimit : listPreferences?.limit || collectionConfig.admin.pagination.defaultLimit
const sort = const sort =
query?.sort && typeof query.sort === 'string' query?.sort && typeof query.sort === 'string'
? query.sort ? query.sort
@@ -125,89 +146,104 @@ export const ListView: React.FC<AdminViewProps> = async ({
where: whereQuery || {}, where: whereQuery || {},
}) })
const createMappedComponent = getCreateMappedComponent({ const clientCollectionConfig = clientConfig.collections.find((c) => c.slug === collectionSlug)
importMap: payload.importMap,
serverProps: { const { columnState, Table } = renderTable({
collectionConfig, collectionConfig: clientCollectionConfig,
collectionSlug, columnPreferences: listPreferences?.columns,
data, customCellProps,
hasCreatePermission: permissions?.collections?.[collectionSlug]?.create?.permission, docs: data.docs,
i18n, drawerSlug,
limit, enableRowSelections,
listPreferences, fields,
listSearchableFields: collectionConfig.admin.listSearchableFields, i18n: req.i18n,
locale: fullLocale, payload,
newDocumentURL: formatAdminURL({ useAsTitle,
adminRoute,
path: `/collections/${collectionSlug}/create`,
}),
params,
payload,
permissions,
searchParams,
user,
},
}) })
const ListComponent = createMappedComponent( const renderedFilters = renderFilters(fields, req.payload.importMap)
collectionConfig?.admin?.components?.views?.list?.Component,
undefined,
DefaultListView,
'collectionConfig?.admin?.components?.views?.list?.Component',
)
let clientCollectionConfig = deepCopyObjectSimple( const staticDescription =
typeof collectionConfig.admin.description === 'function'
? collectionConfig.admin.description({ t: i18n.t })
: collectionConfig.admin.description
const listViewSlots = renderListViewSlots({
collectionConfig, collectionConfig,
) as unknown as ClientCollectionConfig description: staticDescription,
clientCollectionConfig = createClientCollectionConfig({
clientCollection: clientCollectionConfig,
collection: collectionConfig,
createMappedComponent,
DefaultEditView,
DefaultListView,
i18n,
importMap: payload.importMap,
payload, payload,
}) })
return ( const clientProps: ListViewClientProps = {
<Fragment> ...listViewSlots,
<HydrateAuthProvider permissions={permissions} /> collectionSlug,
<ListInfoProvider columnState,
collectionConfig={clientCollectionConfig} disableBulkDelete,
collectionSlug={collectionSlug} disableBulkEdit,
hasCreatePermission={permissions?.collections?.[collectionSlug]?.create?.permission} enableRowSelections,
newDocumentURL={formatAdminURL({ hasCreatePermission: permissions?.collections?.[collectionSlug]?.create?.permission,
adminRoute, listPreferences,
path: `/collections/${collectionSlug}/create`, newDocumentURL: formatAdminURL({
})} adminRoute,
> path: `/collections/${collectionSlug}/create`,
}),
renderedFilters,
Table,
}
const isInDrawer = Boolean(drawerSlug)
return {
List: (
<Fragment>
<HydrateAuthProvider permissions={permissions} />
<ListQueryProvider <ListQueryProvider
collectionSlug={collectionSlug}
data={data} data={data}
defaultLimit={limit || collectionConfig?.admin?.pagination?.defaultLimit} defaultLimit={limit}
defaultSort={sort} defaultSort={sort}
modifySearchParams modifySearchParams={!isInDrawer}
preferenceKey={preferenceKey} preferenceKey={preferenceKey}
> >
<TableColumnsProvider <RenderServerComponent
collectionSlug={collectionSlug} clientProps={clientProps}
enableRowSelections Component={collectionConfig?.admin?.components?.views?.list?.Component}
listPreferences={listPreferences} Fallback={DefaultListView}
preferenceKey={preferenceKey} importMap={payload.importMap}
> serverProps={{
<RenderComponent collectionConfig,
clientProps={{ collectionSlug,
collectionSlug, data,
listSearchableFields: collectionConfig?.admin?.listSearchableFields, i18n,
}} limit,
mappedComponent={ListComponent} listPreferences,
/> listSearchableFields: collectionConfig.admin.listSearchableFields,
</TableColumnsProvider> locale: fullLocale,
params,
payload,
permissions,
searchParams,
user,
}}
/>
</ListQueryProvider> </ListQueryProvider>
</ListInfoProvider> </Fragment>
</Fragment> ),
) }
} }
return notFound() throw new Error('not-found')
}
export const ListView: React.FC<ListViewArgs> = async (args) => {
try {
const { List: RenderedList } = await renderListView({ ...args, enableRowSelections: true })
return RenderedList
} catch (error) {
if (error.message === 'not-found') {
notFound()
} else {
console.error(error) // eslint-disable-line no-console
}
}
} }

View File

@@ -0,0 +1,66 @@
import type { ListViewSlots } from '@payloadcms/ui'
import type { Payload, SanitizedCollectionConfig, StaticDescription } from 'payload'
import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
export const renderListViewSlots = ({
collectionConfig,
description,
payload,
}: {
collectionConfig: SanitizedCollectionConfig
description?: StaticDescription
payload: Payload
}): ListViewSlots => {
const result: ListViewSlots = {} as ListViewSlots
if (collectionConfig.admin.components?.afterList) {
result.AfterList = (
<RenderServerComponent
Component={collectionConfig.admin.components.afterList}
importMap={payload.importMap}
/>
)
}
if (collectionConfig.admin.components?.afterListTable) {
result.AfterListTable = (
<RenderServerComponent
Component={collectionConfig.admin.components.afterListTable}
importMap={payload.importMap}
/>
)
}
if (collectionConfig.admin.components?.beforeList) {
result.BeforeList = (
<RenderServerComponent
Component={collectionConfig.admin.components.beforeList}
importMap={payload.importMap}
/>
)
}
if (collectionConfig.admin.components?.beforeListTable) {
result.BeforeListTable = (
<RenderServerComponent
Component={collectionConfig.admin.components.beforeListTable}
importMap={payload.importMap}
/>
)
}
if (collectionConfig.admin.components?.Description) {
result.Description = (
<RenderServerComponent
clientProps={{
description,
}}
Component={collectionConfig.admin.components.Description}
importMap={payload.importMap}
/>
)
}
return result
}

View File

@@ -13,29 +13,25 @@ import type {
import { import {
DocumentControls, DocumentControls,
DocumentFields, DocumentFields,
DocumentLocked,
DocumentTakeOver,
Form, Form,
LeaveWithoutSaving,
OperationProvider, OperationProvider,
SetViewActions, SetDocumentStepNav,
SetDocumentTitle,
useAuth, useAuth,
useConfig, useConfig,
useDocumentDrawerContext,
useDocumentEvents, useDocumentEvents,
useDocumentInfo, useDocumentInfo,
useServerFunctions,
useTranslation, useTranslation,
} from '@payloadcms/ui' } from '@payloadcms/ui'
import { import { handleBackToDashboard, handleGoBack, handleTakeOver } from '@payloadcms/ui/shared'
getFormState,
handleBackToDashboard,
handleGoBack,
handleTakeOver,
} from '@payloadcms/ui/shared'
import { useRouter } from 'next/navigation.js' import { useRouter } from 'next/navigation.js'
import React, { Fragment, useCallback, useEffect, useRef, useState } from 'react' import React, { Fragment, useCallback, useEffect, useRef, useState } from 'react'
import { DocumentLocked } from '../../elements/DocumentLocked/index.js'
import { DocumentTakeOver } from '../../elements/DocumentTakeOver/index.js'
import { LeaveWithoutSaving } from '../../elements/LeaveWithoutSaving/index.js'
import { SetDocumentStepNav } from '../Edit/Default/SetDocumentStepNav/index.js'
import { SetDocumentTitle } from '../Edit/Default/SetDocumentTitle/index.js'
import { useLivePreviewContext } from './Context/context.js' import { useLivePreviewContext } from './Context/context.js'
import { LivePreviewProvider } from './Context/index.js' import { LivePreviewProvider } from './Context/index.js'
import './index.scss' import './index.scss'
@@ -55,13 +51,11 @@ type Props = {
} }
const PreviewView: React.FC<Props> = ({ const PreviewView: React.FC<Props> = ({
apiRoute,
collectionConfig, collectionConfig,
config, config,
fields, fields,
globalConfig, globalConfig,
schemaPath, schemaPath,
serverURL,
}) => { }) => {
const { const {
id, id,
@@ -69,7 +63,6 @@ const PreviewView: React.FC<Props> = ({
AfterDocument, AfterDocument,
AfterFields, AfterFields,
apiURL, apiURL,
BeforeDocument,
BeforeFields, BeforeFields,
collectionSlug, collectionSlug,
currentEditor, currentEditor,
@@ -86,13 +79,16 @@ const PreviewView: React.FC<Props> = ({
isEditing, isEditing,
isInitializing, isInitializing,
lastUpdateTime, lastUpdateTime,
onSave: onSaveFromProps,
setCurrentEditor, setCurrentEditor,
setDocumentIsLocked, setDocumentIsLocked,
unlockDocument, unlockDocument,
updateDocumentEditor, updateDocumentEditor,
} = useDocumentInfo() } = useDocumentInfo()
const { getFormState } = useServerFunctions()
const { onSave: onSaveFromProps } = useDocumentDrawerContext()
const operation = id ? 'update' : 'create' const operation = id ? 'update' : 'create'
const { const {
@@ -120,6 +116,8 @@ const PreviewView: React.FC<Props> = ({
const [isReadOnlyForIncomingUser, setIsReadOnlyForIncomingUser] = useState(false) const [isReadOnlyForIncomingUser, setIsReadOnlyForIncomingUser] = useState(false)
const [showTakeOverModal, setShowTakeOverModal] = useState(false) const [showTakeOverModal, setShowTakeOverModal] = useState(false)
const abortControllerRef = useRef(new AbortController())
const [editSessionStartTime, setEditSessionStartTime] = useState(Date.now()) const [editSessionStartTime, setEditSessionStartTime] = useState(Date.now())
const lockExpiryTime = lastUpdateTime + lockDurationInMilliseconds const lockExpiryTime = lastUpdateTime + lockDurationInMilliseconds
@@ -178,6 +176,17 @@ const PreviewView: React.FC<Props> = ({
const onChange: FormProps['onChange'][0] = useCallback( const onChange: FormProps['onChange'][0] = useCallback(
async ({ formState: prevFormState }) => { async ({ formState: prevFormState }) => {
if (abortControllerRef.current) {
try {
abortControllerRef.current.abort()
} catch (_err) {
// swallow error
}
}
const abortController = new AbortController()
abortControllerRef.current = abortController
const currentTime = Date.now() const currentTime = Date.now()
const timeSinceLastUpdate = currentTime - editSessionStartTime const timeSinceLastUpdate = currentTime - editSessionStartTime
@@ -190,19 +199,17 @@ const PreviewView: React.FC<Props> = ({
const docPreferences = await getDocPreferences() const docPreferences = await getDocPreferences()
const { lockedState, state } = await getFormState({ const { lockedState, state } = await getFormState({
apiRoute, id,
body: { collectionSlug,
id, docPermissions,
collectionSlug, docPreferences,
docPreferences, formState: prevFormState,
formState: prevFormState, globalSlug,
globalSlug, operation,
operation, returnLockStatus: isLockingEnabled ? true : false,
returnLockStatus: isLockingEnabled ? true : false, schemaPath,
schemaPath, signal: abortController.signal,
updateLastEdited, updateLastEdited,
},
serverURL,
}) })
setDocumentIsLocked(true) setDocumentIsLocked(true)
@@ -214,8 +221,13 @@ const PreviewView: React.FC<Props> = ({
: documentLockStateRef.current?.user : documentLockStateRef.current?.user
if (lockedState) { if (lockedState) {
if (!documentLockStateRef.current || lockedState.user.id !== previousOwnerId) { const lockedUserID =
if (previousOwnerId === user.id && lockedState.user.id !== user.id) { typeof lockedState.user === 'string' || typeof lockedState.user === 'number'
? lockedState.user
: lockedState.user.id
if (!documentLockStateRef.current || lockedUserID !== previousOwnerId) {
if (previousOwnerId === user.id && lockedUserID !== user.id) {
setShowTakeOverModal(true) setShowTakeOverModal(true)
documentLockStateRef.current.hasShownLockedModal = true documentLockStateRef.current.hasShownLockedModal = true
} }
@@ -223,9 +235,10 @@ const PreviewView: React.FC<Props> = ({
documentLockStateRef.current = documentLockStateRef.current = { documentLockStateRef.current = documentLockStateRef.current = {
hasShownLockedModal: documentLockStateRef.current?.hasShownLockedModal || false, hasShownLockedModal: documentLockStateRef.current?.hasShownLockedModal || false,
isLocked: true, isLocked: true,
user: lockedState.user, user: lockedState.user as ClientUser,
} }
setCurrentEditor(lockedState.user)
setCurrentEditor(lockedState.user as ClientUser)
} }
} }
} }
@@ -233,25 +246,33 @@ const PreviewView: React.FC<Props> = ({
return state return state
}, },
[ [
collectionSlug,
editSessionStartTime, editSessionStartTime,
globalSlug,
serverURL,
apiRoute,
id,
isLockingEnabled, isLockingEnabled,
getDocPreferences,
getFormState,
id,
collectionSlug,
docPermissions,
globalSlug,
operation, operation,
schemaPath, schemaPath,
getDocPreferences,
setCurrentEditor,
setDocumentIsLocked, setDocumentIsLocked,
user, user.id,
setCurrentEditor,
], ],
) )
// Clean up when the component unmounts or when the document is unlocked // Clean up when the component unmounts or when the document is unlocked
useEffect(() => { useEffect(() => {
return () => { return () => {
if (abortControllerRef.current) {
try {
abortControllerRef.current.abort()
} catch (_err) {
// swallow error
}
}
if (!isLockingEnabled) { if (!isLockingEnabled) {
return return
} }
@@ -415,7 +436,6 @@ const PreviewView: React.FC<Props> = ({
.filter(Boolean) .filter(Boolean)
.join(' ')} .join(' ')}
> >
{BeforeDocument}
<DocumentFields <DocumentFields
AfterFields={AfterFields} AfterFields={AfterFields}
BeforeFields={BeforeFields} BeforeFields={BeforeFields}
@@ -423,7 +443,7 @@ const PreviewView: React.FC<Props> = ({
fields={fields} fields={fields}
forceSidebarWrap forceSidebarWrap
readOnly={isReadOnlyForIncomingUser || !hasSavePermission} readOnly={isReadOnlyForIncomingUser || !hasSavePermission}
schemaPath={collectionSlug || globalSlug} schemaPathSegments={[collectionSlug || globalSlug]}
/> />
{AfterDocument} {AfterDocument}
</div> </div>
@@ -464,11 +484,6 @@ export const LivePreviewClient: React.FC<{
return ( return (
<Fragment> <Fragment>
<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}

View File

@@ -17,9 +17,11 @@ export const LoginField: React.FC<LoginFieldProps> = ({ type, required = true })
if (type === 'email') { if (type === 'email') {
return ( return (
<EmailField <EmailField
autoComplete="email"
field={{ field={{
name: 'email', name: 'email',
admin: {
autoComplete: 'email',
},
label: t('general:email'), label: t('general:email'),
required, required,
}} }}

View File

@@ -1,6 +1,6 @@
import type { AdminViewProps } from 'payload' import type { AdminViewProps } from 'payload'
import { getCreateMappedComponent, RenderComponent } from '@payloadcms/ui/shared' import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
import { redirect } from 'next/navigation.js' import { redirect } from 'next/navigation.js'
import React, { Fragment } from 'react' import React, { Fragment } from 'react'
@@ -28,23 +28,6 @@ export const LoginView: React.FC<AdminViewProps> = ({ initPageResult, params, se
routes: { admin }, routes: { admin },
} = config } = config
const createMappedComponent = getCreateMappedComponent({
importMap: payload.importMap,
serverProps: {
i18n,
locale,
params,
payload,
permissions,
searchParams,
user,
},
})
const mappedBeforeLogins = createMappedComponent(beforeLogin, undefined, undefined, 'beforeLogin')
const mappedAfterLogins = createMappedComponent(afterLogin, undefined, undefined, 'afterLogin')
if (user) { if (user) {
redirect((searchParams.redirect as string) || admin) redirect((searchParams.redirect as string) || admin)
} }
@@ -82,7 +65,19 @@ export const LoginView: React.FC<AdminViewProps> = ({ initPageResult, params, se
user={user} user={user}
/> />
</div> </div>
<RenderComponent mappedComponent={mappedBeforeLogins} /> <RenderServerComponent
Component={beforeLogin}
importMap={payload.importMap}
serverProps={{
i18n,
locale,
params,
payload,
permissions,
searchParams,
user,
}}
/>
{!collectionConfig?.auth?.disableLocalStrategy && ( {!collectionConfig?.auth?.disableLocalStrategy && (
<LoginForm <LoginForm
prefillEmail={prefillEmail} prefillEmail={prefillEmail}
@@ -91,7 +86,19 @@ export const LoginView: React.FC<AdminViewProps> = ({ initPageResult, params, se
searchParams={searchParams} searchParams={searchParams}
/> />
)} )}
<RenderComponent mappedComponent={mappedAfterLogins} /> <RenderServerComponent
Component={afterLogin}
importMap={payload.importMap}
serverProps={{
i18n,
locale,
params,
payload,
permissions,
searchParams,
user,
}}
/>
</Fragment> </Fragment>
) )
} }

View File

@@ -75,13 +75,26 @@ export const ResetPasswordForm: React.FC<Args> = ({ token }) => {
label: i18n.t('authentication:newPassword'), label: i18n.t('authentication:newPassword'),
required: true, required: true,
}} }}
indexPath=""
parentPath=""
parentSchemaPath=""
path="password"
schemaPath={`${userSlug}.password`}
/> />
<ConfirmPasswordField /> <ConfirmPasswordField />
<HiddenField <HiddenField
field={{ field={{
name: 'token', name: 'token',
type: 'text',
admin: {
hidden: true,
},
}} }}
forceUsePathFromProps indexPath=""
parentPath={userSlug}
parentSchemaPath={userSlug}
path="token"
schemaPath={`${userSlug}.token`}
value={token} value={token}
/> />
</div> </div>

View File

@@ -1,4 +1,11 @@
import type { AdminViewComponent, AdminViewProps, ImportMap, SanitizedConfig } from 'payload' import type {
AdminViewComponent,
AdminViewProps,
CustomComponent,
EditConfig,
ImportMap,
SanitizedConfig,
} from 'payload'
import type React from 'react' import type React from 'react'
import { formatAdminURL } from '@payloadcms/ui/shared' import { formatAdminURL } from '@payloadcms/ui/shared'
@@ -46,6 +53,20 @@ const oneSegmentViews: OneSegmentViews = {
unauthorized: UnauthorizedView, unauthorized: UnauthorizedView,
} }
function getViewActions({
editConfig,
viewKey,
}: {
editConfig: EditConfig
viewKey: keyof EditConfig
}): CustomComponent[] | undefined {
if (editConfig && viewKey in editConfig && 'actions' in editConfig[viewKey]) {
return editConfig[viewKey].actions
}
return undefined
}
export const getViewFromConfig = ({ export const getViewFromConfig = ({
adminRoute, adminRoute,
config, config,
@@ -65,8 +86,10 @@ export const getViewFromConfig = ({
}): { }): {
DefaultView: ViewFromConfig DefaultView: ViewFromConfig
initPageOptions: Parameters<typeof initPage>[0] initPageOptions: Parameters<typeof initPage>[0]
serverProps: Record<string, unknown>
templateClassName: string templateClassName: string
templateType: 'default' | 'minimal' templateType: 'default' | 'minimal'
viewActions?: CustomComponent[]
} => { } => {
let ViewToRender: ViewFromConfig = null let ViewToRender: ViewFromConfig = null
let templateClassName: string let templateClassName: string
@@ -79,10 +102,30 @@ export const getViewFromConfig = ({
searchParams, searchParams,
} }
const [segmentOne, segmentTwo] = segments let viewActions: CustomComponent[] = config?.admin?.components?.actions || []
const [segmentOne, segmentTwo, segmentThree, segmentFour, segmentFive] = segments
const isGlobal = segmentOne === 'globals' const isGlobal = segmentOne === 'globals'
const isCollection = segmentOne === 'collections' const isCollection = segmentOne === 'collections'
let matchedCollection: SanitizedConfig['collections'][number] = undefined
let matchedGlobal: SanitizedConfig['globals'][number] = undefined
let serverProps = {}
if (isCollection) {
matchedCollection = config.collections.find(({ slug }) => slug === segmentTwo)
serverProps = {
collectionConfig: matchedCollection,
}
}
if (isGlobal) {
matchedGlobal = config.globals.find(({ slug }) => slug === segmentTwo)
serverProps = {
globalConfig: matchedGlobal,
}
}
switch (segments.length) { switch (segments.length) {
case 0: { case 0: {
@@ -146,7 +189,7 @@ export const getViewFromConfig = ({
templateType = 'minimal' templateType = 'minimal'
} }
if (isCollection) { if (isCollection && matchedCollection) {
// --> /collections/:collectionSlug // --> /collections/:collectionSlug
ViewToRender = { ViewToRender = {
@@ -155,7 +198,8 @@ export const getViewFromConfig = ({
templateClassName = `${segmentTwo}-list` templateClassName = `${segmentTwo}-list`
templateType = 'default' templateType = 'default'
} else if (isGlobal) { viewActions = viewActions.concat(matchedCollection.admin.components?.views?.list?.actions)
} else if (isGlobal && matchedGlobal) {
// --> /globals/:globalSlug // --> /globals/:globalSlug
ViewToRender = { ViewToRender = {
@@ -164,6 +208,14 @@ export const getViewFromConfig = ({
templateClassName = 'global-edit' templateClassName = 'global-edit'
templateType = 'default' templateType = 'default'
// add default view actions
viewActions = viewActions.concat(
getViewActions({
editConfig: matchedGlobal.admin?.components?.views?.edit,
viewKey: 'default',
}),
)
} }
break break
} }
@@ -176,13 +228,13 @@ export const getViewFromConfig = ({
templateClassName = 'verify' templateClassName = 'verify'
templateType = 'minimal' templateType = 'minimal'
} else if (isCollection) { } else if (isCollection && matchedCollection) {
// Custom Views // Custom Views
// --> /collections/:collectionSlug/:id // --> /collections/:collectionSlug/:id
// --> /collections/:collectionSlug/:id/api
// --> /collections/:collectionSlug/:id/preview // --> /collections/:collectionSlug/:id/preview
// --> /collections/:collectionSlug/:id/versions // --> /collections/:collectionSlug/:id/versions
// --> /collections/:collectionSlug/:id/versions/:versionId // --> /collections/:collectionSlug/:id/versions/:versionId
// --> /collections/:collectionSlug/:id/api
ViewToRender = { ViewToRender = {
Component: DocumentView, Component: DocumentView,
@@ -190,7 +242,65 @@ export const getViewFromConfig = ({
templateClassName = `collection-default-edit` templateClassName = `collection-default-edit`
templateType = 'default' templateType = 'default'
} else if (isGlobal) {
// Adds view actions to the current collection view
if (matchedCollection.admin?.components?.views?.edit) {
if ('root' in matchedCollection.admin.components.views.edit) {
viewActions = viewActions.concat(
getViewActions({
editConfig: matchedCollection.admin?.components?.views?.edit,
viewKey: 'root',
}),
)
} else {
if (segmentFive) {
if (segmentFour === 'versions') {
// add version view actions
viewActions = viewActions.concat(
getViewActions({
editConfig: matchedCollection.admin?.components?.views?.edit,
viewKey: 'version',
}),
)
}
} else if (segmentFour) {
if (segmentFour === 'versions') {
// add versions view actions
viewActions = viewActions.concat(
getViewActions({
editConfig: matchedCollection.admin?.components?.views.edit,
viewKey: 'versions',
}),
)
} else if (segmentFour === 'preview') {
// add livePreview view actions
viewActions = viewActions.concat(
getViewActions({
editConfig: matchedCollection.admin?.components?.views.edit,
viewKey: 'livePreview',
}),
)
} else if (segmentFour === 'api') {
// add api view actions
viewActions = viewActions.concat(
getViewActions({
editConfig: matchedCollection.admin?.components?.views.edit,
viewKey: 'api',
}),
)
}
} else if (segmentThree) {
// add default view actions
viewActions = viewActions.concat(
getViewActions({
editConfig: matchedCollection.admin?.components?.views.edit,
viewKey: 'default',
}),
)
}
}
}
} else if (isGlobal && matchedGlobal) {
// Custom Views // Custom Views
// --> /globals/:globalSlug/versions // --> /globals/:globalSlug/versions
// --> /globals/:globalSlug/preview // --> /globals/:globalSlug/preview
@@ -203,6 +313,56 @@ export const getViewFromConfig = ({
templateClassName = `global-edit` templateClassName = `global-edit`
templateType = 'default' templateType = 'default'
// Adds view actions to the current global view
if (matchedGlobal.admin?.components?.views?.edit) {
if ('root' in matchedGlobal.admin.components.views.edit) {
viewActions = viewActions.concat(
getViewActions({
editConfig: matchedGlobal.admin.components?.views?.edit,
viewKey: 'root',
}),
)
} else {
if (segmentFour) {
if (segmentThree === 'versions') {
// add version view actions
viewActions = viewActions.concat(
getViewActions({
editConfig: matchedGlobal.admin?.components?.views?.edit,
viewKey: 'version',
}),
)
}
} else if (segmentThree) {
if (segmentThree === 'versions') {
// add versions view actions
viewActions = viewActions.concat(
getViewActions({
editConfig: matchedGlobal.admin?.components?.views?.edit,
viewKey: 'versions',
}),
)
} else if (segmentThree === 'preview') {
// add livePreview view actions
viewActions = viewActions.concat(
getViewActions({
editConfig: matchedGlobal.admin?.components?.views?.edit,
viewKey: 'livePreview',
}),
)
} else if (segmentThree === 'api') {
// add api view actions
viewActions = viewActions.concat(
getViewActions({
editConfig: matchedGlobal.admin?.components?.views?.edit,
viewKey: 'api',
}),
)
}
}
}
}
} }
break break
} }
@@ -214,7 +374,9 @@ export const getViewFromConfig = ({
return { return {
DefaultView: ViewToRender, DefaultView: ViewToRender,
initPageOptions, initPageOptions,
serverProps,
templateClassName, templateClassName,
templateType, templateType,
viewActions: viewActions.reverse(),
} }
} }

View File

@@ -1,13 +1,15 @@
import type { I18nClient } from '@payloadcms/translations' import type { I18nClient } from '@payloadcms/translations'
import type { Metadata } from 'next' import type { Metadata } from 'next'
import type { ImportMap, MappedComponent, SanitizedConfig } from 'payload' import type { ImportMap, SanitizedConfig } from 'payload'
import { formatAdminURL, getCreateMappedComponent, RenderComponent } from '@payloadcms/ui/shared' import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
import { formatAdminURL } 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'
import { DefaultTemplate } from '../../templates/Default/index.js' import { DefaultTemplate } from '../../templates/Default/index.js'
import { MinimalTemplate } from '../../templates/Minimal/index.js' import { MinimalTemplate } from '../../templates/Minimal/index.js'
import { getClientConfig } from '../../utilities/getClientConfig.js'
import { initPage } from '../../utilities/initPage/index.js' import { initPage } from '../../utilities/initPage/index.js'
import { getViewFromConfig } from './getViewFromConfig.js' import { getViewFromConfig } from './getViewFromConfig.js'
@@ -55,7 +57,14 @@ export const RootPage = async ({
const searchParams = await searchParamsPromise const searchParams = await searchParamsPromise
const { DefaultView, initPageOptions, templateClassName, templateType } = getViewFromConfig({ const {
DefaultView,
initPageOptions,
serverProps,
templateClassName,
templateType,
viewActions,
} = getViewFromConfig({
adminRoute, adminRoute,
config, config,
currentRoute, currentRoute,
@@ -64,21 +73,22 @@ export const RootPage = async ({
segments, segments,
}) })
let dbHasUser = false
const initPageResult = await initPage(initPageOptions) const initPageResult = await initPage(initPageOptions)
dbHasUser = await initPageResult?.req.payload.db const dbHasUser =
.findOne({ initPageResult.req.user ||
collection: userSlug, (await initPageResult?.req.payload.db
req: initPageResult?.req, .findOne({
}) collection: userSlug,
?.then((doc) => !!doc) req: initPageResult?.req,
})
?.then((doc) => !!doc))
if (!DefaultView?.Component && !DefaultView?.payloadComponent) { if (!DefaultView?.Component && !DefaultView?.payloadComponent) {
if (initPageResult?.req?.user) { if (initPageResult?.req?.user) {
notFound() notFound()
} }
if (dbHasUser) { if (dbHasUser) {
redirect(adminRoute) redirect(adminRoute)
} }
@@ -111,27 +121,30 @@ export const RootPage = async ({
redirect(adminRoute) redirect(adminRoute)
} }
const createMappedView = getCreateMappedComponent({ const clientConfig = await getClientConfig({
importMap, config,
serverProps: { i18n: initPageResult?.req.i18n,
i18n: initPageResult?.req.i18n,
importMap,
initPageResult,
params,
payload: initPageResult?.req.payload,
searchParams,
},
}) })
const MappedView: MappedComponent = createMappedView( const RenderedView = (
DefaultView.payloadComponent, <RenderServerComponent
undefined, clientProps={{ clientConfig }}
DefaultView.Component, Component={DefaultView.payloadComponent}
'createMappedView', Fallback={DefaultView.Component}
importMap={importMap}
serverProps={{
...serverProps,
clientConfig,
i18n: initPageResult?.req.i18n,
importMap,
initPageResult,
params,
payload: initPageResult?.req.payload,
searchParams,
}}
/>
) )
const RenderedView = <RenderComponent mappedComponent={MappedView} />
return ( return (
<Fragment> <Fragment>
{!templateType && <Fragment>{RenderedView}</Fragment>} {!templateType && <Fragment>{RenderedView}</Fragment>}
@@ -147,6 +160,7 @@ export const RootPage = async ({
permissions={initPageResult?.permissions} permissions={initPageResult?.permissions}
searchParams={searchParams} searchParams={searchParams}
user={initPageResult?.req.user} user={initPageResult?.req.user}
viewActions={viewActions}
visibleEntities={{ visibleEntities={{
// The reason we are not passing in initPageResult.visibleEntities directly is due to a "Cannot assign to read only property of object '#<Object>" error introduced in React 19 // The reason we are not passing in initPageResult.visibleEntities directly is due to a "Cannot assign to read only property of object '#<Object>" error introduced in React 19
// which this caused as soon as initPageResult.visibleEntities is passed in // which this caused as soon as initPageResult.visibleEntities is passed in

View File

@@ -1,14 +1,7 @@
'use client' 'use client'
import type { ClientCollectionConfig, ClientGlobalConfig, OptionObject } from 'payload' import type { ClientCollectionConfig, ClientGlobalConfig, OptionObject } from 'payload'
import { import { Gutter, useConfig, useDocumentInfo, usePayloadAPI, useTranslation } from '@payloadcms/ui'
Gutter,
SetViewActions,
useConfig,
useDocumentInfo,
usePayloadAPI,
useTranslation,
} from '@payloadcms/ui'
import { formatDate } from '@payloadcms/ui/shared' import { formatDate } from '@payloadcms/ui/shared'
import React, { useState } from 'react' import React, { useState } from 'react'
@@ -80,11 +73,6 @@ export const DefaultVersionView: React.FC<DefaultVersionsViewProps> = ({
return ( return (
<main className={baseClass}> <main className={baseClass}>
<SetViewActions
actions={
(collectionConfig || globalConfig)?.admin?.components?.views?.edit?.version?.actions
}
/>
<SetStepNav <SetStepNav
collectionConfig={collectionConfig} collectionConfig={collectionConfig}
collectionSlug={collectionSlug} collectionSlug={collectionSlug}

View File

@@ -1,5 +1,11 @@
import type { I18n } from '@payloadcms/translations' import type { I18n } from '@payloadcms/translations'
import type { SanitizedCollectionConfig, SanitizedConfig, SanitizedGlobalConfig } from 'payload' import type {
PaginatedDocs,
SanitizedCollectionConfig,
SanitizedConfig,
SanitizedGlobalConfig,
TypeWithVersion,
} from 'payload'
import { type Column, SortColumn } from '@payloadcms/ui' import { type Column, SortColumn } from '@payloadcms/ui'
import React from 'react' import React from 'react'
@@ -11,6 +17,7 @@ import { IDCell } from './cells/ID/index.js'
export const buildVersionColumns = ({ export const buildVersionColumns = ({
collectionConfig, collectionConfig,
docID, docID,
docs,
globalConfig, globalConfig,
i18n: { t }, i18n: { t },
latestDraftVersion, latestDraftVersion,
@@ -19,6 +26,7 @@ export const buildVersionColumns = ({
collectionConfig?: SanitizedCollectionConfig collectionConfig?: SanitizedCollectionConfig
config: SanitizedConfig config: SanitizedConfig
docID?: number | string docID?: number | string
docs: PaginatedDocs<TypeWithVersion<any>>['docs']
globalConfig?: SanitizedGlobalConfig globalConfig?: SanitizedGlobalConfig
i18n: I18n i18n: I18n
latestDraftVersion?: string latestDraftVersion?: string
@@ -30,56 +38,37 @@ export const buildVersionColumns = ({
{ {
accessor: 'updatedAt', accessor: 'updatedAt',
active: true, active: true,
cellProps: { field: {
field: { name: '',
name: '', type: 'date',
type: 'date',
admin: {
components: {
Cell: {
type: 'client',
Component: null,
RenderedComponent: (
<CreatedAtCell
collectionSlug={collectionConfig?.slug}
docID={docID}
globalSlug={globalConfig?.slug}
/>
),
},
Label: {
type: 'client',
Component: null,
},
},
},
},
}, },
Heading: <SortColumn Label={t('general:updatedAt')} name="updatedAt" />, Heading: <SortColumn Label={t('general:updatedAt')} name="updatedAt" />,
renderedCells: docs.map((doc, i) => {
return (
<CreatedAtCell
collectionSlug={collectionConfig?.slug}
docID={docID}
globalSlug={globalConfig?.slug}
key={i}
rowData={{
id: doc.id,
updatedAt: doc.updatedAt,
}}
/>
)
}),
}, },
{ {
accessor: 'id', accessor: 'id',
active: true, active: true,
cellProps: { field: {
field: { name: '',
name: '', type: 'text',
type: 'text',
admin: {
components: {
Cell: {
type: 'client',
Component: null,
RenderedComponent: <IDCell />,
},
Label: {
type: 'client',
Component: null,
},
},
},
},
}, },
Heading: <SortColumn disable Label={t('version:versionID')} name="id" />, Heading: <SortColumn disable Label={t('version:versionID')} name="id" />,
renderedCells: docs.map((doc, i) => {
return <IDCell id={doc.id} key={i} />
}),
}, },
] ]
@@ -90,31 +79,21 @@ export const buildVersionColumns = ({
columns.push({ columns.push({
accessor: '_status', accessor: '_status',
active: true, active: true,
cellProps: { field: {
field: { name: '',
name: '', type: 'checkbox',
type: 'checkbox',
admin: {
components: {
Cell: {
type: 'client',
Component: null,
RenderedComponent: (
<AutosaveCell
latestDraftVersion={latestDraftVersion}
latestPublishedVersion={latestPublishedVersion}
/>
),
},
Label: {
type: 'client',
Component: null,
},
},
},
},
}, },
Heading: <SortColumn disable Label={t('version:status')} name="status" />, Heading: <SortColumn disable Label={t('version:status')} name="status" />,
renderedCells: docs.map((doc, i) => {
return (
<AutosaveCell
key={i}
latestDraftVersion={latestDraftVersion}
latestPublishedVersion={latestPublishedVersion}
rowData={doc}
/>
)
}),
}) })
} }

View File

@@ -1,10 +1,17 @@
'use client' 'use client'
import { Pill, useConfig, useTableCell, useTranslation } from '@payloadcms/ui' import { Pill, useConfig, useTranslation } from '@payloadcms/ui'
import React, { Fragment } from 'react' import React, { Fragment } from 'react'
type AutosaveCellProps = { type AutosaveCellProps = {
latestDraftVersion?: string latestDraftVersion?: string
latestPublishedVersion?: string latestPublishedVersion?: string
rowData?: {
autosave?: boolean
publishedLocale?: string
version: {
_status?: string
}
}
} }
export const renderPill = (data, latestVersion, currentLabel, previousLabel, pillStyle) => { export const renderPill = (data, latestVersion, currentLabel, previousLabel, pillStyle) => {
@@ -23,9 +30,10 @@ export const renderPill = (data, latestVersion, currentLabel, previousLabel, pil
export const AutosaveCell: React.FC<AutosaveCellProps> = ({ export const AutosaveCell: React.FC<AutosaveCellProps> = ({
latestDraftVersion, latestDraftVersion,
latestPublishedVersion, latestPublishedVersion,
rowData = { autosave: undefined, publishedLocale: undefined, version: undefined },
}) => { }) => {
const { i18n, t } = useTranslation() const { i18n, t } = useTranslation()
const { rowData } = useTableCell()
const { const {
config: { localization }, config: { localization },
} = useConfig() } = useConfig()

View File

@@ -1,5 +1,5 @@
'use client' 'use client'
import { useConfig, useTableCell, useTranslation } from '@payloadcms/ui' import { useConfig, useTranslation } from '@payloadcms/ui'
import { formatAdminURL, formatDate } from '@payloadcms/ui/shared' import { formatAdminURL, formatDate } from '@payloadcms/ui/shared'
import LinkImport from 'next/link.js' import LinkImport from 'next/link.js'
import React from 'react' import React from 'react'
@@ -10,12 +10,17 @@ type CreatedAtCellProps = {
collectionSlug?: string collectionSlug?: string
docID?: number | string docID?: number | string
globalSlug?: string globalSlug?: string
rowData?: {
id: number | string
updatedAt: Date | number | string
}
} }
export const CreatedAtCell: React.FC<CreatedAtCellProps> = ({ export const CreatedAtCell: React.FC<CreatedAtCellProps> = ({
collectionSlug, collectionSlug,
docID, docID,
globalSlug, globalSlug,
rowData: { id, updatedAt } = {},
}) => { }) => {
const { const {
config: { config: {
@@ -26,30 +31,25 @@ export const CreatedAtCell: React.FC<CreatedAtCellProps> = ({
const { i18n } = useTranslation() const { i18n } = useTranslation()
const { cellData, rowData } = useTableCell()
const versionID = rowData.id
let to: string let to: string
if (collectionSlug) { if (collectionSlug) {
to = formatAdminURL({ to = formatAdminURL({
adminRoute, adminRoute,
path: `/collections/${collectionSlug}/${docID}/versions/${versionID}`, path: `/collections/${collectionSlug}/${docID}/versions/${id}`,
}) })
} }
if (globalSlug) { if (globalSlug) {
to = formatAdminURL({ to = formatAdminURL({
adminRoute, adminRoute,
path: `/globals/${globalSlug}/versions/${versionID}`, path: `/globals/${globalSlug}/versions/${id}`,
}) })
} }
return ( return (
<Link href={to} prefetch={false}> <Link href={to} prefetch={false}>
{cellData && {formatDate({ date: updatedAt, i18n, pattern: dateFormat })}
formatDate({ date: cellData as Date | number | string, i18n, pattern: dateFormat })}
</Link> </Link>
) )
} }

View File

@@ -1,8 +1,6 @@
'use client' 'use client'
import { useTableCell } from '@payloadcms/ui'
import React, { Fragment } from 'react' import React, { Fragment } from 'react'
export const IDCell: React.FC = () => { export function IDCell({ id }: { id: number | string }) {
const { cellData } = useTableCell() return <Fragment>{id}</Fragment>
return <Fragment>{cellData as number | string}</Fragment>
} }

View File

@@ -1,15 +1,12 @@
'use client' 'use client'
import type { ClientCollectionConfig, ClientGlobalConfig, SanitizedCollectionConfig } from 'payload' import type { SanitizedCollectionConfig } from 'payload'
import { import {
type Column, type Column,
LoadingOverlayToggle, LoadingOverlayToggle,
Pagination, Pagination,
PerPage, PerPage,
SetViewActions,
Table, Table,
useConfig,
useDocumentInfo,
useListQuery, useListQuery,
useTranslation, useTranslation,
} from '@payloadcms/ui' } from '@payloadcms/ui'
@@ -24,14 +21,8 @@ export const VersionsViewClient: React.FC<{
}> = (props) => { }> = (props) => {
const { baseClass, columns, paginationLimits } = props const { baseClass, columns, paginationLimits } = props
const { collectionSlug, globalSlug } = useDocumentInfo()
const { data, handlePageChange, handlePerPageChange } = useListQuery() const { data, handlePageChange, handlePerPageChange } = useListQuery()
const { getEntityConfig } = useConfig()
const collectionConfig = getEntityConfig({ collectionSlug }) as ClientCollectionConfig
const globalConfig = getEntityConfig({ globalSlug }) as ClientGlobalConfig
const searchParams = useSearchParams() const searchParams = useSearchParams()
const limit = searchParams.get('limit') const limit = searchParams.get('limit')
@@ -41,11 +32,6 @@ export const VersionsViewClient: React.FC<{
return ( return (
<React.Fragment> <React.Fragment>
<SetViewActions
actions={
(collectionConfig || globalConfig)?.admin?.components?.views?.edit?.versions?.actions
}
/>
<LoadingOverlayToggle name="versions" show={!data} /> <LoadingOverlayToggle name="versions" show={!data} />
{versionCount === 0 && ( {versionCount === 0 && (
<div className={`${baseClass}__no-versions`}> <div className={`${baseClass}__no-versions`}>
@@ -54,11 +40,7 @@ export const VersionsViewClient: React.FC<{
)} )}
{versionCount > 0 && ( {versionCount > 0 && (
<React.Fragment> <React.Fragment>
<Table <Table columns={columns} data={data?.docs} />
columns={columns}
data={data?.docs}
fields={(collectionConfig || globalConfig)?.fields}
/>
<div className={`${baseClass}__page-controls`}> <div className={`${baseClass}__page-controls`}>
<Pagination <Pagination
hasNextPage={data.hasNextPage} hasNextPage={data.hasNextPage}

View File

@@ -1,11 +1,10 @@
import type { EditViewComponent, PaginatedDocs, PayloadServerReactComponent } from 'payload' import type { EditViewComponent, PaginatedDocs, PayloadServerReactComponent } from 'payload'
import { Gutter, ListQueryProvider } from '@payloadcms/ui' import { Gutter, ListQueryProvider, SetDocumentStepNav } from '@payloadcms/ui'
import { notFound } from 'next/navigation.js' import { notFound } from 'next/navigation.js'
import { isNumber } from 'payload/shared' import { isNumber } from 'payload/shared'
import React from 'react' import React from 'react'
import { SetDocumentStepNav } from '../Edit/Default/SetDocumentStepNav/index.js'
import { buildVersionColumns } from './buildColumns.js' import { buildVersionColumns } from './buildColumns.js'
import { getLatestVersion } from './getLatestVersion.js' import { getLatestVersion } from './getLatestVersion.js'
import { VersionsViewClient } from './index.client.js' import { VersionsViewClient } from './index.client.js'
@@ -165,6 +164,7 @@ export const VersionsView: PayloadServerReactComponent<EditViewComponent> = asyn
collectionConfig, collectionConfig,
config, config,
docID: id, docID: id,
docs: versionsData?.docs,
globalConfig, globalConfig,
i18n, i18n,
latestDraftVersion: latestDraftVersion?.id, latestDraftVersion: latestDraftVersion?.id,
@@ -190,6 +190,7 @@ export const VersionsView: PayloadServerReactComponent<EditViewComponent> = asyn
<main className={baseClass}> <main className={baseClass}>
<Gutter className={`${baseClass}__wrap`}> <Gutter className={`${baseClass}__wrap`}>
<ListQueryProvider <ListQueryProvider
collectionSlug={collectionSlug}
data={versionsData} data={versionsData}
defaultLimit={limitToUse} defaultLimit={limitToUse}
defaultSort={sort as string} defaultSort={sort as string}

View File

@@ -19,7 +19,7 @@
"src/**/*.ts", "src/**/*.ts",
"src/**/*.tsx", "src/**/*.tsx",
"src/withPayload.js" /* Include the withPayload.js file in the build */ "src/withPayload.js" /* Include the withPayload.js file in the build */
], , "../ui/src/utilities/renderFields.tsx" ],
"references": [ "references": [
{ "path": "../payload" }, { "path": "../payload" },
{ "path": "../ui" }, { "path": "../ui" },

View File

@@ -4,8 +4,8 @@ import type { JSONSchema4 } from 'json-schema'
import type { ImportMap } from '../bin/generateImportMap/index.js' import type { ImportMap } from '../bin/generateImportMap/index.js'
import type { SanitizedCollectionConfig, TypeWithID } from '../collections/config/types.js' import type { SanitizedCollectionConfig, TypeWithID } from '../collections/config/types.js'
import type { Config, PayloadComponent, SanitizedConfig } from '../config/types.js' import type { Config, PayloadComponent, SanitizedConfig } from '../config/types.js'
import type { ValidationFieldError } from '../errors/ValidationError.js'
import type { import type {
Field,
FieldAffectingData, FieldAffectingData,
RichTextField, RichTextField,
RichTextFieldClient, RichTextFieldClient,
@@ -15,7 +15,7 @@ import type { SanitizedGlobalConfig } from '../globals/config/types.js'
import type { RequestContext } from '../index.js' import type { RequestContext } from '../index.js'
import type { JsonObject, Payload, PayloadRequest, PopulateType } from '../types/index.js' import type { JsonObject, Payload, PayloadRequest, PopulateType } from '../types/index.js'
import type { RichTextFieldClientProps } from './fields/RichText.js' import type { RichTextFieldClientProps } from './fields/RichText.js'
import type { CreateMappedComponent } from './types.js' import type { FieldSchemaMap } from './types.js'
export type AfterReadRichTextHookArgs< export type AfterReadRichTextHookArgs<
TData extends TypeWithID = any, TData extends TypeWithID = any,
@@ -91,7 +91,7 @@ export type BeforeChangeRichTextHookArgs<
duplicate?: boolean duplicate?: boolean
errors?: { field: string; message: string }[] errors?: ValidationFieldError[]
/** Only available in `beforeChange` field hooks */ /** Only available in `beforeChange` field hooks */
mergeLocaleActions?: (() => Promise<void>)[] mergeLocaleActions?: (() => Promise<void>)[]
/** A string relating to which operation the field type is currently executing within. */ /** A string relating to which operation the field type is currently executing within. */
@@ -184,32 +184,19 @@ export type RichTextHooks = {
beforeChange?: BeforeChangeRichTextHook[] beforeChange?: BeforeChangeRichTextHook[]
beforeValidate?: BeforeValidateRichTextHook[] beforeValidate?: BeforeValidateRichTextHook[]
} }
export type RichTextGenerateComponentMap = (args: {
clientField: RichTextFieldClient
createMappedComponent: CreateMappedComponent
field: RichTextField
i18n: I18nClient
importMap: ImportMap
payload: Payload
schemaPath: string
}) => Map<string, unknown>
type RichTextAdapterBase< type RichTextAdapterBase<
Value extends object = object, Value extends object = object,
AdapterProps = any, AdapterProps = any,
ExtraFieldProperties = {}, ExtraFieldProperties = {},
> = { > = {
generateComponentMap: PayloadComponent<any, never>
generateImportMap?: Config['admin']['importMap']['generators'][0] generateImportMap?: Config['admin']['importMap']['generators'][0]
generateSchemaMap?: (args: { generateSchemaMap?: (args: {
config: SanitizedConfig config: SanitizedConfig
field: RichTextField field: RichTextField
i18n: I18n<any, any> i18n: I18n<any, any>
schemaMap: Map<string, Field[]> schemaMap: FieldSchemaMap
schemaPath: string schemaPath: string
}) => Map<string, Field[]> }) => FieldSchemaMap
/** /**
* Like an afterRead hook, but runs only for the GraphQL resolver. For populating data, this should be used, as afterRead hooks do not have a depth in graphQL. * Like an afterRead hook, but runs only for the GraphQL resolver. For populating data, this should be used, as afterRead hooks do not have a depth in graphQL.
* *

View File

@@ -1,10 +1,15 @@
import type { ClientCollectionConfig } from '../../collections/config/client.js'
import type { SanitizedCollectionConfig } from '../../collections/config/types.js' import type { SanitizedCollectionConfig } from '../../collections/config/types.js'
import type { ClientField } from '../../fields/config/client.js' import type { ClientField } from '../../fields/config/client.js'
export type RowData = Record<string, any> export type RowData = Record<string, any>
export type CellComponentProps<TField extends ClientField = ClientField> = { export type DefaultCellComponentProps<TCellData = any, TField extends ClientField = ClientField> = {
readonly cellData: TCellData
readonly className?: string readonly className?: string
readonly collectionConfig: ClientCollectionConfig
readonly columnIndex?: number
readonly customCellProps?: Record<string, any>
readonly field: TField readonly field: TField
readonly link?: boolean readonly link?: boolean
readonly onClick?: (args: { readonly onClick?: (args: {
@@ -12,13 +17,5 @@ export type CellComponentProps<TField extends ClientField = ClientField> = {
collectionSlug: SanitizedCollectionConfig['slug'] collectionSlug: SanitizedCollectionConfig['slug']
rowData: RowData rowData: RowData
}) => void }) => void
}
export type DefaultCellComponentProps<TCellData = any, TField extends ClientField = ClientField> = {
readonly cellData: TCellData
readonly customCellContext?: {
collectionSlug?: SanitizedCollectionConfig['slug']
uploadConfig?: SanitizedCollectionConfig['upload']
}
readonly rowData: RowData readonly rowData: RowData
} & CellComponentProps<TField> }

View File

@@ -1,6 +1,6 @@
import type { MarkOptional } from 'ts-essentials' import type { MarkOptional } from 'ts-essentials'
import type { ArrayField, ArrayFieldClient } from '../../fields/config/types.js' import type { ArrayField, ArrayFieldClient, ClientField } from '../../fields/config/types.js'
import type { ArrayFieldValidation } from '../../fields/validations.js' import type { ArrayFieldValidation } from '../../fields/validations.js'
import type { FieldErrorClientComponent, FieldErrorServerComponent } from '../forms/Error.js' import type { FieldErrorClientComponent, FieldErrorServerComponent } from '../forms/Error.js'
import type { import type {
@@ -14,15 +14,14 @@ import type {
FieldDescriptionServerComponent, FieldDescriptionServerComponent,
FieldLabelClientComponent, FieldLabelClientComponent,
FieldLabelServerComponent, FieldLabelServerComponent,
MappedComponent,
} from '../types.js' } from '../types.js'
type ArrayFieldClientWithoutType = MarkOptional<ArrayFieldClient, 'type'> type ArrayFieldClientWithoutType = MarkOptional<ArrayFieldClient, 'type'>
type ArrayFieldBaseClientProps = { type ArrayFieldBaseClientProps = {
readonly CustomRowLabel?: MappedComponent readonly path?: string
readonly validate?: ArrayFieldValidation readonly validate?: ArrayFieldValidation
} } & Pick<ServerFieldBase, 'permissions'>
export type ArrayFieldClientProps = ArrayFieldBaseClientProps & export type ArrayFieldClientProps = ArrayFieldBaseClientProps &
ClientFieldBase<ArrayFieldClientWithoutType> ClientFieldBase<ArrayFieldClientWithoutType>

View File

@@ -1,6 +1,6 @@
import type { MarkOptional } from 'ts-essentials' import type { MarkOptional } from 'ts-essentials'
import type { BlocksField, BlocksFieldClient } from '../../fields/config/types.js' import type { BlocksField, BlocksFieldClient, ClientField } from '../../fields/config/types.js'
import type { BlocksFieldValidation } from '../../fields/validations.js' import type { BlocksFieldValidation } from '../../fields/validations.js'
import type { FieldErrorClientComponent, FieldErrorServerComponent } from '../forms/Error.js' import type { FieldErrorClientComponent, FieldErrorServerComponent } from '../forms/Error.js'
import type { import type {
@@ -19,8 +19,9 @@ import type {
type BlocksFieldClientWithoutType = MarkOptional<BlocksFieldClient, 'type'> type BlocksFieldClientWithoutType = MarkOptional<BlocksFieldClient, 'type'>
type BlocksFieldBaseClientProps = { type BlocksFieldBaseClientProps = {
readonly path?: string
readonly validate?: BlocksFieldValidation readonly validate?: BlocksFieldValidation
} } & Pick<ServerFieldBase, 'permissions'>
export type BlocksFieldClientProps = BlocksFieldBaseClientProps & export type BlocksFieldClientProps = BlocksFieldBaseClientProps &
ClientFieldBase<BlocksFieldClientWithoutType> ClientFieldBase<BlocksFieldClientWithoutType>

View File

@@ -24,6 +24,7 @@ type CheckboxFieldBaseClientProps = {
readonly id?: string readonly id?: string
readonly onChange?: (value: boolean) => void readonly onChange?: (value: boolean) => void
readonly partialChecked?: boolean readonly partialChecked?: boolean
readonly path?: string
readonly validate?: CheckboxFieldValidation readonly validate?: CheckboxFieldValidation
} }

View File

@@ -20,7 +20,8 @@ type CodeFieldClientWithoutType = MarkOptional<CodeFieldClient, 'type'>
type CodeFieldBaseClientProps = { type CodeFieldBaseClientProps = {
readonly autoComplete?: string readonly autoComplete?: string
readonly valiCode?: CodeFieldValidation readonly path?: string
readonly validate?: CodeFieldValidation
} }
export type CodeFieldClientProps = ClientFieldBase<CodeFieldClientWithoutType> & export type CodeFieldClientProps = ClientFieldBase<CodeFieldClientWithoutType> &

View File

@@ -15,9 +15,14 @@ import type {
FieldLabelServerComponent, FieldLabelServerComponent,
} from '../types.js' } from '../types.js'
type CollapsibleFieldBaseClientProps = {
readonly path?: string
} & Pick<ServerFieldBase, 'permissions'>
type CollapsibleFieldClientWithoutType = MarkOptional<CollapsibleFieldClient, 'type'> type CollapsibleFieldClientWithoutType = MarkOptional<CollapsibleFieldClient, 'type'>
export type CollapsibleFieldClientProps = ClientFieldBase<CollapsibleFieldClientWithoutType> export type CollapsibleFieldClientProps = ClientFieldBase<CollapsibleFieldClientWithoutType> &
CollapsibleFieldBaseClientProps
export type CollapsibleFieldServerProps = ServerFieldBase< export type CollapsibleFieldServerProps = ServerFieldBase<
CollapsibleField, CollapsibleField,
@@ -29,8 +34,10 @@ export type CollapsibleFieldServerComponent = FieldServerComponent<
CollapsibleFieldClientWithoutType CollapsibleFieldClientWithoutType
> >
export type CollapsibleFieldClientComponent = export type CollapsibleFieldClientComponent = FieldClientComponent<
FieldClientComponent<CollapsibleFieldClientWithoutType> CollapsibleFieldClientWithoutType,
CollapsibleFieldBaseClientProps
>
export type CollapsibleFieldLabelServerComponent = FieldLabelServerComponent< export type CollapsibleFieldLabelServerComponent = FieldLabelServerComponent<
CollapsibleField, CollapsibleField,

View File

@@ -19,6 +19,7 @@ import type {
type DateFieldClientWithoutType = MarkOptional<DateFieldClient, 'type'> type DateFieldClientWithoutType = MarkOptional<DateFieldClient, 'type'>
type DateFieldBaseClientProps = { type DateFieldBaseClientProps = {
readonly path?: string
readonly validate?: DateFieldValidation readonly validate?: DateFieldValidation
} }

View File

@@ -19,7 +19,7 @@ import type {
type EmailFieldClientWithoutType = MarkOptional<EmailFieldClient, 'type'> type EmailFieldClientWithoutType = MarkOptional<EmailFieldClient, 'type'>
type EmailFieldBaseClientProps = { type EmailFieldBaseClientProps = {
readonly autoComplete?: string readonly path?: string
readonly validate?: EmailFieldValidation readonly validate?: EmailFieldValidation
} }

View File

@@ -17,7 +17,12 @@ import type {
type GroupFieldClientWithoutType = MarkOptional<GroupFieldClient, 'type'> type GroupFieldClientWithoutType = MarkOptional<GroupFieldClient, 'type'>
export type GroupFieldClientProps = ClientFieldBase<GroupFieldClientWithoutType> export type GroupFieldBaseClientProps = {
readonly path?: string
} & Pick<ServerFieldBase, 'permissions'>
export type GroupFieldClientProps = ClientFieldBase<GroupFieldClientWithoutType> &
GroupFieldBaseClientProps
export type GroupFieldServerProps = ServerFieldBase<GroupField, GroupFieldClientWithoutType> export type GroupFieldServerProps = ServerFieldBase<GroupField, GroupFieldClientWithoutType>
@@ -26,7 +31,10 @@ export type GroupFieldServerComponent = FieldServerComponent<
GroupFieldClientWithoutType GroupFieldClientWithoutType
> >
export type GroupFieldClientComponent = FieldClientComponent<GroupFieldClientWithoutType> export type GroupFieldClientComponent = FieldClientComponent<
GroupFieldClientWithoutType,
GroupFieldBaseClientProps
>
export type GroupFieldLabelServerComponent = FieldLabelServerComponent< export type GroupFieldLabelServerComponent = FieldLabelServerComponent<
GroupField, GroupField,

View File

@@ -1,11 +1,12 @@
import type { ClientField } from '../../fields/config/client.js' import type { ClientField } from '../../fields/config/types.js'
import type { FormFieldBase } from '../types.js' import type { ClientFieldBase } from '../types.js'
export type HiddenFieldProps = { type HiddenFieldBaseClientProps = {
readonly disableModifyingForm?: false readonly disableModifyingForm?: false
readonly field?: { readonly field?: {
readonly name?: string readonly name?: string
} & Pick<ClientField, '_path'> } & ClientField
readonly forceUsePathFromProps?: boolean
readonly value?: unknown readonly value?: unknown
} & FormFieldBase }
export type HiddenFieldProps = ClientFieldBase & HiddenFieldBaseClientProps

View File

@@ -19,6 +19,7 @@ import type {
type JSONFieldClientWithoutType = MarkOptional<JSONFieldClient, 'type'> type JSONFieldClientWithoutType = MarkOptional<JSONFieldClient, 'type'>
type JSONFieldBaseClientProps = { type JSONFieldBaseClientProps = {
readonly path?: string
readonly validate?: JSONFieldValidation readonly validate?: JSONFieldValidation
} }

View File

@@ -17,7 +17,9 @@ import type {
type JoinFieldClientWithoutType = MarkOptional<JoinFieldClient, 'type'> type JoinFieldClientWithoutType = MarkOptional<JoinFieldClient, 'type'>
export type JoinFieldClientProps = ClientFieldBase<JoinFieldClientWithoutType> export type JoinFieldClientProps = {
path?: string
} & ClientFieldBase<JoinFieldClientWithoutType>
export type JoinFieldServerProps = ServerFieldBase<JoinField> export type JoinFieldServerProps = ServerFieldBase<JoinField>

View File

@@ -20,6 +20,7 @@ type NumberFieldClientWithoutType = MarkOptional<NumberFieldClient, 'type'>
type NumberFieldBaseClientProps = { type NumberFieldBaseClientProps = {
readonly onChange?: (e: number) => void readonly onChange?: (e: number) => void
readonly path?: string
readonly validate?: NumberFieldValidation readonly validate?: NumberFieldValidation
} }

View File

@@ -19,6 +19,7 @@ import type {
type PointFieldClientWithoutType = MarkOptional<PointFieldClient, 'type'> type PointFieldClientWithoutType = MarkOptional<PointFieldClient, 'type'>
type PointFieldBaseClientProps = { type PointFieldBaseClientProps = {
readonly path?: string
readonly validate?: PointFieldValidation readonly validate?: PointFieldValidation
} }

View File

@@ -24,6 +24,7 @@ type RadioFieldBaseClientProps = {
*/ */
readonly disableModifyingForm?: boolean readonly disableModifyingForm?: boolean
readonly onChange?: OnChange readonly onChange?: OnChange
readonly path?: string
readonly validate?: RadioFieldValidation readonly validate?: RadioFieldValidation
readonly value?: string readonly value?: string
} }

View File

@@ -19,6 +19,7 @@ import type {
type RelationshipFieldClientWithoutType = MarkOptional<RelationshipFieldClient, 'type'> type RelationshipFieldClientWithoutType = MarkOptional<RelationshipFieldClient, 'type'>
type RelationshipFieldBaseClientProps = { type RelationshipFieldBaseClientProps = {
readonly path?: string
readonly validate?: RelationshipFieldValidation readonly validate?: RelationshipFieldValidation
} }

View File

@@ -16,13 +16,18 @@ import type {
FieldLabelServerComponent, FieldLabelServerComponent,
} from '../types.js' } from '../types.js'
type RichTextFieldClientWithoutType = MarkOptional<RichTextFieldClient, 'type'> type RichTextFieldClientWithoutType<
TValue extends object = any,
TAdapterProps = any,
TExtraProperties = object,
> = MarkOptional<RichTextFieldClient<TValue, TAdapterProps, TExtraProperties>, 'type'>
type RichTextFieldBaseClientProps< type RichTextFieldBaseClientProps<
TValue extends object = any, TValue extends object = any,
TAdapterProps = any, TAdapterProps = any,
TExtraProperties = object, TExtraProperties = object,
> = { > = {
readonly path?: string
readonly validate?: RichTextFieldValidation readonly validate?: RichTextFieldValidation
} }
@@ -30,7 +35,7 @@ export type RichTextFieldClientProps<
TValue extends object = any, TValue extends object = any,
TAdapterProps = any, TAdapterProps = any,
TExtraProperties = object, TExtraProperties = object,
> = ClientFieldBase<RichTextFieldClientWithoutType> & > = ClientFieldBase<RichTextFieldClientWithoutType<TValue, TAdapterProps, TExtraProperties>> &
RichTextFieldBaseClientProps<TValue, TAdapterProps, TExtraProperties> RichTextFieldBaseClientProps<TValue, TAdapterProps, TExtraProperties>
export type RichTextFieldServerProps = ServerFieldBase< export type RichTextFieldServerProps = ServerFieldBase<

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