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

View File

@@ -228,7 +228,6 @@ The following additional properties are also provided to the `field` prop:
| Property | Description |
| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`_isPresentational`** | A boolean indicating that the field is purely visual and does not directly affect data or change data shape, i.e. the [UI Field](../fields/ui). |
| **`_path`** | A string representing the direct, dynamic path to the field at runtime, i.e. `myGroup.myArray[0].myField`. |
| **`_schemaPath`** | A string representing the direct, static path to the [Field Config](../fields/overview), i.e. `myGroup.myArray.myField` |

View File

@@ -370,7 +370,12 @@ const yourEditorState: SerializedEditorState // <= your current editor state her
// Import editor state into your headless editor
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) {
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
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
try {
headlessEditor.setEditorState(headlessEditor.parseEditorState(yourEditorState)) // This should commit the editor state immediately
} catch (e) {
headlessEditor.update(
() => {
headlessEditor.setEditorState(headlessEditor.parseEditorState(yourEditorState))
},
{ discrete: true }, // This should commit the editor state immediately
)
} catch (e) {
logger.error({ err: e }, 'ERROR parsing editor state')
}

View File

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

View File

@@ -1,8 +1,8 @@
/* 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 '@payloadcms/next/css'
import { RootLayout } from '@payloadcms/next/layouts'
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import React from 'react'
import './custom.scss'

View File

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

View File

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

View File

@@ -58,6 +58,7 @@
"dev:generate-importmap": "pnpm runts ./test/generateImportMap.ts",
"dev:generate-types": "pnpm runts ./test/generateTypes.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",
"devsafe": "node ./scripts/delete-recursively.js '**/.next' && pnpm dev",
"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",
"force:build": "pnpm run build:core:force",
"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",
"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-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: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": "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 -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",
"release:alpha": "pnpm runts ./scripts/release.ts --bump prerelease --tag alpha",
"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 { count } from './count.js'
import { countGlobalVersions } from './countGlobalVersions.js'
import { countVersions } from './countVersions.js'
import { create } from './create.js'
import { createGlobal } from './createGlobal.js'
import { createGlobalVersion } from './createGlobalVersion.js'
@@ -154,7 +156,6 @@ export function mongooseAdapter({
collections: {},
connection: undefined,
connectOptions: connectOptions || {},
count,
disableIndexHints,
globals: undefined,
mongoMemoryServer,
@@ -166,6 +167,9 @@ export function mongooseAdapter({
beginTransaction: transactionOptions === false ? defaultBeginTransaction() : beginTransaction,
commitTransaction,
connect,
count,
countGlobalVersions,
countVersions,
create,
createGlobal,
createGlobalVersion,

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,8 @@ import {
beginTransaction,
commitTransaction,
count,
countGlobalVersions,
countVersions,
create,
createGlobal,
createGlobalVersion,
@@ -127,6 +129,8 @@ export function vercelPostgresAdapter(args: Args = {}): DatabaseAdapterObj<Verce
convertPathToJSONTraversal,
count,
countDistinct,
countGlobalVersions,
countVersions,
create,
createGlobal,
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 { countGlobalVersions } from './countGlobalVersions.js'
export { countVersions } from './countVersions.js'
export { create } from './create.js'
export { createGlobal } from './createGlobal.js'
export { createGlobalVersion } from './createGlobalVersion.js'

View File

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

View File

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

View File

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

View File

@@ -5,14 +5,11 @@ import React from 'react'
import { baseClass } from '../../Tab/index.js'
export const VersionsPill: React.FC = () => {
const { versions } = useDocumentInfo()
const { versionCount } = useDocumentInfo()
// don't count snapshots
const totalVersions = versions?.docs.filter((version) => !version.snapshot).length || 0
if (!versions?.totalDocs) {
if (!versionCount) {
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'
import { Gutter, RenderTitle } from '@payloadcms/ui'
import React, { Fragment } from 'react'
import React from 'react'
import './index.scss'
import { DocumentTabs } from './Tabs/index.js'
@@ -16,21 +16,16 @@ const baseClass = `doc-header`
export const DocumentHeader: React.FC<{
collectionConfig?: SanitizedCollectionConfig
customHeader?: React.ReactNode
globalConfig?: SanitizedGlobalConfig
hideTabs?: boolean
i18n: I18n
payload: Payload
permissions: Permissions
}> = (props) => {
const { collectionConfig, customHeader, globalConfig, hideTabs, i18n, payload, permissions } =
props
const { collectionConfig, globalConfig, hideTabs, i18n, payload, permissions } = props
return (
<Gutter className={baseClass}>
{customHeader && customHeader}
{!customHeader && (
<Fragment>
<RenderTitle className={`${baseClass}__title`} />
{!hideTabs && (
<DocumentTabs
@@ -41,8 +36,6 @@ export const DocumentHeader: React.FC<{
permissions={permissions}
/>
)}
</Fragment>
)}
</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 { getCreateMappedComponent, PayloadLogo, RenderComponent } from '@payloadcms/ui/shared'
import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
import { PayloadLogo } from '@payloadcms/ui/shared'
import React from 'react'
export const Logo: React.FC<ServerProps> = (props) => {
@@ -16,9 +17,12 @@ export const Logo: React.FC<ServerProps> = (props) => {
} = {},
} = payload.config
const createMappedComponent = getCreateMappedComponent({
importMap: payload.importMap,
serverProps: {
return (
<RenderServerComponent
Component={CustomLogo}
Fallback={PayloadLogo}
importMap={payload.importMap}
serverProps={{
i18n,
locale,
params,
@@ -26,10 +30,7 @@ export const Logo: React.FC<ServerProps> = (props) => {
permissions,
searchParams,
user,
},
})
const mappedCustomLogo = createMappedComponent(CustomLogo, undefined, PayloadLogo, 'CustomLogo')
return <RenderComponent mappedComponent={mappedCustomLogo} />
}}
/>
)
}

View File

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

View File

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

View File

@@ -1 +1,2 @@
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 { addLocalesToRequestFromData, sanitizeLocales } from '../utilities/addLocalesToRequest.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 { 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 }) => {
let parsingRequest = true
let shouldAbortProccessing = false
let fileCount = 0
let filesCompleted = 0
let allFilesHaveResolved: (value?: unknown) => void
@@ -42,14 +43,16 @@ export const processMultipart: ProcessMultipart = async ({ options, request }) =
headersObject[name] = value
})
function abortAndDestroyFile(file: Readable, err: APIError) {
file.destroy()
parsingRequest = false
failedResolvingFiles(err)
}
const reader = request.body.getReader()
const busboy = Busboy({ ...options, headers: headersObject })
function abortAndDestroyFile(file: Readable, err: APIError) {
file.destroy()
shouldAbortProccessing = true
failedResolvingFiles(err)
}
// Build multipart req.body fields
busboy.on('field', (field, val) => {
result.fields = buildFields(result.fields, field, val)
@@ -136,7 +139,7 @@ export const processMultipart: ProcessMultipart = async ({ options, request }) =
mimetype: mime,
size,
tempFilePath: getFilePath(),
truncated: Boolean('truncated' in file && file.truncated),
truncated: Boolean('truncated' in file && file.truncated) || false,
},
options,
),
@@ -164,8 +167,6 @@ export const processMultipart: ProcessMultipart = async ({ options, request }) =
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 () => {
debugLog(options, `Busboy finished parsing request.`)
if (options.parseNested) {
@@ -190,14 +191,10 @@ export const processMultipart: ProcessMultipart = async ({ options, request }) =
'error',
(err = new APIError('Busboy error parsing multipart request', httpStatus.BAD_REQUEST)) => {
debugLog(options, `Busboy error`)
parsingRequest = false
throw err
},
)
const reader = request.body.getReader()
// Start parsing request
while (parsingRequest) {
const { done, value } = await reader.read()
@@ -205,7 +202,7 @@ export const processMultipart: ProcessMultipart = async ({ options, request }) =
parsingRequest = false
}
if (value) {
if (value && !shouldAbortProccessing) {
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 { CustomVersionParser, ImportMap, SanitizedConfig } from 'payload'
import type { CustomVersionParser, ImportMap, SanitizedConfig, ServerFunctionClient } from 'payload'
import { rtlLanguages } from '@payloadcms/translations'
import { RootProvider } from '@payloadcms/ui'
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 { checkDependencies, parseCookies } from 'payload'
import React from 'react'
import { getClientConfig } from '../../utilities/getClientConfig.js'
import { getPayloadHMR } from '../../utilities/getPayloadHMR.js'
import { getRequestLanguage } from '../../utilities/getRequestLanguage.js'
import { getRequestTheme } from '../../utilities/getRequestTheme.js'
import { initReq } from '../../utilities/initReq.js'
import { DefaultEditView } from '../../views/Edit/Default/index.js'
import { DefaultListView } from '../../views/List/Default/index.js'
import { NestProviders } from './NestProviders.js'
export const metadata = {
description: 'Generated by Next.js',
@@ -41,11 +40,12 @@ let checkedDependencies = false
export const RootLayout = async ({
children,
config: configPromise,
importMap,
serverFunction,
}: {
readonly children: React.ReactNode
readonly config: Promise<SanitizedConfig>
readonly importMap: ImportMap
readonly serverFunction: ServerFunctionClient
}) => {
if (
process.env.NODE_ENV !== 'production' &&
@@ -103,16 +103,6 @@ export const RootLayout = async ({
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)
? 'RTL'
: 'LTR'
@@ -174,23 +164,39 @@ export const RootLayout = async ({
const isNavOpen = navPreferences?.value?.open ?? true
const clientConfig = await getClientConfig({
config,
i18n,
})
return (
<html data-theme={theme} dir={dir} lang={languageCode}>
<body>
<RootProvider
config={clientConfig}
dateFNSKey={i18n.dateFNSKey}
fallbackLang={clientConfig.i18n.fallbackLanguage}
fallbackLang={config.i18n.fallbackLanguage}
isNavOpen={isNavOpen}
languageCode={languageCode}
languageOptions={languageOptions}
permissions={permissions}
serverFunction={serverFunction}
switchLanguageServerAction={switchLanguageServerAction}
theme={theme}
translations={i18n.translations}
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>
<div id="portal" />
</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 { unlock } from './auth/unlock.js'
import { verifyEmail } from './auth/verifyEmail.js'
import { buildFormState } from './buildFormState.js'
import { endpointsAreDisabled } from './checkEndpoints.js'
import { count } from './collections/count.js'
import { create } from './collections/create.js'
@@ -110,9 +109,6 @@ const endpoints = {
access,
og: generateOGImage,
},
POST: {
'form-state': buildFormState,
},
},
}
@@ -575,10 +571,6 @@ export const POST =
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) {

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'
export const OGImage: React.FC<{
description?: string
Fallback: React.ComponentType
fontFamily?: string
Icon: MappedComponent
Icon: PayloadComponent
importMap: ImportMap
leader?: string
title?: string
}> = ({ description, fontFamily = 'Arial, sans-serif', Icon, leader, title }) => {
}> = ({
description,
Fallback,
fontFamily = 'Arial, sans-serif',
Icon,
importMap,
leader,
title,
}) => {
return (
<div
style={{
@@ -85,11 +95,13 @@ export const OGImage: React.FC<{
width: '38px',
}}
>
<RenderComponent
<RenderServerComponent
clientProps={{
fill: 'white',
}}
mappedComponent={Icon}
Component={Icon}
Fallback={Fallback}
importMap={importMap}
/>
</div>
</div>

View File

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

View File

@@ -1,73 +1,12 @@
import type { Collection, ErrorResult, PayloadRequest, SanitizedConfig } from 'payload'
import httpStatus from 'http-status'
import { APIError, APIErrorName, ValidationErrorName } from 'payload'
import { APIError, formatErrors } from 'payload'
import { getPayloadHMR } from '../../utilities/getPayloadHMR.js'
import { headersWithCors } from '../../utilities/headersWithCors.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 ({
collection,
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 { getCreateMappedComponent, RenderComponent } from '@payloadcms/ui/shared'
import {
ActionsProvider,
AppHeader,
BulkUploadProvider,
EntityVisibilityProvider,
NavToggler,
} from '@payloadcms/ui'
import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
import React from 'react'
import { DefaultNav } from '../../elements/Nav/index.js'
@@ -14,6 +20,7 @@ const baseClass = 'template-default'
export type DefaultTemplateProps = {
children?: React.ReactNode
className?: string
viewActions?: CustomComponent[]
visibleEntities: VisibleEntities
} & ServerProps
@@ -27,10 +34,13 @@ export const DefaultTemplate: React.FC<DefaultTemplateProps> = ({
permissions,
searchParams,
user,
viewActions,
visibleEntities,
}) => {
const {
admin: {
avatar,
components,
components: { header: CustomHeader, Nav: CustomNav } = {
header: undefined,
Nav: undefined,
@@ -38,9 +48,39 @@ export const DefaultTemplate: React.FC<DefaultTemplateProps> = ({
} = {},
} = payload.config || {}
const createMappedComponent = getCreateMappedComponent({
importMap: payload.importMap,
serverProps: {
const { Actions } = React.useMemo<{
Actions: Record<string, React.ReactNode>
}>(() => {
return {
Actions: viewActions
? viewActions.reduce((acc, action) => {
if (action) {
if (typeof action === 'object') {
acc[action.path] = (
<RenderServerComponent Component={action} importMap={payload.importMap} />
)
} else {
acc[action] = (
<RenderServerComponent Component={action} importMap={payload.importMap} />
)
}
}
return acc
}, {})
: undefined,
}
}, [viewActions, payload])
return (
<EntityVisibilityProvider visibleEntities={visibleEntities}>
<BulkUploadProvider>
<ActionsProvider Actions={Actions}>
<RenderServerComponent
clientProps={{ clientProps: { visibleEntities } }}
Component={CustomHeader}
importMap={payload.importMap}
serverProps={{
i18n,
locale,
params,
@@ -48,27 +88,9 @@ export const DefaultTemplate: React.FC<DefaultTemplateProps> = ({
permissions,
searchParams,
user,
},
})
const MappedDefaultNav: MappedComponent = createMappedComponent(
CustomNav,
undefined,
DefaultNav,
'CustomNav',
)
const MappedCustomHeader = createMappedComponent(
CustomHeader,
undefined,
undefined,
'CustomHeader',
)
return (
<EntityVisibilityProvider visibleEntities={visibleEntities}>
<BulkUploadProvider>
<RenderComponent mappedComponent={MappedCustomHeader} />
visibleEntities,
}}
/>
<div style={{ position: 'relative' }}>
<div className={`${baseClass}__nav-toggler-wrapper`} id="nav-toggler">
<div className={`${baseClass}__nav-toggler-container`} id="nav-toggler">
@@ -78,14 +100,46 @@ export const DefaultTemplate: React.FC<DefaultTemplateProps> = ({
</div>
</div>
<Wrapper baseClass={baseClass} className={className}>
<RenderComponent mappedComponent={MappedDefaultNav} />
<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 />
<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>
</ActionsProvider>
</BulkUploadProvider>
</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 { headers as getHeaders } from 'next/headers.js'
@@ -47,13 +48,13 @@ export const initPage = async ({
req: {
headers,
host: headers.get('host'),
i18n,
i18n: i18n as I18n,
query: qs.parse(queryString, {
depth: 10,
ignoreQueryPrefix: true,
}),
url: `${payload.config.serverURL}${route}${searchParams ? queryString : ''}`,
} as PayloadRequest,
},
},
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 { initI18n } from '@payloadcms/translations'
@@ -16,7 +16,10 @@ type Result = {
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 headers = await getHeaders()
@@ -40,9 +43,9 @@ export const initReq = cache(async function (config: SanitizedConfig): Promise<R
req: {
headers,
host: headers.get('host'),
i18n,
i18n: i18n as I18n,
url: `${payload.config.serverURL}`,
} as PayloadRequest,
},
},
payload,
)

View File

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

View File

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

View File

@@ -22,7 +22,7 @@ export const Settings: React.FC<{
<div className={[baseClass, className].filter(Boolean).join(' ')}>
<h3>{i18n.t('general:payloadSettings')}</h3>
<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} />
</div>
{theme === 'all' && <ToggleTheme />}

View File

@@ -1,18 +1,17 @@
import type { AdminViewProps } from 'payload'
import {
DocumentInfoProvider,
EditDepthProvider,
HydrateAuthProvider,
RenderComponent,
} from '@payloadcms/ui'
import { getCreateMappedComponent } from '@payloadcms/ui/shared'
import { DocumentInfoProvider, EditDepthProvider, HydrateAuthProvider } from '@payloadcms/ui'
import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
import { buildFormState } from '@payloadcms/ui/utilities/buildFormState'
import { notFound } from 'next/navigation.js'
import React from 'react'
import { DocumentHeader } from '../../elements/DocumentHeader/index.js'
import { getDocPreferences } from '../Document/getDocPreferences.js'
import { getDocumentData } from '../Document/getDocumentData.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 { AccountClient } from './index.client.js'
import { Settings } from './Settings/index.js'
@@ -50,24 +49,103 @@ export const Account: React.FC<AdminViewProps> = async ({
const collectionConfig = config.collections.find((collection) => collection.slug === userSlug)
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 } =
await getDocumentPermissions({
id: user.id,
collectionConfig,
data: user,
data,
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,
collectionConfig,
locale,
req,
isEditing: true,
payload: req.payload,
user,
})
const createMappedComponent = getCreateMappedComponent({
importMap: payload.importMap,
serverProps: {
// Get all versions required for UI
const { hasPublishedDoc, mostRecentVersionIsAutosaved, unpublishedVersionCount, versionCount } =
await getVersions({
id: user.id,
collectionConfig,
docPermissions,
locale: locale?.code,
payload,
user,
})
return (
<DocumentInfoProvider
AfterFields={<Settings i18n={i18n} languageOptions={languageOptions} theme={theme} />}
apiURL={`${serverURL}${api}/${userSlug}${user?.id ? `/${user.id}` : ''}`}
collectionSlug={userSlug}
currentEditor={currentEditor}
docPermissions={docPermissions}
hasPublishedDoc={hasPublishedDoc}
hasPublishPermission={hasPublishPermission}
hasSavePermission={hasSavePermission}
id={user?.id}
initialData={data}
initialState={formState}
isEditing
isLocked={isLocked}
lastUpdateTime={lastUpdateTime}
mostRecentVersionIsAutosaved={mostRecentVersionIsAutosaved}
unpublishedVersionCount={unpublishedVersionCount}
versionCount={versionCount}
>
<EditDepthProvider>
<DocumentHeader
collectionConfig={collectionConfig}
hideTabs
i18n={i18n}
payload={payload}
permissions={permissions}
/>
<HydrateAuthProvider permissions={permissions} />
<RenderServerComponent
Component={CustomAccountComponent}
importMap={payload.importMap}
serverProps={{
i18n,
initPageResult,
locale,
@@ -77,39 +155,9 @@ export const Account: React.FC<AdminViewProps> = async ({
routeSegments: [],
searchParams,
user,
},
})
const mappedAccountComponent = createMappedComponent(
CustomAccountComponent?.Component,
undefined,
EditView,
'CustomAccountComponent.Component',
)
return (
<DocumentInfoProvider
AfterFields={<Settings i18n={i18n} languageOptions={languageOptions} theme={theme} />}
apiURL={`${serverURL}${api}/${userSlug}${user?.id ? `/${user.id}` : ''}`}
collectionSlug={userSlug}
docPermissions={docPermissions}
hasPublishPermission={hasPublishPermission}
hasSavePermission={hasSavePermission}
id={user?.id}
initialData={data}
initialState={formState}
isEditing
>
<EditDepthProvider depth={1}>
<DocumentHeader
collectionConfig={collectionConfig}
hideTabs
i18n={i18n}
payload={payload}
permissions={permissions}
}}
/>
<HydrateAuthProvider permissions={permissions} />
<RenderComponent mappedComponent={mappedAccountComponent} />
<EditView />
<AccountClient />
</EditDepthProvider>
</DocumentInfoProvider>

View File

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

View File

@@ -1,8 +1,11 @@
import type { AdminViewProps } from 'payload'
import { buildFormState } from '@payloadcms/ui/utilities/buildFormState'
import React from 'react'
import { getDocPreferences } from '../Document/getDocPreferences.js'
import { getDocumentData } from '../Document/getDocumentData.js'
import { getDocumentPermissions } from '../Document/getDocumentPermissions.js'
import { CreateFirstUserClient } from './index.client.js'
import './index.scss'
@@ -26,11 +29,39 @@ export const CreateFirstUserView: React.FC<AdminViewProps> = async ({ initPageRe
const { auth: authOptions } = collectionConfig
const loginWithUsername = authOptions.loginWithUsername
const { formState } = await getDocumentData({
collectionConfig,
// Fetch the data required for the view
const data = await getDocumentData({
collectionSlug: collectionConfig.slug,
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,
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 (
@@ -38,6 +69,8 @@ export const CreateFirstUserView: React.FC<AdminViewProps> = async ({ initPageRe
<h1>{req.t('general:welcome')}</h1>
<p>{req.t('authentication:beginCreateFirstUser')}</p>
<CreateFirstUserClient
docPermissions={docPermissions}
docPreferences={docPreferences}
initialState={formState}
loginWithUsername={loginWithUsername}
userSlug={userSlug}

View File

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

View File

@@ -1,13 +1,9 @@
import type { EntityToGroup } from '@payloadcms/ui/shared'
import type { AdminViewProps } from 'payload'
import { HydrateAuthProvider } from '@payloadcms/ui'
import {
EntityType,
getCreateMappedComponent,
groupNavItems,
RenderComponent,
} from '@payloadcms/ui/shared'
import { HydrateAuthProvider, SetStepNav } from '@payloadcms/ui'
import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
import { EntityType, groupNavItems } from '@payloadcms/ui/shared'
import LinkImport from 'next/link.js'
import React, { Fragment } from 'react'
@@ -111,9 +107,19 @@ export const Dashboard: React.FC<AdminViewProps> = async ({
i18n,
)
const createMappedComponent = getCreateMappedComponent({
importMap: payload.importMap,
serverProps: {
return (
<Fragment>
<HydrateAuthProvider permissions={permissions} />
<SetStepNav nav={[]} />
<RenderServerComponent
clientProps={{
Link,
locale,
}}
Component={CustomDashboardComponent}
Fallback={DefaultDashboard}
importMap={payload.importMap}
serverProps={{
globalData,
i18n,
Link,
@@ -125,25 +131,7 @@ export const Dashboard: React.FC<AdminViewProps> = async ({
searchParams,
user,
visibleEntities,
},
})
const mappedDashboardComponent = createMappedComponent(
CustomDashboardComponent?.Component,
undefined,
DefaultDashboard,
'CustomDashboardComponent.Component',
)
return (
<Fragment>
<HydrateAuthProvider permissions={permissions} />
<RenderComponent
clientProps={{
Link,
locale,
}}
mappedComponent={mappedDashboardComponent}
/>
</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'
import type React from 'react'
import { notFound } from 'next/navigation.js'
import { APIView as DefaultAPIView } from '../API/index.js'
import { EditView as DefaultEditView } from '../Edit/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> = {
Component?: React.FC<TProps>
payloadComponent?: PayloadComponent<TProps>
ComponentConfig?: PayloadComponent<TProps>
}
export const getViewsFromConfig = ({
@@ -81,7 +79,7 @@ export const getViewsFromConfig = ({
routeSegments
if (!overrideDocPermissions && !docPermissions?.read?.permission) {
notFound()
throw new Error('not-found')
} else {
// `../:id`, or `../create`
switch (routeSegments.length) {
@@ -94,7 +92,7 @@ export const getViewsFromConfig = ({
docPermissions?.create?.permission
) {
CustomView = {
payloadComponent: getCustomViewByKey(views, 'default'),
ComponentConfig: getCustomViewByKey(views, 'default'),
}
DefaultView = {
Component: DefaultEditView,
@@ -132,11 +130,11 @@ export const getViewsFromConfig = ({
viewKey = customViewKey
CustomView = {
payloadComponent: CustomViewComponent,
ComponentConfig: CustomViewComponent,
}
} else {
CustomView = {
payloadComponent: getCustomViewByKey(views, 'default'),
ComponentConfig: getCustomViewByKey(views, 'default'),
}
DefaultView = {
@@ -156,7 +154,7 @@ export const getViewsFromConfig = ({
case 'api': {
if (collectionConfig?.admin?.hideAPIURL !== true) {
CustomView = {
payloadComponent: getCustomViewByKey(views, 'api'),
ComponentConfig: getCustomViewByKey(views, 'api'),
}
DefaultView = {
Component: DefaultAPIView,
@@ -171,7 +169,7 @@ export const getViewsFromConfig = ({
Component: DefaultLivePreviewView,
}
CustomView = {
payloadComponent: getCustomViewByKey(views, 'livePreview'),
ComponentConfig: getCustomViewByKey(views, 'livePreview'),
}
}
break
@@ -180,7 +178,7 @@ export const getViewsFromConfig = ({
case 'versions': {
if (!overrideDocPermissions && docPermissions?.readVersions?.permission) {
CustomView = {
payloadComponent: getCustomViewByKey(views, 'versions'),
ComponentConfig: getCustomViewByKey(views, 'versions'),
}
DefaultView = {
Component: DefaultVersionsView,
@@ -218,7 +216,7 @@ export const getViewsFromConfig = ({
viewKey = customViewKey
CustomView = {
payloadComponent: CustomViewComponent,
ComponentConfig: CustomViewComponent,
}
}
@@ -233,7 +231,7 @@ export const getViewsFromConfig = ({
if (segment4 === 'versions') {
if (!overrideDocPermissions && docPermissions?.readVersions?.permission) {
CustomView = {
payloadComponent: getCustomViewByKey(views, 'version'),
ComponentConfig: getCustomViewByKey(views, 'version'),
}
DefaultView = {
Component: DefaultVersionView,
@@ -269,7 +267,7 @@ export const getViewsFromConfig = ({
viewKey = customViewKey
CustomView = {
payloadComponent: CustomViewComponent,
ComponentConfig: CustomViewComponent,
}
}
}
@@ -284,12 +282,12 @@ export const getViewsFromConfig = ({
const [globalEntity, globalSlug, segment3, ...remainingSegments] = routeSegments
if (!overrideDocPermissions && !docPermissions?.read?.permission) {
notFound()
throw new Error('not-found')
} else {
switch (routeSegments.length) {
case 2: {
CustomView = {
payloadComponent: getCustomViewByKey(views, 'default'),
ComponentConfig: getCustomViewByKey(views, 'default'),
}
DefaultView = {
Component: DefaultEditView,
@@ -303,7 +301,7 @@ export const getViewsFromConfig = ({
case 'api': {
if (globalConfig?.admin?.hideAPIURL !== true) {
CustomView = {
payloadComponent: getCustomViewByKey(views, 'api'),
ComponentConfig: getCustomViewByKey(views, 'api'),
}
DefaultView = {
Component: DefaultAPIView,
@@ -318,7 +316,7 @@ export const getViewsFromConfig = ({
Component: DefaultLivePreviewView,
}
CustomView = {
payloadComponent: getCustomViewByKey(views, 'livePreview'),
ComponentConfig: getCustomViewByKey(views, 'livePreview'),
}
}
break
@@ -327,7 +325,7 @@ export const getViewsFromConfig = ({
case 'versions': {
if (!overrideDocPermissions && docPermissions?.readVersions?.permission) {
CustomView = {
payloadComponent: getCustomViewByKey(views, 'versions'),
ComponentConfig: getCustomViewByKey(views, 'versions'),
}
DefaultView = {
@@ -362,7 +360,7 @@ export const getViewsFromConfig = ({
viewKey = customViewKey
CustomView = {
payloadComponent: CustomViewComponent,
ComponentConfig: CustomViewComponent,
}
} else {
DefaultView = {
@@ -385,7 +383,7 @@ export const getViewsFromConfig = ({
if (segment3 === 'versions') {
if (!overrideDocPermissions && docPermissions?.readVersions?.permission) {
CustomView = {
payloadComponent: getCustomViewByKey(views, 'version'),
ComponentConfig: getCustomViewByKey(views, 'version'),
}
DefaultView = {
Component: DefaultVersionView,
@@ -416,7 +414,7 @@ export const getViewsFromConfig = ({
viewKey = customViewKey
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 {
AdminViewProps,
EditViewComponent,
MappedComponent,
Data,
PayloadComponent,
ServerProps,
ServerSideEditViewProps,
} from 'payload'
import { DocumentInfoProvider, EditDepthProvider, HydrateAuthProvider } from '@payloadcms/ui'
import {
formatAdminURL,
getCreateMappedComponent,
isEditing as getIsEditing,
RenderComponent,
} from '@payloadcms/ui/shared'
import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
import { formatAdminURL, isEditing as getIsEditing } from '@payloadcms/ui/shared'
import { buildFormState } from '@payloadcms/ui/utilities/buildFormState'
import { isRedirectError } from 'next/dist/client/components/redirect.js'
import { notFound, redirect } from 'next/navigation.js'
import React from 'react'
import type { GenerateEditViewMetadata } from './getMetaBySegment.js'
import type { ViewFromConfig } from './getViewsFromConfig.js'
import { DocumentHeader } from '../../elements/DocumentHeader/index.js'
import { NotFoundView } from '../NotFound/index.js'
import { getDocPreferences } from './getDocPreferences.js'
import { getDocumentData } from './getDocumentData.js'
import { getDocumentPermissions } from './getDocumentPermissions.js'
import { getIsLocked } from './getIsLocked.js'
import { getMetaBySegment } from './getMetaBySegment.js'
import { getVersions } from './getVersions.js'
import { getViewsFromConfig } from './getViewsFromConfig.js'
import { renderDocumentSlots } from './renderDocumentSlots.js'
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,
initialData,
initPageResult,
params,
redirectAfterDelete,
redirectAfterDuplicate,
searchParams,
}) => {
}: AdminViewProps): Promise<{
data: Data
Document: React.ReactNode
}> => {
const {
collectionConfig,
docID: id,
@@ -57,39 +72,94 @@ export const Document: React.FC<AdminViewProps> = async ({
const segments = Array.isArray(params?.segments) ? params.segments : []
const collectionSlug = collectionConfig?.slug || undefined
const globalSlug = globalConfig?.slug || undefined
const isEditing = getIsEditing({ id, collectionSlug, globalSlug })
let RootViewOverride: MappedComponent<ServerSideEditViewProps>
let CustomView: MappedComponent<ServerSideEditViewProps>
let DefaultView: MappedComponent<ServerSideEditViewProps>
let ErrorView: MappedComponent<AdminViewProps>
let RootViewOverride: PayloadComponent
let CustomView: ViewFromConfig<ServerSideEditViewProps>
let DefaultView: ViewFromConfig<ServerSideEditViewProps>
let ErrorView: ViewFromConfig<AdminViewProps>
let apiURL: string
const { data, formState } = await getDocumentData({
// Fetch the doc required for the view
const doc =
initialData ||
(await getDocumentData({
id,
collectionConfig,
globalConfig,
collectionSlug,
globalSlug,
locale,
req,
})
payload,
user,
}))
if (!data) {
notFound()
if (isEditing && !doc) {
throw new Error('not-found')
}
const { docPermissions, hasPublishPermission, hasSavePermission } = await getDocumentPermissions({
const [
docPreferences,
{ docPermissions, hasPublishPermission, hasSavePermission },
{ currentEditor, isLocked, lastUpdateTime },
] = await Promise.all([
// Get document preferences
getDocPreferences({
id,
collectionSlug,
globalSlug,
payload,
user,
}),
// Get permissions
getDocumentPermissions({
id,
collectionConfig,
data,
data: doc,
globalConfig,
req,
})
}),
const createMappedComponent = getCreateMappedComponent({
importMap,
serverProps: {
// 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,
@@ -99,12 +169,11 @@ export const Document: React.FC<AdminViewProps> = async ({
routeSegments: segments,
searchParams,
user,
},
})
}
if (collectionConfig) {
if (!visibleEntities?.collections?.find((visibleSlug) => visibleSlug === collectionSlug)) {
notFound()
throw new Error('not-found')
}
const params = new URLSearchParams()
@@ -122,12 +191,7 @@ export const Document: React.FC<AdminViewProps> = async ({
RootViewOverride =
collectionConfig?.admin?.components?.views?.edit?.root &&
'Component' in collectionConfig.admin.components.views.edit.root
? createMappedComponent(
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',
)
? collectionConfig?.admin?.components?.views?.edit?.root?.Component
: null
if (!RootViewOverride) {
@@ -138,36 +202,21 @@ export const Document: React.FC<AdminViewProps> = async ({
routeSegments: segments,
})
CustomView = createMappedComponent(
collectionViews?.CustomView?.payloadComponent,
undefined,
collectionViews?.CustomView?.Component,
'collectionViews?.CustomView.payloadComponent',
)
DefaultView = createMappedComponent(
collectionViews?.DefaultView?.payloadComponent,
undefined,
collectionViews?.DefaultView?.Component,
'collectionViews?.DefaultView.payloadComponent',
)
ErrorView = createMappedComponent(
collectionViews?.ErrorView?.payloadComponent,
undefined,
collectionViews?.ErrorView?.Component,
'collectionViews?.ErrorView.payloadComponent',
)
CustomView = collectionViews?.CustomView
DefaultView = collectionViews?.DefaultView
ErrorView = collectionViews?.ErrorView
}
if (!CustomView && !DefaultView && !RootViewOverride && !ErrorView) {
ErrorView = createMappedComponent(undefined, undefined, NotFoundView, 'NotFoundView')
ErrorView = {
Component: NotFoundView,
}
}
}
if (globalConfig) {
if (!visibleEntities?.globals?.find((visibleSlug) => visibleSlug === globalSlug)) {
notFound()
throw new Error('not-found')
}
const params = new URLSearchParams({
@@ -189,12 +238,7 @@ export const Document: React.FC<AdminViewProps> = async ({
RootViewOverride =
globalConfig?.admin?.components?.views?.edit?.root &&
'Component' in globalConfig.admin.components.views.edit.root
? createMappedComponent(
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',
)
? globalConfig?.admin?.components?.views?.edit?.root?.Component
: null
if (!RootViewOverride) {
@@ -205,29 +249,14 @@ export const Document: React.FC<AdminViewProps> = async ({
routeSegments: segments,
})
CustomView = createMappedComponent(
globalViews?.CustomView?.payloadComponent,
undefined,
globalViews?.CustomView?.Component,
'globalViews?.CustomView.payloadComponent',
)
DefaultView = createMappedComponent(
globalViews?.DefaultView?.payloadComponent,
undefined,
globalViews?.DefaultView?.Component,
'globalViews?.DefaultView.payloadComponent',
)
ErrorView = createMappedComponent(
globalViews?.ErrorView?.payloadComponent,
undefined,
globalViews?.ErrorView?.Component,
'globalViews?.ErrorView.payloadComponent',
)
CustomView = globalViews?.CustomView
DefaultView = globalViews?.DefaultView
ErrorView = globalViews?.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 &&
((collectionConfig?.versions?.drafts && collectionConfig?.versions?.drafts?.autosave) ||
(globalConfig?.versions?.drafts && globalConfig?.versions?.drafts?.autosave))
const validateDraftData =
collectionConfig?.versions?.drafts && collectionConfig?.versions?.drafts?.validate
if (shouldAutosave && !validateDraftData && !id && collectionSlug) {
const doc = await payload.create({
collection: collectionSlug,
data: {},
data: initialData || {},
depth: 0,
draft: true,
fallbackLocale: null,
@@ -263,26 +293,47 @@ export const Document: React.FC<AdminViewProps> = async ({
})
redirect(redirectURL)
} else {
notFound()
throw new Error('not-found')
}
}
return (
const documentSlots = renderDocumentSlots({
collectionConfig,
globalConfig,
hasSavePermission,
permissions: docPermissions,
req,
})
const clientProps = { formState, ...documentSlots }
return {
data: doc,
Document: (
<DocumentInfoProvider
apiURL={apiURL}
collectionSlug={collectionConfig?.slug}
disableActions={false}
currentEditor={currentEditor}
disableActions={disableActions ?? false}
docPermissions={docPermissions}
globalSlug={globalConfig?.slug}
hasPublishedDoc={hasPublishedDoc}
hasPublishPermission={hasPublishPermission}
hasSavePermission={hasSavePermission}
id={id}
initialData={data}
initialData={doc}
initialState={formState}
isEditing={isEditing}
isLocked={isLocked}
key={locale?.code}
lastUpdateTime={lastUpdateTime}
mostRecentVersionIsAutosaved={mostRecentVersionIsAutosaved}
redirectAfterDelete={redirectAfterDelete}
redirectAfterDuplicate={redirectAfterDuplicate}
unpublishedVersionCount={unpublishedVersionCount}
versionCount={versionCount}
>
{!RootViewOverride && (
{!RootViewOverride && !drawerSlug && (
<DocumentHeader
collectionConfig={collectionConfig}
globalConfig={globalConfig}
@@ -292,28 +343,46 @@ export const Document: React.FC<AdminViewProps> = async ({
/>
)}
<HydrateAuthProvider permissions={permissions} />
{/**
* 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).
* If both HydrateClientUser and RenderCustomComponent receive the same permissions object (same object reference), we get a
* "TypeError: Cannot read properties of undefined (reading '$$typeof')" error when loading up some version views - for example a versions
* view in the draft-posts collection of the versions test suite. RenderCustomComponent is what renders the versions view.
*
* // 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)
*/}
<EditDepthProvider
depth={1}
key={`${collectionSlug || globalSlug}${locale?.code ? `-${locale?.code}` : ''}`}
>
<EditDepthProvider>
{ErrorView ? (
<RenderComponent mappedComponent={ErrorView} />
<RenderServerComponent
clientProps={clientProps}
Component={ErrorView.ComponentConfig || ErrorView.Component}
importMap={importMap}
serverProps={serverProps}
/>
) : (
<RenderComponent
mappedComponent={
RootViewOverride ? RootViewOverride : CustomView ? CustomView : DefaultView
<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 { EditViewClient } from './index.client.js'
export const EditView: PayloadServerReactComponent<EditViewComponent> = () => {
return <EditViewClient />
export const EditView: React.FC<ClientSideEditViewProps> = (props) => {
return <DefaultEditView {...props} />
}

View File

@@ -105,9 +105,11 @@ export const ForgotPasswordForm: React.FC = () => {
/>
) : (
<EmailField
autoComplete="email"
field={{
name: 'email',
admin: {
autoComplete: 'email',
},
label: t('general:email'),
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 {
HydrateAuthProvider,
ListInfoProvider,
ListQueryProvider,
TableColumnsProvider,
} from '@payloadcms/ui'
import { formatAdminURL, getCreateMappedComponent, RenderComponent } from '@payloadcms/ui/shared'
import { createClientCollectionConfig } from '@payloadcms/ui/utilities/createClientConfig'
import { DefaultListView, HydrateAuthProvider, ListQueryProvider } from '@payloadcms/ui'
import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
import { renderFilters, renderTable } from '@payloadcms/ui/rsc'
import { formatAdminURL, mergeListSearchAndWhere } from '@payloadcms/ui/shared'
import { notFound } from 'next/navigation.js'
import { deepCopyObjectSimple, mergeListSearchAndWhere } from 'payload'
import { isNumber } from 'payload/shared'
import React, { Fragment } from 'react'
import type { ListPreferences } from './Default/types.js'
import { DefaultEditView } from '../Edit/Default/index.js'
import { DefaultListView } from './Default/index.js'
import { renderListViewSlots } from './renderListViewSlots.js'
export { generateListMetadata } from './meta.js'
export const ListView: React.FC<AdminViewProps> = async ({
type ListViewArgs = {
customCellProps?: Record<string, any>
disableBulkDelete?: boolean
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 {
collectionConfig,
collectionConfig: {
slug: collectionSlug,
admin: { useAsTitle },
defaultSort,
fields,
},
locale: fullLocale,
permissions,
req,
@@ -35,18 +55,18 @@ export const ListView: React.FC<AdminViewProps> = async ({
locale,
payload,
payload: { config },
query,
query: queryFromReq,
user,
},
visibleEntities,
} = initPageResult
const collectionSlug = collectionConfig?.slug
if (!permissions?.collections?.[collectionSlug]?.read?.permission) {
notFound()
throw new Error('not-found')
}
const query = queryFromArgs || queryFromReq
let listPreferences: ListPreferences
const preferenceKey = `${collectionSlug}-list`
@@ -79,7 +99,7 @@ export const ListView: React.FC<AdminViewProps> = async ({
},
})
?.then((res) => res?.docs?.[0]?.value)) as ListPreferences
} catch (error) {} // eslint-disable-line no-empty
} catch (_err) {} // eslint-disable-line no-empty
const {
routes: { admin: adminRoute },
@@ -87,20 +107,21 @@ export const ListView: React.FC<AdminViewProps> = async ({
if (collectionConfig) {
if (!visibleEntities.collections.includes(collectionSlug)) {
return notFound()
throw new Error('not-found')
}
const page = isNumber(query?.page) ? Number(query.page) : 0
const whereQuery = mergeListSearchAndWhere({
collectionConfig,
query: {
search: typeof query?.search === 'string' ? query.search : undefined,
where: (query?.where as Where) || undefined,
},
})
const limit = isNumber(query?.limit)
? Number(query.limit)
: listPreferences?.limit || collectionConfig.admin.pagination.defaultLimit
const sort =
query?.sort && typeof query.sort === 'string'
? query.sort
@@ -125,89 +146,104 @@ export const ListView: React.FC<AdminViewProps> = async ({
where: whereQuery || {},
})
const createMappedComponent = getCreateMappedComponent({
importMap: payload.importMap,
serverProps: {
const clientCollectionConfig = clientConfig.collections.find((c) => c.slug === collectionSlug)
const { columnState, Table } = renderTable({
collectionConfig: clientCollectionConfig,
columnPreferences: listPreferences?.columns,
customCellProps,
docs: data.docs,
drawerSlug,
enableRowSelections,
fields,
i18n: req.i18n,
payload,
useAsTitle,
})
const renderedFilters = renderFilters(fields, req.payload.importMap)
const staticDescription =
typeof collectionConfig.admin.description === 'function'
? collectionConfig.admin.description({ t: i18n.t })
: collectionConfig.admin.description
const listViewSlots = renderListViewSlots({
collectionConfig,
description: staticDescription,
payload,
})
const clientProps: ListViewClientProps = {
...listViewSlots,
collectionSlug,
columnState,
disableBulkDelete,
disableBulkEdit,
enableRowSelections,
hasCreatePermission: permissions?.collections?.[collectionSlug]?.create?.permission,
listPreferences,
newDocumentURL: formatAdminURL({
adminRoute,
path: `/collections/${collectionSlug}/create`,
}),
renderedFilters,
Table,
}
const isInDrawer = Boolean(drawerSlug)
return {
List: (
<Fragment>
<HydrateAuthProvider permissions={permissions} />
<ListQueryProvider
collectionSlug={collectionSlug}
data={data}
defaultLimit={limit}
defaultSort={sort}
modifySearchParams={!isInDrawer}
preferenceKey={preferenceKey}
>
<RenderServerComponent
clientProps={clientProps}
Component={collectionConfig?.admin?.components?.views?.list?.Component}
Fallback={DefaultListView}
importMap={payload.importMap}
serverProps={{
collectionConfig,
collectionSlug,
data,
hasCreatePermission: permissions?.collections?.[collectionSlug]?.create?.permission,
i18n,
limit,
listPreferences,
listSearchableFields: collectionConfig.admin.listSearchableFields,
locale: fullLocale,
newDocumentURL: formatAdminURL({
adminRoute,
path: `/collections/${collectionSlug}/create`,
}),
params,
payload,
permissions,
searchParams,
user,
},
})
const ListComponent = createMappedComponent(
collectionConfig?.admin?.components?.views?.list?.Component,
undefined,
DefaultListView,
'collectionConfig?.admin?.components?.views?.list?.Component',
)
let clientCollectionConfig = deepCopyObjectSimple(
collectionConfig,
) as unknown as ClientCollectionConfig
clientCollectionConfig = createClientCollectionConfig({
clientCollection: clientCollectionConfig,
collection: collectionConfig,
createMappedComponent,
DefaultEditView,
DefaultListView,
i18n,
importMap: payload.importMap,
payload,
})
return (
<Fragment>
<HydrateAuthProvider permissions={permissions} />
<ListInfoProvider
collectionConfig={clientCollectionConfig}
collectionSlug={collectionSlug}
hasCreatePermission={permissions?.collections?.[collectionSlug]?.create?.permission}
newDocumentURL={formatAdminURL({
adminRoute,
path: `/collections/${collectionSlug}/create`,
})}
>
<ListQueryProvider
data={data}
defaultLimit={limit || collectionConfig?.admin?.pagination?.defaultLimit}
defaultSort={sort}
modifySearchParams
preferenceKey={preferenceKey}
>
<TableColumnsProvider
collectionSlug={collectionSlug}
enableRowSelections
listPreferences={listPreferences}
preferenceKey={preferenceKey}
>
<RenderComponent
clientProps={{
collectionSlug,
listSearchableFields: collectionConfig?.admin?.listSearchableFields,
}}
mappedComponent={ListComponent}
/>
</TableColumnsProvider>
</ListQueryProvider>
</ListInfoProvider>
</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 {
DocumentControls,
DocumentFields,
DocumentLocked,
DocumentTakeOver,
Form,
LeaveWithoutSaving,
OperationProvider,
SetViewActions,
SetDocumentStepNav,
SetDocumentTitle,
useAuth,
useConfig,
useDocumentDrawerContext,
useDocumentEvents,
useDocumentInfo,
useServerFunctions,
useTranslation,
} from '@payloadcms/ui'
import {
getFormState,
handleBackToDashboard,
handleGoBack,
handleTakeOver,
} from '@payloadcms/ui/shared'
import { handleBackToDashboard, handleGoBack, handleTakeOver } from '@payloadcms/ui/shared'
import { useRouter } from 'next/navigation.js'
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 { LivePreviewProvider } from './Context/index.js'
import './index.scss'
@@ -55,13 +51,11 @@ type Props = {
}
const PreviewView: React.FC<Props> = ({
apiRoute,
collectionConfig,
config,
fields,
globalConfig,
schemaPath,
serverURL,
}) => {
const {
id,
@@ -69,7 +63,6 @@ const PreviewView: React.FC<Props> = ({
AfterDocument,
AfterFields,
apiURL,
BeforeDocument,
BeforeFields,
collectionSlug,
currentEditor,
@@ -86,13 +79,16 @@ const PreviewView: React.FC<Props> = ({
isEditing,
isInitializing,
lastUpdateTime,
onSave: onSaveFromProps,
setCurrentEditor,
setDocumentIsLocked,
unlockDocument,
updateDocumentEditor,
} = useDocumentInfo()
const { getFormState } = useServerFunctions()
const { onSave: onSaveFromProps } = useDocumentDrawerContext()
const operation = id ? 'update' : 'create'
const {
@@ -120,6 +116,8 @@ const PreviewView: React.FC<Props> = ({
const [isReadOnlyForIncomingUser, setIsReadOnlyForIncomingUser] = useState(false)
const [showTakeOverModal, setShowTakeOverModal] = useState(false)
const abortControllerRef = useRef(new AbortController())
const [editSessionStartTime, setEditSessionStartTime] = useState(Date.now())
const lockExpiryTime = lastUpdateTime + lockDurationInMilliseconds
@@ -178,6 +176,17 @@ const PreviewView: React.FC<Props> = ({
const onChange: FormProps['onChange'][0] = useCallback(
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 timeSinceLastUpdate = currentTime - editSessionStartTime
@@ -190,19 +199,17 @@ const PreviewView: React.FC<Props> = ({
const docPreferences = await getDocPreferences()
const { lockedState, state } = await getFormState({
apiRoute,
body: {
id,
collectionSlug,
docPermissions,
docPreferences,
formState: prevFormState,
globalSlug,
operation,
returnLockStatus: isLockingEnabled ? true : false,
schemaPath,
signal: abortController.signal,
updateLastEdited,
},
serverURL,
})
setDocumentIsLocked(true)
@@ -214,8 +221,13 @@ const PreviewView: React.FC<Props> = ({
: documentLockStateRef.current?.user
if (lockedState) {
if (!documentLockStateRef.current || lockedState.user.id !== previousOwnerId) {
if (previousOwnerId === user.id && lockedState.user.id !== user.id) {
const lockedUserID =
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)
documentLockStateRef.current.hasShownLockedModal = true
}
@@ -223,9 +235,10 @@ const PreviewView: React.FC<Props> = ({
documentLockStateRef.current = documentLockStateRef.current = {
hasShownLockedModal: documentLockStateRef.current?.hasShownLockedModal || false,
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
},
[
collectionSlug,
editSessionStartTime,
globalSlug,
serverURL,
apiRoute,
id,
isLockingEnabled,
getDocPreferences,
getFormState,
id,
collectionSlug,
docPermissions,
globalSlug,
operation,
schemaPath,
getDocPreferences,
setCurrentEditor,
setDocumentIsLocked,
user,
user.id,
setCurrentEditor,
],
)
// Clean up when the component unmounts or when the document is unlocked
useEffect(() => {
return () => {
if (abortControllerRef.current) {
try {
abortControllerRef.current.abort()
} catch (_err) {
// swallow error
}
}
if (!isLockingEnabled) {
return
}
@@ -415,7 +436,6 @@ const PreviewView: React.FC<Props> = ({
.filter(Boolean)
.join(' ')}
>
{BeforeDocument}
<DocumentFields
AfterFields={AfterFields}
BeforeFields={BeforeFields}
@@ -423,7 +443,7 @@ const PreviewView: React.FC<Props> = ({
fields={fields}
forceSidebarWrap
readOnly={isReadOnlyForIncomingUser || !hasSavePermission}
schemaPath={collectionSlug || globalSlug}
schemaPathSegments={[collectionSlug || globalSlug]}
/>
{AfterDocument}
</div>
@@ -464,11 +484,6 @@ export const LivePreviewClient: React.FC<{
return (
<Fragment>
<SetViewActions
actions={
(collectionConfig || globalConfig)?.admin?.components?.views?.edit?.livePreview?.actions
}
/>
<LivePreviewProvider
breakpoints={breakpoints}
fieldSchema={collectionConfig?.fields || globalConfig?.fields}

View File

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

View File

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

View File

@@ -75,13 +75,26 @@ export const ResetPasswordForm: React.FC<Args> = ({ token }) => {
label: i18n.t('authentication:newPassword'),
required: true,
}}
indexPath=""
parentPath=""
parentSchemaPath=""
path="password"
schemaPath={`${userSlug}.password`}
/>
<ConfirmPasswordField />
<HiddenField
field={{
name: 'token',
type: 'text',
admin: {
hidden: true,
},
}}
forceUsePathFromProps
indexPath=""
parentPath={userSlug}
parentSchemaPath={userSlug}
path="token"
schemaPath={`${userSlug}.token`}
value={token}
/>
</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 { formatAdminURL } from '@payloadcms/ui/shared'
@@ -46,6 +53,20 @@ const oneSegmentViews: OneSegmentViews = {
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 = ({
adminRoute,
config,
@@ -65,8 +86,10 @@ export const getViewFromConfig = ({
}): {
DefaultView: ViewFromConfig
initPageOptions: Parameters<typeof initPage>[0]
serverProps: Record<string, unknown>
templateClassName: string
templateType: 'default' | 'minimal'
viewActions?: CustomComponent[]
} => {
let ViewToRender: ViewFromConfig = null
let templateClassName: string
@@ -79,10 +102,30 @@ export const getViewFromConfig = ({
searchParams,
}
const [segmentOne, segmentTwo] = segments
let viewActions: CustomComponent[] = config?.admin?.components?.actions || []
const [segmentOne, segmentTwo, segmentThree, segmentFour, segmentFive] = segments
const isGlobal = segmentOne === 'globals'
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) {
case 0: {
@@ -146,7 +189,7 @@ export const getViewFromConfig = ({
templateType = 'minimal'
}
if (isCollection) {
if (isCollection && matchedCollection) {
// --> /collections/:collectionSlug
ViewToRender = {
@@ -155,7 +198,8 @@ export const getViewFromConfig = ({
templateClassName = `${segmentTwo}-list`
templateType = 'default'
} else if (isGlobal) {
viewActions = viewActions.concat(matchedCollection.admin.components?.views?.list?.actions)
} else if (isGlobal && matchedGlobal) {
// --> /globals/:globalSlug
ViewToRender = {
@@ -164,6 +208,14 @@ export const getViewFromConfig = ({
templateClassName = 'global-edit'
templateType = 'default'
// add default view actions
viewActions = viewActions.concat(
getViewActions({
editConfig: matchedGlobal.admin?.components?.views?.edit,
viewKey: 'default',
}),
)
}
break
}
@@ -176,13 +228,13 @@ export const getViewFromConfig = ({
templateClassName = 'verify'
templateType = 'minimal'
} else if (isCollection) {
} else if (isCollection && matchedCollection) {
// Custom Views
// --> /collections/:collectionSlug/:id
// --> /collections/:collectionSlug/:id/api
// --> /collections/:collectionSlug/:id/preview
// --> /collections/:collectionSlug/:id/versions
// --> /collections/:collectionSlug/:id/versions/:versionId
// --> /collections/:collectionSlug/:id/api
ViewToRender = {
Component: DocumentView,
@@ -190,7 +242,65 @@ export const getViewFromConfig = ({
templateClassName = `collection-default-edit`
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
// --> /globals/:globalSlug/versions
// --> /globals/:globalSlug/preview
@@ -203,6 +313,56 @@ export const getViewFromConfig = ({
templateClassName = `global-edit`
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
}
@@ -214,7 +374,9 @@ export const getViewFromConfig = ({
return {
DefaultView: ViewToRender,
initPageOptions,
serverProps,
templateClassName,
templateType,
viewActions: viewActions.reverse(),
}
}

View File

@@ -1,13 +1,15 @@
import type { I18nClient } from '@payloadcms/translations'
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 React, { Fragment } from 'react'
import { DefaultTemplate } from '../../templates/Default/index.js'
import { MinimalTemplate } from '../../templates/Minimal/index.js'
import { getClientConfig } from '../../utilities/getClientConfig.js'
import { initPage } from '../../utilities/initPage/index.js'
import { getViewFromConfig } from './getViewFromConfig.js'
@@ -55,7 +57,14 @@ export const RootPage = async ({
const searchParams = await searchParamsPromise
const { DefaultView, initPageOptions, templateClassName, templateType } = getViewFromConfig({
const {
DefaultView,
initPageOptions,
serverProps,
templateClassName,
templateType,
viewActions,
} = getViewFromConfig({
adminRoute,
config,
currentRoute,
@@ -64,21 +73,22 @@ export const RootPage = async ({
segments,
})
let dbHasUser = false
const initPageResult = await initPage(initPageOptions)
dbHasUser = await initPageResult?.req.payload.db
const dbHasUser =
initPageResult.req.user ||
(await initPageResult?.req.payload.db
.findOne({
collection: userSlug,
req: initPageResult?.req,
})
?.then((doc) => !!doc)
?.then((doc) => !!doc))
if (!DefaultView?.Component && !DefaultView?.payloadComponent) {
if (initPageResult?.req?.user) {
notFound()
}
if (dbHasUser) {
redirect(adminRoute)
}
@@ -111,27 +121,30 @@ export const RootPage = async ({
redirect(adminRoute)
}
const createMappedView = getCreateMappedComponent({
importMap,
serverProps: {
const clientConfig = await getClientConfig({
config,
i18n: initPageResult?.req.i18n,
})
const RenderedView = (
<RenderServerComponent
clientProps={{ clientConfig }}
Component={DefaultView.payloadComponent}
Fallback={DefaultView.Component}
importMap={importMap}
serverProps={{
...serverProps,
clientConfig,
i18n: initPageResult?.req.i18n,
importMap,
initPageResult,
params,
payload: initPageResult?.req.payload,
searchParams,
},
})
const MappedView: MappedComponent = createMappedView(
DefaultView.payloadComponent,
undefined,
DefaultView.Component,
'createMappedView',
}}
/>
)
const RenderedView = <RenderComponent mappedComponent={MappedView} />
return (
<Fragment>
{!templateType && <Fragment>{RenderedView}</Fragment>}
@@ -147,6 +160,7 @@ export const RootPage = async ({
permissions={initPageResult?.permissions}
searchParams={searchParams}
user={initPageResult?.req.user}
viewActions={viewActions}
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
// which this caused as soon as initPageResult.visibleEntities is passed in

View File

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

View File

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

View File

@@ -1,10 +1,17 @@
'use client'
import { Pill, useConfig, useTableCell, useTranslation } from '@payloadcms/ui'
import { Pill, useConfig, useTranslation } from '@payloadcms/ui'
import React, { Fragment } from 'react'
type AutosaveCellProps = {
latestDraftVersion?: string
latestPublishedVersion?: string
rowData?: {
autosave?: boolean
publishedLocale?: string
version: {
_status?: string
}
}
}
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> = ({
latestDraftVersion,
latestPublishedVersion,
rowData = { autosave: undefined, publishedLocale: undefined, version: undefined },
}) => {
const { i18n, t } = useTranslation()
const { rowData } = useTableCell()
const {
config: { localization },
} = useConfig()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,8 +4,8 @@ import type { JSONSchema4 } from 'json-schema'
import type { ImportMap } from '../bin/generateImportMap/index.js'
import type { SanitizedCollectionConfig, TypeWithID } from '../collections/config/types.js'
import type { Config, PayloadComponent, SanitizedConfig } from '../config/types.js'
import type { ValidationFieldError } from '../errors/ValidationError.js'
import type {
Field,
FieldAffectingData,
RichTextField,
RichTextFieldClient,
@@ -15,7 +15,7 @@ import type { SanitizedGlobalConfig } from '../globals/config/types.js'
import type { RequestContext } from '../index.js'
import type { JsonObject, Payload, PayloadRequest, PopulateType } from '../types/index.js'
import type { RichTextFieldClientProps } from './fields/RichText.js'
import type { CreateMappedComponent } from './types.js'
import type { FieldSchemaMap } from './types.js'
export type AfterReadRichTextHookArgs<
TData extends TypeWithID = any,
@@ -91,7 +91,7 @@ export type BeforeChangeRichTextHookArgs<
duplicate?: boolean
errors?: { field: string; message: string }[]
errors?: ValidationFieldError[]
/** Only available in `beforeChange` field hooks */
mergeLocaleActions?: (() => Promise<void>)[]
/** A string relating to which operation the field type is currently executing within. */
@@ -184,32 +184,19 @@ export type RichTextHooks = {
beforeChange?: BeforeChangeRichTextHook[]
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<
Value extends object = object,
AdapterProps = any,
ExtraFieldProperties = {},
> = {
generateComponentMap: PayloadComponent<any, never>
generateImportMap?: Config['admin']['importMap']['generators'][0]
generateSchemaMap?: (args: {
config: SanitizedConfig
field: RichTextField
i18n: I18n<any, any>
schemaMap: Map<string, Field[]>
schemaMap: FieldSchemaMap
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.
*

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
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 { FieldErrorClientComponent, FieldErrorServerComponent } from '../forms/Error.js'
import type {
@@ -19,8 +19,9 @@ import type {
type BlocksFieldClientWithoutType = MarkOptional<BlocksFieldClient, 'type'>
type BlocksFieldBaseClientProps = {
readonly path?: string
readonly validate?: BlocksFieldValidation
}
} & Pick<ServerFieldBase, 'permissions'>
export type BlocksFieldClientProps = BlocksFieldBaseClientProps &
ClientFieldBase<BlocksFieldClientWithoutType>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,7 +17,12 @@ import 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>
@@ -26,7 +31,10 @@ export type GroupFieldServerComponent = FieldServerComponent<
GroupFieldClientWithoutType
>
export type GroupFieldClientComponent = FieldClientComponent<GroupFieldClientWithoutType>
export type GroupFieldClientComponent = FieldClientComponent<
GroupFieldClientWithoutType,
GroupFieldBaseClientProps
>
export type GroupFieldLabelServerComponent = FieldLabelServerComponent<
GroupField,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,13 +16,18 @@ import type {
FieldLabelServerComponent,
} 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<
TValue extends object = any,
TAdapterProps = any,
TExtraProperties = object,
> = {
readonly path?: string
readonly validate?: RichTextFieldValidation
}
@@ -30,7 +35,7 @@ export type RichTextFieldClientProps<
TValue extends object = any,
TAdapterProps = any,
TExtraProperties = object,
> = ClientFieldBase<RichTextFieldClientWithoutType> &
> = ClientFieldBase<RichTextFieldClientWithoutType<TValue, TAdapterProps, TExtraProperties>> &
RichTextFieldBaseClientProps<TValue, TAdapterProps, TExtraProperties>
export type RichTextFieldServerProps = ServerFieldBase<

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