Compare commits

..

25 Commits

Author SHA1 Message Date
Elliot DeNolf
a8d88b8238 chore(release): v3.0.0-beta.68 [skip ci] 2024-07-22 16:05:04 -04:00
Jarrod Flesch
5aa3283dc0 fix: search plugin localized fields (#7292) 2024-07-22 15:47:39 -04:00
Alessio Gravili
45844789f2 feat: upgrade pino and pino-pretty, clean up hacky esm imports (#7291)
Doesn't look like those hacky esm-cjs imports are needed anymore.

These major pino releases only drop Node.js version support for versions
which payload doesn't support anyways.
2024-07-22 15:11:34 -04:00
Alessio Gravili
79975f48cf chore: ensure the correct next & react versions are installed in templates and core (#7283)
Some package.json's were on older or mismatching Next.js / React
versions. This includes other Next.js packages like @next/env
2024-07-22 18:33:54 +00:00
Paul
bba7cf37f8 fix(templates): website template building error with postgres number IDs (#7281) 2024-07-22 17:40:58 +00:00
Paul
1ae71a3d24 fix(ui): not updating permissions when locale changes (#7245)
Closes https://github.com/payloadcms/payload/issues/7163
2024-07-22 17:31:20 +00:00
Alessio Gravili
e83eb99436 feat: remove joi schema validation (#7226)
We do not really need runtime joi schema validation - this is what TypeScript is for. If people are ignoring TypeScript errors in your schema, or JavaScript errors, that is their fault and does not warrant an extra dependency (joi), lots of code to maintain, as well as slower startups.

If we wanna keep runtime schema validation, we should switch to zod so that we can generate TypeScript types based on the schema and do not have to manually maintain config properties in 2 different places (types & schema).

**joi PROs:**
- Safety for JavaScript-only evangelists messing up their schema
- Safety for people putting @ts-expect-error or `as any` everywhere in their code

**joi CONs:**
- Larger bundle size
- More Modules
- Slower Compilation Speed in dev. Worse DX
- Slower Startup (it needs to validate) in dev. Worse DX
- More code to maintain. For every schema change we'll have to change the types AND the joi schema
- TypeScript already throws proper errors if you mess up your schema. Why have runtime errors?
- The errors are bad. They might tell you what field has an issue, but they do not tell you what exactly is wrong. You have probably seen those "Field XY, value is incorrect" errors - and value could mean anything. Worse DX
- Having extra properties in your schema, even if they are useless, doesn't cause any harm

Cons outweigh the pros
2024-07-22 13:22:54 -04:00
Dan Ribbens
f50e599684 chore: add index to status for versions (#6257) 2024-07-22 13:13:09 -04:00
Dan Ribbens
7dab75d85e chore: make ui bundle script windows compatible (#7197) 2024-07-22 13:10:07 -04:00
Alessio Gravili
c45fbb9149 feat!: 700% faster deepCopyObject, refactor deep merging and deep copying, type improvements (#7272)
**BREAKING:**
- The `deepMerge` exported from payload now handles more complex data and
is slower. The old, simple deepMerge is now exported as `deepMergeSimple`
- `combineMerge` is no longer exported. You can use
`deepMergeWithCombinedArrays` instead
- The behavior of the exported `deepCopyObject` and `isPlainObject` may
be different and more reliable, as the underlying algorithm has changed
2024-07-22 13:01:52 -04:00
Patrik
2c16c608ba fix(payload): resizes images first before applying focal point (#7277)
Fixes #7275
2024-07-22 12:28:35 -04:00
Paul
c3f6c81dc6 chore: add custom ID warning about forbidden characters (#7268) 2024-07-22 02:23:53 +00:00
Alessio Gravili
a7b0f8ba36 feat!: new server-only, faster and immediate autoLogin (#7224)
- When autoLogin is enabled, it will no longer flash an unresponsive
"login" screen. Instead, it will straight up open the admin panel.
That's because, on the server, we will now always & immediately see the
user as authenticated, thus no initial login view is pushed to the
client until the client component sends the auth request anymore. Less
useless requests. Additionally, jwt verification is now completely
skipped
- No more auto-login related frontend code. autoLogin handling has been
removed from the frontend `Auth` component
- less code to maintain, this is way simpler now

**For reviewers:**
- The new logic for autoFill without prefillOnly is here: [jwt auth
strategy](https://github.com/payloadcms/payload/pull/7224/files#diff-7d40839079a8b2abb58233e5904513ab321023a70538229dfaf1dfee067dc8bfR21)
- The new logic for autoFill with prefillOnly is here: [Server Login
View](https://github.com/payloadcms/payload/pull/7224/files#diff-683770104f196196743398a698fbf8987f00e4426ca1c0ace3658d18ab80e82dL72)
=> [Client Login
Form](https://github.com/payloadcms/payload/pull/7224/files#diff-ac3504d3b3b0489455245663649bef9e84477bf0c1185da5a4d3a612450f01eeL20)

**BREAKING**
`autoLogin` without `prefillOnly` set now also affects graphQL/Rest
operations. Only the user specified in `autoLogin` will be returned.
Within the graphQL/Rest/Local API, this should still allow you to
authenticate with a different user, as the autoLogin user is only used
if no token is set.
2024-07-20 23:25:50 +00:00
Paul
014ee1a1b2 feat(ui): change autosave logic to send updates as soon as possible, improving live preview speed (#7201)
Now has a minimum animation time for the autosave but it fires off the
send events sooner to improve the live preview timing.
2024-07-19 15:24:53 -04:00
Patrik
cf6da0186b chore(translations, ui): updates addImage translation to addFile translation (#7231) 2024-07-19 13:36:37 -04:00
Jacob Fletcher
18063bd256 chore(examples): proper module resolution and migrations for multi-tenant single-domain example (#7240) 2024-07-19 13:18:13 -04:00
Paul
76b3075369 feat: update reserved fields name check to be more comprehensive and only check top level fields (#7235)
Continuation of https://github.com/payloadcms/payload/pull/7179
2024-07-19 15:53:00 +00:00
Jacob Fletcher
3d63ce94bb fix: api errors not populating in prod (#7232) 2024-07-19 11:42:53 -04:00
Paul
f8a5103ed7 chore(docs): update rest API handler to async (#7237)
Closes https://github.com/payloadcms/payload/issues/7077
2024-07-19 15:41:10 +00:00
Paul
2bd53a06eb chore(templates): add react cache to queryPostBySlug in website template (#7219) 2024-07-18 18:20:05 +00:00
Elliot DeNolf
442518dbc9 ci: make release script synchronous to ensure consistency 2024-07-18 14:09:36 -04:00
Elliot DeNolf
d3131122db chore(release): v3.0.0-beta.67 [skip ci] 2024-07-18 14:00:49 -04:00
Alessio Gravili
6d0dfeafc8 chore: ensure fs operations in bundle scripts finish in sync (#7218)
Hopefully fixes broken releases
2024-07-18 13:44:26 -04:00
Patrik
00771b1f2a fix(ui): uploading from drawer & focal point positioning (#7117)
Fixes #7101
Fixes #7006

Drawers were sending duplicate query params. This new approach modeled after the fix in V2, ensures that each drawer has its own action url created per document and the query params will be created when that is generated.

Also fixes the following:
- incorrect focal point cropping
- generated filenames for animated image names used incorrect heights
2024-07-18 13:43:53 -04:00
Jarrod Flesch
448186f374 chore: use href for locale switching, warns user before leaving (#7215)
Opts to use links instead of router.replace when switching locales. The
main benefit is now the user will be warned if they have changes and
want to switch locales. Before it would switch locales and they would
lose any unsaved changes in the locale they came from.
2024-07-18 12:59:27 -04:00
300 changed files with 3347 additions and 23665 deletions

View File

@@ -86,19 +86,19 @@ const config = buildConfig({
The following options are available:
| Option | Description |
| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `autoLogin` | Used to automate admin log-in for dev and demonstration convenience. [More details](../authentication/overview). |
| `avatar` | Set account profile picture. Options: `gravatar`, `default` or a custom React component. |
| `buildPath` | Specify an absolute path for where to store the built Admin bundle used in production. Defaults to `path.resolve(process.cwd(), 'build')`. |
| `components` | Component overrides that affect the entirety of the Admin Panel. [More details](./components). |
| `custom` | Any custom properties you wish to pass to the Admin Panel. |
| `dateFormat` | The date format that will be used for all dates within the Admin Panel. Any valid [date-fns](https://date-fns.org/) format pattern can be used. |
| `disable` | If set to `true`, the entire Admin Panel will be disabled. |
| `livePreview` | Enable real-time editing for instant visual feedback of your front-end application. [More details](../live-preview/overview). |
| `meta` | Base metadata to use for the Admin Panel. Included properties are `titleSuffix`, `icons`, and `openGraph`. Can be overridden on a per Collection or per Global basis. |
| `routes` | Replace built-in Admin Panel routes with your own custom routes. [More details](#customizing-routes). |
| `user` | The `slug` of the Collection that you want to allow to login to the Admin Panel. [More details](#the-admin-user-collection). |
| Option | Description |
|---------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `avatar` | Set account profile picture. Options: `gravatar`, `default` or a custom React component. |
| `autoLogin` | Used to automate log-in for dev and demonstration convenience. [More details](../authentication/overview). |
| `buildPath` | Specify an absolute path for where to store the built Admin bundle used in production. Defaults to `path.resolve(process.cwd(), 'build')`. |
| `components` | Component overrides that affect the entirety of the Admin Panel. [More details](./components). |
| `custom` | Any custom properties you wish to pass to the Admin Panel. |
| `dateFormat` | The date format that will be used for all dates within the Admin Panel. Any valid [date-fns](https://date-fns.org/) format pattern can be used. |
| `disable` | If set to `true`, the entire Admin Panel will be disabled. |
| `livePreview` | Enable real-time editing for instant visual feedback of your front-end application. [More details](../live-preview/overview). |
| `meta` | Base metadata to use for the Admin Panel. Included properties are `titleSuffix`, `icons`, and `openGraph`. Can be overridden on a per Collection or per Global basis. |
| `routes` | Replace built-in Admin Panel routes with your own custom routes. [More details](#customizing-routes). |
| `user` | The `slug` of the Collection that you want to allow to login to the Admin Panel. [More details](#the-admin-user-collection). |
<Banner type="success">
<strong>Reminder:</strong>

View File

@@ -127,30 +127,27 @@ If set to `true`, users can log in with either their username or email address.
If set to `true`, an email address is required when creating a new user. If set to `false`, email is not required upon creation.
## Admin Auto-Login
## Auto-Login
For testing and demo purposes you may want to skip forcing the admin user to login in order to access the [Admin Panel](../admin/overview). Typically, all users should be required to login to access the Admin Panel, however, you can speed up local development time by enabling auto-login.
For testing and demo purposes you may want to skip forcing the user to login in order to access your application. Typically, all users should be required to login, however, you can speed up local development time by enabling auto-login.
To enable auto-login, set the `autoLogin` property in the [Admin Config](../configuration/admin):
```ts
import { buildConfig } from 'payload/config'
import { buildConfig } from 'payload'
export default buildConfig({
// ...
admin: {
// ...
// highlight-start
autoLogin:
process.env.NEXT_PUBLIC_ENABLE_AUTOLOGIN === 'true'
? {
email: 'test@example.com',
password: 'test',
prefillOnly: true,
}
: false,
// highlight-end
},
// highlight-start
autoLogin:
process.env.NEXT_PUBLIC_ENABLE_AUTOLOGIN === 'true'
? {
email: 'test@example.com',
password: 'test',
prefillOnly: true,
}
: false,
// highlight-end
})
```
@@ -162,9 +159,10 @@ export default buildConfig({
The following options are available:
| Option | Description |
| ----------------- | --------------------------------------------------------------------------------------------------------------- |
|-------------------|-----------------------------------------------------------------------------------------------------------------|
| **`username`** | The username of the user to login as |
| **`email`** | The email address of the user to login as |
| **`password`** | The password of the user to login as |
| **`password`** | The password of the user to login as. This is only needed if `prefillOnly` is set to true |
| **`prefillOnly`** | If set to true, the login credentials will be prefilled but the user will still need to click the login button. |
## Operations

View File

@@ -64,38 +64,38 @@ export default buildConfig({
The following options are available:
| Option | Description |
| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`admin`** | The configuration options for the Admin Panel, including Custom Components, Live Preview, etc. [More details](../admin/overview#admin-options). |
| **`bin`** | Register custom bin scripts for Payload to execute. |
| **`editor`** | The Rich Text Editor which will be used by `richText` fields. [More details](../rich-text/overview). |
| **`db`** \* | The Database Adapter which will be used by Payload. [More details](../database/overview). |
| **`serverURL`** | A string used to define the absolute URL of your app. This includes the protocol, for example `https://example.com`. No paths allowed, only protocol, domain and (optionally) port. |
| **`collections`** | An array of Collections for Payload to manage. [More details](./collections). |
| **`globals`** | An array of Globals for Payload to manage. [More details](./globals). |
| Option | Description |
|----------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **`admin`** | The configuration options for the Admin Panel, including Custom Components, Live Preview, etc. [More details](../admin/overview#admin-options). |
| **`bin`** | Register custom bin scripts for Payload to execute. |
| **`editor`** | The Rich Text Editor which will be used by `richText` fields. [More details](../rich-text/overview). |
| **`db`** \* | The Database Adapter which will be used by Payload. [More details](../database/overview). |
| **`serverURL`** | A string used to define the absolute URL of your app. This includes the protocol, for example `https://example.com`. No paths allowed, only protocol, domain and (optionally) port. |
| **`collections`** | An array of Collections for Payload to manage. [More details](./collections). |
| **`globals`** | An array of Globals for Payload to manage. [More details](./globals). |
| **`cors`** | Cross-origin resource sharing (CORS) is a mechanism that accept incoming requests from given domains. You can also customize the `Access-Control-Allow-Headers` header. [More details](#cors). |
| **`localization`** | Opt-in to translate your content into multiple locales. [More details](./localization). |
| **`graphQL`** | Manage GraphQL-specific functionality, including custom queries and mutations, query complexity limits, etc. [More details](../graphql/overview#graphql-options). |
| **`cookiePrefix`** | A string that will be prefixed to all cookies that Payload sets. |
| **`csrf`** | A whitelist array of URLs to allow Payload to accept cookies from. [More details](../authentication/overview#csrf-protection). |
| **`defaultDepth`** | If a user does not specify `depth` while requesting a resource, this depth will be used. [More details](../queries/depth). |
| **`defaultMaxTextLength`** | The maximum allowed string length to be permitted application-wide. Helps to prevent malicious public document creation. |
| **`maxDepth`** | The maximum allowed depth to be permitted application-wide. This setting helps prevent against malicious queries. Defaults to `10`. [More details](../queries/depth). |
| **`indexSortableFields`** | Automatically index all sortable top-level fields in the database to improve sort performance and add database compatibility for Azure Cosmos and similar. |
| **`upload`** | Base Payload upload configuration. [More details](../upload/overview#payload-wide-upload-options). |
| **`routes`** | Control the routing structure that Payload binds itself to. [More details](../admin/overview#root-level-routes). |
| **`email`** | Configure the Email Adapter for Payload to use. [More details](../email/overview). |
| **`debug`** | Enable to expose more detailed error information. |
| **`telemetry`** | Disable Payload telemetry by passing `false`. [More details](#telemetry). |
| **`rateLimit`** | Control IP-based rate limiting for all Payload resources. Used to prevent DDoS attacks, etc. [More details](../production/preventing-abuse#rate-limiting-requests). |
| **`hooks`** | An array of Root Hooks. [More details](../hooks/overview). |
| **`plugins`** | An array of Plugins. [More details](../plugins/overview). |
| **`endpoints`** | An array of Custom Endpoints added to the Payload router. [More details](../rest-api/overview#custom-endpoints). |
| **`custom`** | Extension point for adding custom data (e.g. for plugins). |
| **`i18n`** | Internationalization configuration. Pass all i18n languages you'd like the admin UI to support. Defaults to English-only. [More details](./i18n). |
| **`secret`** \* | A secure, unguessable string that Payload will use for any encryption workflows - for example, password salt / hashing. |
| **`sharp`** | If you would like Payload to offer cropping, focal point selection, and automatic media resizing, install and pass the Sharp module to the config here. |
| **`typescript`** | Configure TypeScript settings here. [More details](#typescript). |
| **`localization`** | Opt-in to translate your content into multiple locales. [More details](./localization). |
| **`graphQL`** | Manage GraphQL-specific functionality, including custom queries and mutations, query complexity limits, etc. [More details](../graphql/overview#graphql-options). |
| **`cookiePrefix`** | A string that will be prefixed to all cookies that Payload sets. |
| **`csrf`** | A whitelist array of URLs to allow Payload to accept cookies from. [More details](../authentication/overview#csrf-protection). |
| **`defaultDepth`** | If a user does not specify `depth` while requesting a resource, this depth will be used. [More details](../queries/depth). |
| **`defaultMaxTextLength`** | The maximum allowed string length to be permitted application-wide. Helps to prevent malicious public document creation. |
| **`maxDepth`** | The maximum allowed depth to be permitted application-wide. This setting helps prevent against malicious queries. Defaults to `10`. [More details](../queries/depth). |
| **`indexSortableFields`** | Automatically index all sortable top-level fields in the database to improve sort performance and add database compatibility for Azure Cosmos and similar. |
| **`upload`** | Base Payload upload configuration. [More details](../upload/overview#payload-wide-upload-options). |
| **`routes`** | Control the routing structure that Payload binds itself to. [More details](../admin/overview#root-level-routes). |
| **`email`** | Configure the Email Adapter for Payload to use. [More details](../email/overview). |
| **`debug`** | Enable to expose more detailed error information. |
| **`telemetry`** | Disable Payload telemetry by passing `false`. [More details](#telemetry). |
| **`rateLimit`** | Control IP-based rate limiting for all Payload resources. Used to prevent DDoS attacks, etc. [More details](../production/preventing-abuse#rate-limiting-requests). |
| **`hooks`** | An array of Root Hooks. [More details](../hooks/overview). |
| **`plugins`** | An array of Plugins. [More details](../plugins/overview). |
| **`endpoints`** | An array of Custom Endpoints added to the Payload router. [More details](../rest-api/overview#custom-endpoints). |
| **`custom`** | Extension point for adding custom data (e.g. for plugins). |
| **`i18n`** | Internationalization configuration. Pass all i18n languages you'd like the admin UI to support. Defaults to English-only. [More details](./i18n). |
| **`secret`** \* | A secure, unguessable string that Payload will use for any encryption workflows - for example, password salt / hashing. |
| **`sharp`** | If you would like Payload to offer cropping, focal point selection, and automatic media resizing, install and pass the Sharp module to the config here. |
| **`typescript`** | Configure TypeScript settings here. [More details](#typescript). |
_\* An asterisk denotes that a property is required._

View File

@@ -375,6 +375,8 @@ export const MyCollection: CollectionConfig = {
<Banner type="warning">
<strong>Reminder:</strong>
The Custom ID Fields can only be of type [`Number`](./number) or [`Text`](./text).
Custom ID fields with type `text` must not contain `/` or `.` characters.
</Banner>
## TypeScript

View File

@@ -180,7 +180,6 @@ import {
useFormInitializing,
useFormModified,
useFormProcessing,
useFormQueryParams,
useFormSubmitted,
useHotkey,
useIntersect,
@@ -221,7 +220,6 @@ import {
EntityVisibilityProvider,
FieldComponentsProvider,
FieldPropsProvider,
FormQueryParamsProvider,
ListInfoProvider,
ListQueryProvider,
LocaleProvider,

View File

@@ -599,7 +599,7 @@ export const Orders: CollectionConfig = {
{
path: '/:id/tracking',
method: 'get',
handler: (req) => {
handler: async (req) => {
const tracking = await getTrackingInfo(req.params.id)
if (!tracking) {
@@ -614,7 +614,7 @@ export const Orders: CollectionConfig = {
{
path: '/:id/tracking',
method: 'post',
handler: (req) => {
handler: async (req) => {
// `data` is not automatically appended to the request
// if you would like to read the body of the request
// you can use `data = await req.json()`
@@ -654,7 +654,7 @@ import { addDataAndFileToRequest } from '@payloadcms/next/utilities'
{
path: '/:id/tracking',
method: 'post',
handler: (req) => {
handler: async (req) => {
await addDataAndFileToRequest(req)
await req.payload.update({
collection: 'tracking',
@@ -680,7 +680,7 @@ import { addLocalesToRequestFromData } from '@payloadcms/next/utilities'
{
path: '/:id/tracking',
method: 'post',
handler: (req) => {
handler: async (req) => {
await addLocalesToRequestFromData(req)
// you now can access req.locale & req.fallbackLocale
return Response.json({ message: 'success' })

View File

@@ -1,4 +1,4 @@
# Payload Multi-Tenant Example
# Payload Multi-Tenant Example (Single Domain)
This example demonstrates how to achieve a multi-tenancy in [Payload](https://github.com/payloadcms/payload) on a single domain. Tenants are separated by a `Tenants` collection.
@@ -6,12 +6,16 @@ This example demonstrates how to achieve a multi-tenancy in [Payload](https://gi
To spin up this example locally, follow these steps:
1. First clone the repo
2. `cd YOUR_PROJECT_REPO && cp .env.example .env`
3. `pnpm i && pnpm dev`
4. run `yarn seed` to seed the database
5. open `http://localhost:3000/admin` to access the admin panel
6. Login with email `demo@payloadcms.com` and password `demo`
1. Clone this repo
1. `cd` into this directory and run `pnpm i --ignore-workspace`\*, `yarn`, or `npm install`
> \*If you are running using pnpm within the Payload Monorepo, the `--ignore-workspace` flag is needed so that pnpm generates a lockfile in this example's directory despite the fact that one exists in root.
1. `pnpm dev`, `yarn dev` or `npm run dev` to start the server
- Press `y` when prompted to seed the database
1. `open http://localhost:3000` to access the home page
1. `open http://localhost:3000/admin` to access the admin panel
- Login with email `demo@payloadcms.com` and password `demo`
## How it works

View File

@@ -0,0 +1,8 @@
import { withPayload } from '@payloadcms/next/withPayload'
/** @type {import('next').NextConfig} */
const nextConfig = {
// Your Next.js config here
}
export default withPayload(nextConfig)

View File

@@ -1,21 +1,18 @@
{
"name": "multi-tenant-single-domain",
"description": "An example of a multi tenant application, using a single domain",
"version": "1.0.0",
"description": "An example of a multi tenant application, using a single domain",
"license": "MIT",
"type": "module",
"engines": {
"node": "^18.20.2 || >=20.9.0"
},
"scripts": {
"dev": "cross-env NODE_OPTIONS=--no-deprecation next dev --turbo",
"_dev": "cross-env NODE_OPTIONS=--no-deprecation next dev",
"build": "cross-env NODE_OPTIONS=--no-deprecation next build",
"payload": "cross-env NODE_OPTIONS=--no-deprecation payload",
"start": "cross-env NODE_OPTIONS=--no-deprecation next start",
"generate:types": "payload generate:types",
"dev": "cross-env NODE_OPTIONS=--no-deprecation && pnpm seed && next dev --turbo",
"generate:schema": "payload-graphql generate:schema",
"seed": "tsx ./scripts/seed.ts"
"generate:types": "payload generate:types",
"payload": "cross-env NODE_OPTIONS=--no-deprecation payload",
"seed": "npm run payload migrate:fresh",
"start": "cross-env NODE_OPTIONS=--no-deprecation next start"
},
"dependencies": {
"@payloadcms/db-mongodb": "3.0.0-beta.58",
@@ -42,6 +39,9 @@
"tsx": "^4.16.2",
"typescript": "5.5.2"
},
"engines": {
"node": "^18.20.2 || >=20.9.0"
},
"pnpm": {
"overrides": {
"@types/react": "npm:types-react@19.0.0-beta.2",

View File

@@ -1,209 +0,0 @@
/**
* This is an example of a standalone script that loads in the Payload config
* and uses the Payload Local API to query the database.
*/
process.env.PAYLOAD_DROP_DATABASE = 'true'
import type { Payload, RequiredDataFromCollectionSlug } from 'payload'
import { getPayload } from 'payload'
import { importConfig } from 'payload/node'
async function findOrCreateTenant({ data, payload }: { data: any; payload: Payload }) {
const tenantsQuery = await payload.find({
collection: 'tenants',
where: {
slug: {
equals: data.slug,
},
},
})
if (tenantsQuery.docs?.[0]) return tenantsQuery.docs[0]
return payload.create({
collection: 'tenants',
data,
})
}
async function findOrCreateUser({
data,
payload,
}: {
data: RequiredDataFromCollectionSlug<'users'>
payload: Payload
}) {
const usersQuery = await payload.find({
collection: 'users',
where: {
email: {
equals: data.email,
},
},
})
if (usersQuery.docs?.[0]) return usersQuery.docs[0]
return payload.create({
collection: 'users',
data,
})
}
async function findOrCreatePage({ data, payload }: { data: any; payload: Payload }) {
const pagesQuery = await payload.find({
collection: 'pages',
where: {
slug: {
equals: data.slug,
},
},
})
if (pagesQuery.docs?.[0]) return pagesQuery.docs[0]
return payload.create({
collection: 'pages',
data,
})
}
async function run() {
const awaitedConfig = await importConfig('../src/payload.config.ts')
const payload = await getPayload({ config: awaitedConfig })
const tenant1 = await findOrCreateTenant({
data: {
name: 'Tenant 1',
slug: 'tenant-1',
},
payload,
})
const tenant2 = await findOrCreateTenant({
data: {
name: 'Tenant 2',
slug: 'tenant-2',
public: true,
},
payload,
})
const tenant3 = await findOrCreateTenant({
data: {
name: 'Tenant 3',
slug: 'tenant-3',
},
payload,
})
await findOrCreateUser({
data: {
email: 'demo@payloadcms.com',
password: 'demo',
roles: ['super-admin'],
},
payload,
})
await findOrCreateUser({
data: {
email: 'tenant1@payloadcms.com',
password: 'test',
tenants: [
{
roles: ['tenant-admin'],
tenant: tenant1.id,
},
],
username: 'tenant1',
},
payload,
})
await findOrCreateUser({
data: {
email: 'tenant2@payloadcms.com',
password: 'test',
tenants: [
{
roles: ['tenant-admin'],
tenant: tenant2.id,
},
],
username: 'tenant2',
},
payload,
})
await findOrCreateUser({
data: {
email: 'tenant3@payloadcms.com',
password: 'test',
tenants: [
{
roles: ['tenant-admin'],
tenant: tenant3.id,
},
],
username: 'tenant3',
},
payload,
})
await findOrCreateUser({
data: {
email: 'multi-admin@payloadcms.com',
password: 'test',
tenants: [
{
roles: ['tenant-admin'],
tenant: tenant1.id,
},
{
roles: ['tenant-admin'],
tenant: tenant2.id,
},
{
roles: ['tenant-admin'],
tenant: tenant3.id,
},
],
username: 'tenant3',
},
payload,
})
await findOrCreatePage({
data: {
slug: 'home',
tenant: tenant1.id,
title: 'Page for Tenant 1',
},
payload,
})
await findOrCreatePage({
data: {
slug: 'home',
tenant: tenant2.id,
title: 'Page for Tenant 2',
},
payload,
})
await findOrCreatePage({
data: {
slug: 'home',
tenant: tenant3.id,
title: 'Page for Tenant 3',
},
payload,
})
process.exit(0)
}
run().catch(console.error)

View File

@@ -2,7 +2,7 @@ import type { Where } from 'payload'
import configPromise from '@payload-config'
import { getPayloadHMR } from '@payloadcms/next/utilities'
import { headers as getHeaders } from 'next/headers.js'
import { headers as getHeaders } from 'next/headers'
import { notFound, redirect } from 'next/navigation'
import React from 'react'

View File

@@ -1 +0,0 @@
export default {};

View File

@@ -1,5 +0,0 @@
import type { PayloadRequest } from "payload";
export const isPayloadAdminPanel = (req: PayloadRequest) => {
return req.headers.has('referer') && req.headers.get('referer')?.startsWith(`${process.env.NEXT_PUBLIC_SERVER_URL}${req.payload.config.routes.admin}`)
}

View File

@@ -2,8 +2,8 @@ import type { Access } from 'payload'
import { parseCookies } from 'payload'
import { isSuperAdmin } from '../../../access/isSuperAdmin.js'
import { getTenantAccessIDs } from '../../../utilities/getTenantAccessIDs.js'
import { isSuperAdmin } from '../../../access/isSuperAdmin'
import { getTenantAccessIDs } from '../../../utilities/getTenantAccessIDs'
export const filterByTenantRead: Access = (args) => {
const req = args.req

View File

@@ -2,7 +2,7 @@ import type { FieldHook } from 'payload'
import { ValidationError } from 'payload'
import { getTenantAccessIDs } from '../../../utilities/getTenantAccessIDs.js'
import { getTenantAccessIDs } from '../../../utilities/getTenantAccessIDs'
export const ensureUniqueSlug: FieldHook = async ({ data, originalDoc, req, value }) => {
// if value is unchanged, skip validation

View File

@@ -1,10 +1,10 @@
import type { CollectionConfig } from 'payload'
import { tenantField } from '../../fields/TenantField/index.js'
import { isPayloadAdminPanel } from '../../utilities/isPayloadAdminPanel.js'
import { canMutatePage, filterByTenantRead } from './access/byTenant.js'
import { externalReadAccess } from './access/externalReadAccess.js'
import { ensureUniqueSlug } from './hooks/ensureUniqueSlug.js'
import { tenantField } from '../../fields/TenantField'
import { isPayloadAdminPanel } from '../../utilities/isPayloadAdminPanel'
import { canMutatePage, filterByTenantRead } from './access/byTenant'
import { externalReadAccess } from './access/externalReadAccess'
import { ensureUniqueSlug } from './hooks/ensureUniqueSlug'
export const Pages: CollectionConfig = {
slug: 'pages',

View File

@@ -2,8 +2,8 @@ import type { Access } from 'payload'
import { parseCookies } from 'payload'
import { isSuperAdmin } from '../../../access/isSuperAdmin.js'
import { getTenantAccessIDs } from '../../../utilities/getTenantAccessIDs.js'
import { isSuperAdmin } from '../../../access/isSuperAdmin'
import { getTenantAccessIDs } from '../../../utilities/getTenantAccessIDs'
export const filterByTenantRead: Access = (args) => {
const req = args.req

View File

@@ -1,7 +1,7 @@
import type { Access } from 'payload'
import { isSuperAdmin } from '../../../access/isSuperAdmin.js'
import { getTenantAccessIDs } from '../../../utilities/getTenantAccessIDs.js'
import { isSuperAdmin } from '../../../access/isSuperAdmin'
import { getTenantAccessIDs } from '../../../utilities/getTenantAccessIDs'
export const tenantRead: Access = (args) => {
const req = args.req

View File

@@ -1,7 +1,7 @@
import type { CollectionConfig } from 'payload'
import { isSuperAdmin } from '../../access/isSuperAdmin.js'
import { canMutateTenant, filterByTenantRead } from './access/byTenant.js'
import { isSuperAdmin } from '../../access/isSuperAdmin'
import { canMutateTenant, filterByTenantRead } from './access/byTenant'
export const Tenants: CollectionConfig = {
slug: 'tenants',

View File

@@ -2,8 +2,8 @@ import type { Access } from 'payload'
import type { User } from '../../../../payload-types'
import { isSuperAdmin } from '../../../access/isSuperAdmin.js'
import { getTenantAdminTenantAccessIDs } from '../../../utilities/getTenantAccessIDs.js'
import { isSuperAdmin } from '../../../access/isSuperAdmin'
import { getTenantAdminTenantAccessIDs } from '../../../utilities/getTenantAccessIDs'
export const createAccess: Access<User> = (args) => {
const { req } = args

View File

@@ -1,6 +1,6 @@
import type { Access } from 'payload'
import { isSuperAdmin } from '../../../access/isSuperAdmin.js'
import { isAccessingSelf } from './isAccessingSelf.js'
import { isSuperAdmin } from '../../../access/isSuperAdmin'
import { isAccessingSelf } from './isAccessingSelf'
export const isSuperAdminOrSelf: Access = (args) => isSuperAdmin(args) || isAccessingSelf(args)

View File

@@ -1,10 +1,11 @@
import type { User } from '@/payload-types'
import type { Access, Where } from 'payload'
import { isSuperAdmin } from '@/cms/access/isSuperAdmin'
import { getTenantAdminTenantAccessIDs } from '@/cms/utilities/getTenantAccessIDs'
import { parseCookies } from 'payload'
import { isSuperAdmin } from '../../../access/isSuperAdmin'
import { getTenantAdminTenantAccessIDs } from '../../../utilities/getTenantAccessIDs'
export const readAccess: Access<User> = (args) => {
const { req } = args
if (!req?.user) return false

View File

@@ -1,7 +1,7 @@
import type { Access } from 'payload'
import { isSuperAdmin } from '../../../access/isSuperAdmin.js'
import { getTenantAdminTenantAccessIDs } from '../../../utilities/getTenantAccessIDs.js'
import { isSuperAdmin } from '../../../access/isSuperAdmin'
import { getTenantAdminTenantAccessIDs } from '../../../utilities/getTenantAccessIDs'
export const updateAndDeleteAccess: Access = (args) => {
const { req } = args

View File

@@ -2,7 +2,7 @@ import type { FieldHook } from 'payload'
import { ValidationError } from 'payload'
import { getTenantAccessIDs } from '../../../utilities/getTenantAccessIDs.js'
import { getTenantAccessIDs } from '../../../utilities/getTenantAccessIDs'
export const ensureUniqueUsername: FieldHook = async ({ data, originalDoc, req, value }) => {
// if value is unchanged, skip validation

View File

@@ -2,12 +2,12 @@ import type { CollectionConfig } from 'payload'
import type { User } from '../../../payload-types'
import { getTenantAdminTenantAccessIDs } from '../../utilities/getTenantAccessIDs.js'
import { createAccess } from './access/create.js'
import { getTenantAdminTenantAccessIDs } from '../../utilities/getTenantAccessIDs'
import { createAccess } from './access/create'
import { readAccess } from './access/read'
import { updateAndDeleteAccess } from './access/updateAndDelete.js'
import { externalUsersLogin } from './endpoints/externalUsersLogin.js'
import { ensureUniqueUsername } from './hooks/ensureUniqueUsername.js'
import { updateAndDeleteAccess } from './access/updateAndDelete'
import { externalUsersLogin } from './endpoints/externalUsersLogin'
import { ensureUniqueUsername } from './hooks/ensureUniqueUsername'
const Users: CollectionConfig = {
slug: 'users',

View File

@@ -1,7 +1,7 @@
import { cookies as getCookies } from 'next/headers'
import React from 'react'
import { TenantSelector } from './index.client.js'
import { TenantSelector } from './index.client'
export const TenantSelectorRSC = () => {
const cookies = getCookies()

View File

@@ -1,7 +1,7 @@
import type { FieldAccess } from 'payload'
import { isSuperAdmin } from '../../../access/isSuperAdmin.js'
import { getTenantAccessIDs } from '../../../utilities/getTenantAccessIDs.js'
import { isSuperAdmin } from '../../../access/isSuperAdmin'
import { getTenantAccessIDs } from '../../../utilities/getTenantAccessIDs'
export const tenantFieldUpdate: FieldAccess = (args) => {
const tenantIDs = getTenantAccessIDs(args.req.user)

View File

@@ -1,6 +1,6 @@
import type { FieldHook } from 'payload'
import { getTenantAccessIDs } from '../../../utilities/getTenantAccessIDs.js'
import { getTenantAccessIDs } from '../../../utilities/getTenantAccessIDs'
export const autofillTenant: FieldHook = ({ req, value }) => {
// If there is no value,

View File

@@ -1,9 +1,9 @@
import type { Field } from 'payload'
import { isSuperAdmin } from '../../access/isSuperAdmin.js'
import { tenantFieldUpdate } from './access/update.js'
import { TenantFieldComponent } from './components/Field.js'
import { autofillTenant } from './hooks/autofillTenant.js'
import { isSuperAdmin } from '../../access/isSuperAdmin'
import { tenantFieldUpdate } from './access/update'
import { TenantFieldComponent } from './components/Field'
import { autofillTenant } from './hooks/autofillTenant'
export const tenantField: Field = {
name: 'tenant',

View File

@@ -0,0 +1,131 @@
import type { MigrateUpArgs } from '@payloadcms/db-mongodb'
export async function up({ payload }: MigrateUpArgs): Promise<void> {
await payload.create({
collection: 'users',
data: {
email: 'demo@payloadcms.com',
password: 'demo',
roles: ['super-admin'],
},
})
const tenant1 = await payload.create({
collection: 'tenants',
data: {
name: 'Tenant 1',
slug: 'tenant-1',
},
})
const tenant2 = await payload.create({
collection: 'tenants',
data: {
name: 'Tenant 2',
slug: 'tenant-2',
},
})
const tenant3 = await payload.create({
collection: 'tenants',
data: {
name: 'Tenant 3',
slug: 'tenant-3',
},
})
await payload.create({
collection: 'users',
data: {
email: 'tenant1@payloadcms.com',
password: 'test',
tenants: [
{
roles: ['tenant-admin'],
tenant: tenant1.id,
},
],
username: 'tenant1',
},
})
await payload.create({
collection: 'users',
data: {
email: 'tenant2@payloadcms.com',
password: 'test',
tenants: [
{
roles: ['tenant-admin'],
tenant: tenant2.id,
},
],
username: 'tenant2',
},
})
await payload.create({
collection: 'users',
data: {
email: 'tenant3@payloadcms.com',
password: 'test',
tenants: [
{
roles: ['tenant-admin'],
tenant: tenant3.id,
},
],
username: 'tenant3',
},
})
await payload.create({
collection: 'users',
data: {
email: 'multi-admin@payloadcms.com',
password: 'test',
tenants: [
{
roles: ['tenant-admin'],
tenant: tenant1.id,
},
{
roles: ['tenant-admin'],
tenant: tenant2.id,
},
{
roles: ['tenant-admin'],
tenant: tenant3.id,
},
],
username: 'tenant3',
},
})
await payload.create({
collection: 'pages',
data: {
slug: 'home',
tenant: tenant1.id,
title: 'Page for Tenant 1',
},
})
await payload.create({
collection: 'pages',
data: {
slug: 'home',
tenant: tenant2.id,
title: 'Page for Tenant 2',
},
})
await payload.create({
collection: 'pages',
data: {
slug: 'home',
tenant: tenant3.id,
title: 'Page for Tenant 3',
},
})
}

View File

@@ -0,0 +1 @@
export default {}

View File

@@ -131,4 +131,4 @@ export interface Auth {
declare module 'payload' {
export interface GeneratedTypes extends Config {}
}
}

View File

@@ -4,10 +4,10 @@ import path from 'path'
import { buildConfig } from 'payload'
import { fileURLToPath } from 'url'
import { Pages } from './cms/collections/Pages/index.js'
import { Tenants } from './cms/collections/Tenants/index.js'
import Users from './cms/collections/Users/index.js'
import { TenantSelectorRSC } from './cms/components/TenantSelector/index.js'
import { Pages } from './collections/Pages'
import { Tenants } from './collections/Tenants'
import Users from './collections/Users'
import { TenantSelectorRSC } from './components/TenantSelector'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)

View File

@@ -0,0 +1,10 @@
import type { PayloadRequest } from 'payload'
export const isPayloadAdminPanel = (req: PayloadRequest) => {
return (
req.headers.has('referer') &&
req.headers
.get('referer')
?.startsWith(`${process.env.NEXT_PUBLIC_SERVER_URL}${req.payload.config.routes.admin}`)
)
}

View File

@@ -27,10 +27,10 @@
"./src/*"
],
"@payload-config": [
"./src/payload.config.ts"
"src/payload.config.ts"
],
"@payload-types": [
"./src/payload-types.ts"
"src/payload-types.ts"
]
},
"target": "ES2017"

View File

@@ -1,6 +1,6 @@
{
"name": "payload-monorepo",
"version": "3.0.0-beta.66",
"version": "3.0.0-beta.68",
"private": true,
"type": "module",
"scripts": {

View File

@@ -1,6 +1,6 @@
{
"name": "create-payload-app",
"version": "3.0.0-beta.66",
"version": "3.0.0-beta.68",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/db-mongodb",
"version": "3.0.0-beta.66",
"version": "3.0.0-beta.68",
"description": "The officially supported MongoDB database adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {
@@ -34,7 +34,6 @@
},
"dependencies": {
"bson-objectid": "2.0.4",
"deepmerge": "4.3.1",
"http-status": "1.6.2",
"mongoose": "6.12.3",
"mongoose-paginate-v2": "1.7.22",

View File

@@ -1,8 +1,7 @@
import type { FilterQuery } from 'mongoose'
import type { Field, Operator, Payload, Where } from 'payload'
import deepmerge from 'deepmerge'
import { combineMerge } from 'payload'
import { deepMergeWithCombinedArrays } from 'payload'
import { validOperators } from 'payload/shared'
import { buildAndOrConditions } from './buildAndOrConditions.js'
@@ -70,7 +69,7 @@ export async function parseParams({
[searchParam.path]: searchParam.value,
}
} else if (typeof searchParam?.value === 'object') {
result = deepmerge(result, searchParam.value, { arrayMerge: combineMerge })
result = deepMergeWithCombinedArrays(result, searchParam.value)
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/db-postgres",
"version": "3.0.0-beta.66",
"version": "3.0.0-beta.68",
"description": "The officially supported Postgres database adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/email-nodemailer",
"version": "3.0.0-beta.66",
"version": "3.0.0-beta.68",
"description": "Payload Nodemailer Email Adapter",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/email-resend",
"version": "3.0.0-beta.66",
"version": "3.0.0-beta.68",
"description": "Payload Resend Email Adapter",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/graphql",
"version": "3.0.0-beta.66",
"version": "3.0.0-beta.68",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/live-preview-react",
"version": "3.0.0-beta.66",
"version": "3.0.0-beta.68",
"description": "The official live preview React SDK for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/live-preview",
"version": "3.0.0-beta.66",
"version": "3.0.0-beta.68",
"description": "The official live preview JavaScript SDK for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -14,12 +14,12 @@ async function build() {
plugins: [sassPlugin({ css: 'external' })],
})
await fs.rename('dist/prod/esbuildEntry.css', 'dist/prod/styles.css', (err) => {
if (err) {
console.error(`Error while renaming index.css: ${err}`)
throw err
}
})
try {
fs.renameSync('dist/prod/esbuildEntry.css', 'dist/prod/styles.css')
} catch (err) {
console.error(`Error while renaming index.css: ${err}`)
throw err
}
console.log('styles.css bundled successfully')
@@ -32,12 +32,12 @@ async function build() {
]
for (const file of filesToDelete) {
await fs.unlink(file, (err) => {
if (err) {
console.error(`Error while deleting ${file}: ${err}`)
throw err
}
})
try {
fs.unlinkSync(file)
} catch (err) {
console.error(`Error while deleting ${file}: ${err}`)
throw err
}
}
console.log('Files renamed and deleted successfully')

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/next",
"version": "3.0.0-beta.66",
"version": "3.0.0-beta.68",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",

View File

@@ -1,7 +1,7 @@
import type { Collection, PayloadRequest, SanitizedConfig } from 'payload'
import httpStatus from 'http-status'
import { APIError } from 'payload'
import { APIError, APIErrorName, ValidationErrorName } from 'payload'
import { getPayloadHMR } from '../../utilities/getPayloadHMR.js'
import { headersWithCors } from '../../utilities/headersWithCors.js'
@@ -16,7 +16,7 @@ const formatErrors = (incoming: { [key: string]: unknown } | APIError): ErrorRes
// Payload 'ValidationError' and 'APIError'
if (
(proto.constructor.name === 'ValidationError' || proto.constructor.name === 'APIError') &&
(proto.constructor.name === ValidationErrorName || proto.constructor.name === APIErrorName) &&
incoming.data
) {
return {
@@ -31,7 +31,7 @@ const formatErrors = (incoming: { [key: string]: unknown } | APIError): ErrorRes
}
// Mongoose 'ValidationError': https://mongoosejs.com/docs/api/error.html#Error.ValidationError
if (proto.constructor.name === 'ValidationError' && 'errors' in incoming && incoming.errors) {
if (proto.constructor.name === ValidationErrorName && 'errors' in incoming && incoming.errors) {
return {
errors: Object.keys(incoming.errors).reduce((acc, key) => {
acc.push({

View File

@@ -1,6 +1,6 @@
import type { AdminViewProps, ServerSideEditViewProps } from 'payload'
import { DocumentInfoProvider, FormQueryParamsProvider, HydrateClientUser } from '@payloadcms/ui'
import { DocumentInfoProvider, HydrateClientUser } from '@payloadcms/ui'
import { RenderCustomComponent } from '@payloadcms/ui/shared'
import { notFound } from 'next/navigation.js'
import React from 'react'
@@ -65,7 +65,6 @@ export const Account: React.FC<AdminViewProps> = async ({
return (
<DocumentInfoProvider
AfterFields={<Settings i18n={i18n} languageOptions={languageOptions} />}
action={`${serverURL}${api}/${userSlug}${user?.id ? `/${user.id}` : ''}`}
apiURL={`${serverURL}${api}/${userSlug}${user?.id ? `/${user.id}` : ''}`}
collectionSlug={userSlug}
docPermissions={docPermissions}
@@ -84,31 +83,22 @@ export const Account: React.FC<AdminViewProps> = async ({
permissions={permissions}
/>
<HydrateClientUser permissions={permissions} user={user} />
<FormQueryParamsProvider
initialParams={{
depth: 0,
'fallback-locale': 'null',
locale: locale?.code,
uploadEdits: undefined,
<RenderCustomComponent
CustomComponent={
typeof CustomAccountComponent === 'function' ? CustomAccountComponent : undefined
}
DefaultComponent={EditView}
componentProps={viewComponentProps}
serverOnlyProps={{
i18n,
locale,
params,
payload,
permissions,
searchParams,
user,
}}
>
<RenderCustomComponent
CustomComponent={
typeof CustomAccountComponent === 'function' ? CustomAccountComponent : undefined
}
DefaultComponent={EditView}
componentProps={viewComponentProps}
serverOnlyProps={{
i18n,
locale,
params,
payload,
permissions,
searchParams,
user,
}}
/>
</FormQueryParamsProvider>
/>
</DocumentInfoProvider>
)
}

View File

@@ -1,16 +1,6 @@
import type {
AdminViewComponent,
AdminViewProps,
EditViewComponent,
ServerSideEditViewProps,
} from 'payload'
import type { AdminViewComponent, AdminViewProps, EditViewComponent } from 'payload'
import {
DocumentInfoProvider,
EditDepthProvider,
FormQueryParamsProvider,
HydrateClientUser,
} from '@payloadcms/ui'
import { DocumentInfoProvider, EditDepthProvider, HydrateClientUser } from '@payloadcms/ui'
import { RenderCustomComponent, isEditing as getIsEditing } from '@payloadcms/ui/shared'
import { notFound, redirect } from 'next/navigation.js'
import React from 'react'
@@ -65,7 +55,6 @@ export const Document: React.FC<AdminViewProps> = async ({
let ErrorView: AdminViewComponent
let apiURL: string
let action: string
const { data, formState } = await getDocumentData({
id,
@@ -88,8 +77,6 @@ export const Document: React.FC<AdminViewProps> = async ({
notFound()
}
action = `${serverURL}${apiRoute}/${collectionSlug}${isEditing ? `/${id}` : ''}`
const params = new URLSearchParams()
if (collectionConfig.versions?.drafts) {
params.append('draft', 'true')
@@ -128,8 +115,6 @@ export const Document: React.FC<AdminViewProps> = async ({
notFound()
}
action = `${serverURL}${apiRoute}/globals/${globalSlug}`
const params = new URLSearchParams({
locale: locale?.code,
})
@@ -198,7 +183,6 @@ export const Document: React.FC<AdminViewProps> = async ({
return (
<DocumentInfoProvider
action={action}
apiURL={apiURL}
collectionSlug={collectionConfig?.slug}
disableActions={false}
@@ -225,34 +209,25 @@ export const Document: React.FC<AdminViewProps> = async ({
depth={1}
key={`${collectionSlug || globalSlug}${locale?.code ? `-${locale?.code}` : ''}`}
>
<FormQueryParamsProvider
initialParams={{
depth: 0,
'fallback-locale': 'null',
locale: locale?.code,
uploadEdits: undefined,
}}
>
{ErrorView ? (
<ErrorView initPageResult={initPageResult} searchParams={searchParams} />
) : (
<RenderCustomComponent
CustomComponent={ViewOverride || CustomView}
DefaultComponent={DefaultView}
serverOnlyProps={{
i18n,
initPageResult,
locale,
params,
payload,
permissions,
routeSegments: segments,
searchParams,
user,
}}
/>
)}
</FormQueryParamsProvider>
{ErrorView ? (
<ErrorView initPageResult={initPageResult} searchParams={searchParams} />
) : (
<RenderCustomComponent
CustomComponent={ViewOverride || CustomView}
DefaultComponent={DefaultView}
serverOnlyProps={{
i18n,
initPageResult,
locale,
params,
payload,
permissions,
routeSegments: segments,
searchParams,
user,
}}
/>
)}
</EditDepthProvider>
</DocumentInfoProvider>
)

View File

@@ -13,7 +13,7 @@ import {
useDocumentEvents,
useDocumentInfo,
useEditDepth,
useFormQueryParams,
useUploadEdits,
} from '@payloadcms/ui'
import { getFormState } from '@payloadcms/ui/shared'
import { useRouter, useSearchParams } from 'next/navigation.js'
@@ -58,11 +58,13 @@ export const DefaultEditView: React.FC = () => {
const { refreshCookieAsync, user } = useAuth()
const config = useConfig()
const router = useRouter()
const { dispatchFormQueryParams } = useFormQueryParams()
const { getComponentMap, getFieldMap } = useComponentMap()
const params = useSearchParams()
const depth = useEditDepth()
const params = useSearchParams()
const { reportUpdate } = useDocumentEvents()
const { resetUploadEdits } = useUploadEdits()
const locale = params.get('locale')
const {
admin: { user: userSlug },
@@ -72,8 +74,6 @@ export const DefaultEditView: React.FC = () => {
serverURL,
} = config
const locale = params.get('locale')
const collectionConfig =
collectionSlug && collections.find((collection) => collection.slug === collectionSlug)
@@ -130,12 +130,7 @@ export const DefaultEditView: React.FC = () => {
const redirectRoute = `${adminRoute}/collections/${collectionSlug}/${json?.doc?.id}${locale ? `?locale=${locale}` : ''}`
router.push(redirectRoute)
} else {
dispatchFormQueryParams({
type: 'SET',
params: {
uploadEdits: null,
},
})
resetUploadEdits()
}
},
[
@@ -151,9 +146,9 @@ export const DefaultEditView: React.FC = () => {
isEditing,
refreshCookieAsync,
adminRoute,
locale,
router,
dispatchFormQueryParams,
locale,
resetUploadEdits,
],
)

View File

@@ -17,13 +17,15 @@ import { LoginField } from '../LoginField/index.js'
import './index.scss'
export const LoginForm: React.FC<{
prefillEmail?: string
prefillPassword?: string
prefillUsername?: string
searchParams: { [key: string]: string | string[] | undefined }
}> = ({ searchParams }) => {
}> = ({ prefillEmail, prefillPassword, prefillUsername, searchParams }) => {
const config = useConfig()
const {
admin: {
autoLogin,
routes: { forgot: forgotRoute },
user: userSlug,
},
@@ -45,27 +47,25 @@ export const LoginForm: React.FC<{
const { t } = useTranslation()
const prefillForm = autoLogin && autoLogin.prefillOnly
const initialState: FormState = {
password: {
initialValue: prefillForm ? autoLogin.password : undefined,
initialValue: prefillPassword ?? undefined,
valid: true,
value: prefillForm ? autoLogin.password : undefined,
value: prefillPassword ?? undefined,
},
}
if (loginWithUsername) {
initialState.username = {
initialValue: prefillForm ? autoLogin.username : undefined,
initialValue: prefillUsername ?? undefined,
valid: true,
value: prefillForm ? autoLogin.username : undefined,
value: prefillUsername ?? undefined,
}
} else {
initialState.email = {
initialValue: prefillForm ? autoLogin.email : undefined,
initialValue: prefillEmail ?? undefined,
valid: true,
value: prefillForm ? autoLogin.email : undefined,
value: prefillEmail ?? undefined,
}
}

View File

@@ -70,6 +70,24 @@ export const LoginView: React.FC<AdminViewProps> = ({ initPageResult, params, se
const collectionConfig = collections.find(({ slug }) => slug === userSlug)
const prefillAutoLogin =
typeof config.admin?.autoLogin === 'object' && config.admin?.autoLogin.prefillOnly
const prefillUsername =
prefillAutoLogin && typeof config.admin?.autoLogin === 'object'
? config.admin?.autoLogin.username
: undefined
const prefillEmail =
prefillAutoLogin && typeof config.admin?.autoLogin === 'object'
? config.admin?.autoLogin.email
: undefined
const prefillPassword =
prefillAutoLogin && typeof config.admin?.autoLogin === 'object'
? config.admin?.autoLogin.password
: undefined
return (
<Fragment>
<div className={`${loginBaseClass}__brand`}>
@@ -84,7 +102,14 @@ export const LoginView: React.FC<AdminViewProps> = ({ initPageResult, params, se
/>
</div>
{Array.isArray(BeforeLogins) && BeforeLogins.map((Component) => Component)}
{!collectionConfig?.auth?.disableLocalStrategy && <LoginForm searchParams={searchParams} />}
{!collectionConfig?.auth?.disableLocalStrategy && (
<LoginForm
prefillEmail={prefillEmail}
prefillPassword={prefillPassword}
prefillUsername={prefillUsername}
searchParams={searchParams}
/>
)}
{Array.isArray(AfterLogins) && AfterLogins.map((Component) => Component)}
</Fragment>
)

View File

@@ -15,7 +15,6 @@ async function build() {
splitting: false,
external: [
'lodash',
//'joi',
'*.scss',
'*.css',
'@payloadcms/translations',

View File

@@ -1,6 +1,6 @@
{
"name": "payload",
"version": "3.0.0-beta.66",
"version": "3.0.0-beta.68",
"description": "Node, React, Headless CMS and Application Framework built on Next.js",
"keywords": [
"admin panel",
@@ -84,7 +84,7 @@
"pretest": "pnpm build"
},
"dependencies": {
"@next/env": "^15.0.0-rc.0",
"@next/env": "^15.0.0-canary.53",
"@payloadcms/translations": "workspace:*",
"@swc-node/core": "1.13.1",
"@swc-node/sourcemap-support": "0.5.0",
@@ -99,13 +99,12 @@
"get-tsconfig": "^4.7.2",
"http-status": "1.6.2",
"image-size": "^1.1.1",
"joi": "^17.12.1",
"json-schema-to-typescript": "11.0.3",
"jsonwebtoken": "9.0.1",
"minimist": "1.2.8",
"monaco-editor": "0.38.0",
"pino": "8.15.0",
"pino-pretty": "10.2.0",
"pino": "9.3.1",
"pino-pretty": "11.2.1",
"pluralize": "8.0.0",
"sanitize-filename": "1.6.3",
"scmp": "2.1.0",
@@ -117,7 +116,6 @@
"@monaco-editor/react": "4.5.1",
"@payloadcms/eslint-config": "workspace:*",
"@types/express-fileupload": "1.4.1",
"@types/joi": "14.3.4",
"@types/json-schema": "7.0.15",
"@types/jsonwebtoken": "8.5.9",
"@types/minimist": "1.2.2",

View File

@@ -6,7 +6,7 @@ import type { SanitizedCollectionConfig, TypeWithID } from '../collections/confi
import type { SanitizedConfig } from '../config/types.js'
import type { Field, FieldAffectingData, RichTextField, Validate } from '../fields/config/types.js'
import type { SanitizedGlobalConfig } from '../globals/config/types.js'
import type { PayloadRequest, RequestContext } from '../types/index.js'
import type { JsonObject, PayloadRequest, RequestContext } from '../types/index.js'
import type { WithServerSidePropsComponentProps } from './elements/WithServerSideProps.js'
export type RichTextFieldProps<Value extends object, AdapterProps, ExtraFieldProperties = {}> = {
@@ -82,7 +82,7 @@ export type BeforeChangeRichTextHookArgs<
/**
* The original data with locales (not modified by any hooks). Only available in `beforeChange` and `beforeDuplicate` field hooks.
*/
docWithLocales?: Record<string, unknown>
docWithLocales?: JsonObject
duplicate?: boolean
@@ -98,7 +98,7 @@ export type BeforeChangeRichTextHookArgs<
/**
* The original siblingData with locales (not modified by any hooks).
*/
siblingDocWithLocales?: Record<string, unknown>
siblingDocWithLocales?: JsonObject
skipValidation?: boolean
}
@@ -216,7 +216,7 @@ type RichTextAdapterBase<
populationPromises: Promise<void>[]
req: PayloadRequest
showHiddenFields: boolean
siblingDoc: Record<string, unknown>
siblingDoc: JsonObject
}) => void
hooks?: RichTextHooks
i18n?: Partial<GenericLanguages>

View File

@@ -59,7 +59,7 @@ export const resetPasswordOperation = async (args: Arguments): Promise<Result> =
collection: collectionConfig.slug,
req,
where: {
resetPasswordExpiration: { greater_than: new Date() },
resetPasswordExpiration: { greater_than: new Date().toISOString() },
resetPasswordToken: { equals: data.token },
},
})

View File

@@ -1,5 +1,6 @@
import jwt from 'jsonwebtoken'
import type { Where } from '../../types/index.js'
import type { AuthStrategyFunction, User } from '../index.js'
import { extractJWT } from '../extractJWT.js'
@@ -16,6 +17,46 @@ export const JWTAuthentication: AuthStrategyFunction = async ({
}) => {
try {
const token = extractJWT({ headers, payload })
if (
!token &&
typeof payload?.config?.admin?.autoLogin === 'object' &&
!payload.config.admin?.autoLogin.prefillOnly &&
headers.get('DisableAutologin') !== 'true'
) {
const collection = payload.collections[payload.config.admin.user]
const where: Where = {
or: [],
}
if (payload.config.admin?.autoLogin.email) {
where.or.push({
email: {
equals: payload.config.admin?.autoLogin.email,
},
})
} else if (payload.config.admin?.autoLogin.username) {
where.or.push({
username: {
equals: payload.config.admin?.autoLogin.username,
},
})
}
const user = (
await payload.find({
collection: collection.config.slug,
depth: isGraphQL ? 0 : collection.config.auth.depth,
where,
})
).docs[0]
user.collection = collection.config.slug
user._strategy = 'local-jwt'
return {
user: user as User,
}
}
const decodedPayload = jwt.verify(token, payload.secret) as JWTToken & jwt.JwtPayload
const collection = payload.collections[decodedPayload.collection]

View File

@@ -1,5 +1,5 @@
import type { SanitizedCollectionConfig, TypeWithID } from '../../../collections/config/types.js'
import type { Payload } from '../../../index.js'
import type { JsonObject, Payload } from '../../../index.js'
import type { PayloadRequest } from '../../../types/index.js'
type Args = {
@@ -39,13 +39,13 @@ export const incrementLoginAttempts = async ({
return
}
const data: Record<string, unknown> = {
const data: JsonObject = {
loginAttempts: Number(doc.loginAttempts) + 1,
}
// Lock the account if at max attempts and not already locked
if (typeof doc.loginAttempts === 'number' && doc.loginAttempts + 1 >= maxLoginAttempts) {
const lockUntil = new Date(Date.now() + lockTime)
const lockUntil = new Date(Date.now() + lockTime).toISOString()
data.lockUntil = lockUntil
}

View File

@@ -1,5 +1,5 @@
import type { SanitizedCollectionConfig } from '../../../collections/config/types.js'
import type { Payload } from '../../../index.js'
import type { JsonObject, Payload } from '../../../index.js'
import type { PayloadRequest } from '../../../types/index.js'
import { ValidationError } from '../../../errors/index.js'
@@ -7,7 +7,7 @@ import { generatePasswordSaltHash } from './generatePasswordSaltHash.js'
type Args = {
collection: SanitizedCollectionConfig
doc: Record<string, unknown>
doc: JsonObject
password: string
payload: Payload
req: PayloadRequest

View File

@@ -4,13 +4,13 @@ import { compile } from 'json-schema-to-typescript'
import type { SanitizedConfig } from '../config/types.js'
import { configToJSONSchema } from '../utilities/configToJSONSchema.js'
import Logger from '../utilities/logger.js'
import { getLogger } from '../utilities/logger.js'
export async function generateTypes(
config: SanitizedConfig,
options?: { log: boolean },
): Promise<void> {
const logger = Logger()
const logger = getLogger()
const outputFile = process.env.PAYLOAD_TS_OUTPUT_PATH || config.typescript.outputFile
const shouldLog = options?.log ?? true

View File

@@ -1,8 +1,9 @@
import type { LoginWithUsernameOptions } from '../../auth/types.js'
import type { IncomingAuthType, LoginWithUsernameOptions } from '../../auth/types.js'
import type { CollectionConfig } from './types.js'
import defaultAccess from '../../auth/defaultAccess.js'
export const defaults = {
export const defaults: Partial<CollectionConfig> = {
access: {
create: defaultAccess,
delete: defaultAccess,
@@ -49,7 +50,7 @@ export const defaults = {
versions: false,
}
export const authDefaults = {
export const authDefaults: IncomingAuthType = {
cookies: {
sameSite: 'Lax',
secure: false,

View File

@@ -0,0 +1,172 @@
import type { Config } from '../../config/types.js'
import type { CollectionConfig, Field } from '../../index.js'
import { ReservedFieldName } from '../../errors/index.js'
import { sanitizeCollection } from './sanitize.js'
describe('reservedFieldNames - collections -', () => {
const config = {
collections: [],
globals: [],
} as Partial<Config>
describe('uploads -', () => {
const collectionWithUploads: CollectionConfig = {
slug: 'collection-with-uploads',
fields: [],
upload: true,
}
it('should throw on file', async () => {
const fields: Field[] = [
{
name: 'file',
type: 'text',
label: 'some-collection',
},
]
await expect(async () => {
await sanitizeCollection(
// @ts-expect-error
{
...config,
collections: [
{
...collectionWithUploads,
fields,
},
],
},
{
...collectionWithUploads,
fields,
},
)
}).rejects.toThrow(ReservedFieldName)
})
it('should not throw on a custom field', async () => {
const fields: Field[] = [
{
name: 'customField',
type: 'text',
label: 'some-collection',
},
]
await expect(async () => {
await sanitizeCollection(
// @ts-expect-error
{
...config,
collections: [
{
...collectionWithUploads,
fields,
},
],
},
{
...collectionWithUploads,
fields,
},
)
}).not.toThrow()
})
})
describe('auth -', () => {
const collectionWithAuth: CollectionConfig = {
slug: 'collection-with-auth',
fields: [],
auth: {
verify: true,
useAPIKey: true,
loginWithUsername: true,
},
}
it('should throw on hash', async () => {
const fields: Field[] = [
{
name: 'hash',
type: 'text',
label: 'some-collection',
},
]
await expect(async () => {
await sanitizeCollection(
// @ts-expect-error
{
...config,
collections: [
{
...collectionWithAuth,
fields,
},
],
},
{
...collectionWithAuth,
fields,
},
)
}).rejects.toThrow(ReservedFieldName)
})
it('should throw on salt', async () => {
const fields: Field[] = [
{
name: 'salt',
type: 'text',
label: 'some-collection',
},
]
await expect(async () => {
await sanitizeCollection(
// @ts-expect-error
{
...config,
collections: [
{
...collectionWithAuth,
fields,
},
],
},
{
...collectionWithAuth,
fields,
},
)
}).rejects.toThrow(ReservedFieldName)
})
it('should not throw on a custom field', async () => {
const fields: Field[] = [
{
name: 'customField',
type: 'text',
label: 'some-collection',
},
]
await expect(async () => {
await sanitizeCollection(
// @ts-expect-error
{
...config,
collections: [
{
...collectionWithAuth,
fields,
},
],
},
{
...collectionWithAuth,
fields,
},
)
}).not.toThrow()
})
})
})

View File

@@ -0,0 +1,150 @@
import type { Field } from '../../fields/config/types.js'
import type { CollectionConfig } from '../../index.js'
import { ReservedFieldName } from '../../errors/ReservedFieldName.js'
import { fieldAffectsData } from '../../fields/config/types.js'
// Note for future reference: We've slimmed down the reserved field names but left them in here for reference in case it's needed in the future.
/**
* Reserved field names for collections with auth config enabled
*/
const reservedBaseAuthFieldNames = [
/* 'email',
'resetPasswordToken',
'resetPasswordExpiration', */
'salt',
'hash',
]
/**
* Reserved field names for auth collections with verify: true
*/
const reservedVerifyFieldNames = [
/* '_verified', '_verificationToken' */
]
/**
* Reserved field names for auth collections with useApiKey: true
*/
const reservedAPIKeyFieldNames = [
/* 'enableAPIKey', 'apiKeyIndex', 'apiKey' */
]
/**
* Reserved field names for collections with upload config enabled
*/
const reservedBaseUploadFieldNames = [
'file',
/* 'mimeType',
'thumbnailURL',
'width',
'height',
'filesize',
'filename',
'url',
'focalX',
'focalY',
'sizes', */
]
/**
* Reserved field names for collections with versions enabled
*/
const reservedVersionsFieldNames = [
/* '__v', '_status' */
]
/**
* Sanitize fields for collections with auth config enabled.
*
* Should run on top level fields only.
*/
export const sanitizeAuthFields = (fields: Field[], config: CollectionConfig) => {
for (let i = 0; i < fields.length; i++) {
const field = fields[i]
if (fieldAffectsData(field) && field.name) {
if (config.auth && typeof config.auth === 'object' && !config.auth.disableLocalStrategy) {
const auth = config.auth
if (reservedBaseAuthFieldNames.includes(field.name)) {
throw new ReservedFieldName(field, field.name)
}
if (auth.verify) {
if (reservedAPIKeyFieldNames.includes(field.name)) {
throw new ReservedFieldName(field, field.name)
}
}
/* if (auth.maxLoginAttempts) {
if (field.name === 'loginAttempts' || field.name === 'lockUntil') {
throw new ReservedFieldName(field, field.name)
}
} */
/* if (auth.loginWithUsername) {
if (field.name === 'username') {
throw new ReservedFieldName(field, field.name)
}
} */
if (auth.verify) {
if (reservedVerifyFieldNames.includes(field.name)) {
throw new ReservedFieldName(field, field.name)
}
}
}
}
// Handle tabs without a name
if (field.type === 'tabs') {
for (let j = 0; j < field.tabs.length; j++) {
const tab = field.tabs[j]
if (!('name' in tab)) {
sanitizeAuthFields(tab.fields, config)
}
}
}
// Handle presentational fields like rows and collapsibles
if (!fieldAffectsData(field) && 'fields' in field && field.fields) {
sanitizeAuthFields(field.fields, config)
}
}
}
/**
* Sanitize fields for collections with upload config enabled.
*
* Should run on top level fields only.
*/
export const sanitizeUploadFields = (fields: Field[], config: CollectionConfig) => {
if (config.upload && typeof config.upload === 'object') {
for (let i = 0; i < fields.length; i++) {
const field = fields[i]
if (fieldAffectsData(field) && field.name) {
if (reservedBaseUploadFieldNames.includes(field.name)) {
throw new ReservedFieldName(field, field.name)
}
}
// Handle tabs without a name
if (field.type === 'tabs') {
for (let j = 0; j < field.tabs.length; j++) {
const tab = field.tabs[j]
if (!('name' in tab)) {
sanitizeUploadFields(tab.fields, config)
}
}
}
// Handle presentational fields like rows and collapsibles
if (!fieldAffectsData(field) && 'fields' in field && field.fields) {
sanitizeUploadFields(field.fields, config)
}
}
}
}

View File

@@ -1,5 +1,3 @@
import merge from 'deepmerge'
import type { Config, SanitizedConfig } from '../../config/types.js'
import type { CollectionConfig, SanitizedCollectionConfig } from './types.js'
@@ -9,11 +7,12 @@ import { sanitizeFields } from '../../fields/config/sanitize.js'
import { fieldAffectsData } from '../../fields/config/types.js'
import mergeBaseFields from '../../fields/mergeBaseFields.js'
import { getBaseUploadFields } from '../../uploads/getBaseFields.js'
import { deepMergeWithReactComponents } from '../../utilities/deepMerge.js'
import { formatLabels } from '../../utilities/formatLabels.js'
import { isPlainObject } from '../../utilities/isPlainObject.js'
import baseVersionFields from '../../versions/baseFields.js'
import { versionDefaults } from '../../versions/defaults.js'
import { authDefaults, defaults, loginWithUsernameDefaults } from './defaults.js'
import { sanitizeAuthFields, sanitizeUploadFields } from './reservedFieldNames.js'
export const sanitizeCollection = async (
config: Config,
@@ -28,9 +27,7 @@ export const sanitizeCollection = async (
// Make copy of collection config
// /////////////////////////////////
const sanitized: CollectionConfig = merge(defaults, collection, {
isMergeableObject: isPlainObject,
})
const sanitized: CollectionConfig = deepMergeWithReactComponents(defaults, collection)
// /////////////////////////////////
// Sanitize fields
@@ -38,6 +35,7 @@ export const sanitizeCollection = async (
const validRelationships = config.collections.map((c) => c.slug) || []
sanitized.fields = await sanitizeFields({
collectionConfig: sanitized,
config,
fields: sanitized.fields,
richTextSanitizationPromises,
@@ -115,6 +113,9 @@ export const sanitizeCollection = async (
if (sanitized.upload) {
if (sanitized.upload === true) sanitized.upload = {}
// sanitize fields for reserved names
sanitizeUploadFields(sanitized.fields, sanitized)
// disable duplicate for uploads by default
sanitized.disableDuplicate = sanitized.disableDuplicate || true
@@ -133,9 +134,13 @@ export const sanitizeCollection = async (
}
if (sanitized.auth) {
sanitized.auth = merge(authDefaults, typeof sanitized.auth === 'object' ? sanitized.auth : {}, {
isMergeableObject: isPlainObject,
})
// sanitize fields for reserved names
sanitizeAuthFields(sanitized.fields, sanitized)
sanitized.auth = deepMergeWithReactComponents(
authDefaults,
typeof sanitized.auth === 'object' ? sanitized.auth : {},
)
if (!sanitized.auth.disableLocalStrategy && sanitized.auth.verify === true) {
sanitized.auth.verify = {}
@@ -149,12 +154,12 @@ export const sanitizeCollection = async (
}
sanitized.auth.loginWithUsername = sanitized.auth.loginWithUsername
? merge(
loginWithUsernameDefaults,
typeof sanitized.auth.loginWithUsername === 'boolean'
? {
...loginWithUsernameDefaults,
...(typeof sanitized.auth.loginWithUsername === 'boolean'
? {}
: sanitized.auth.loginWithUsername,
)
: sanitized.auth.loginWithUsername),
}
: false
sanitized.fields = mergeBaseFields(sanitized.fields, getBaseAuthFields(sanitized.auth))

View File

@@ -1,240 +0,0 @@
import joi from 'joi'
import { endpointsSchema } from '../../config/schema.js'
import {
componentSchema,
customViewSchema,
livePreviewSchema,
} from '../../config/shared/componentSchema.js'
import { openGraphSchema } from '../../config/shared/openGraphSchema.js'
const collectionSchema = joi.object().keys({
slug: joi.string().required(),
access: joi.object({
admin: joi.func(),
create: joi.func(),
delete: joi.func(),
read: joi.func(),
readVersions: joi.func(),
unlock: joi.func(),
update: joi.func(),
}),
admin: joi.object({
components: joi.object({
afterList: joi.array().items(componentSchema),
afterListTable: joi.array().items(componentSchema),
beforeList: joi.array().items(componentSchema),
beforeListTable: joi.array().items(componentSchema),
edit: joi.object({
Description: componentSchema,
PreviewButton: componentSchema,
PublishButton: componentSchema,
SaveButton: componentSchema,
SaveDraftButton: componentSchema,
Upload: componentSchema,
}),
views: joi.object({
Edit: joi.alternatives().try(
componentSchema,
joi.object({
API: joi.alternatives().try(componentSchema, customViewSchema),
Default: joi.alternatives().try(componentSchema, customViewSchema),
LivePreview: joi.alternatives().try(componentSchema, customViewSchema),
Version: joi.alternatives().try(componentSchema, customViewSchema),
Versions: joi.alternatives().try(componentSchema, customViewSchema),
// Relationships
// References
}),
),
List: joi.alternatives().try(
componentSchema,
joi.object({
Component: componentSchema,
actions: joi.array().items(componentSchema),
}),
),
}),
}),
custom: joi.object().pattern(joi.string(), joi.any()),
defaultColumns: joi.array().items(joi.string()),
description: joi
.alternatives()
.try(joi.func(), joi.object().pattern(joi.string(), [joi.string()]), joi.string()),
enableRichTextLink: joi.boolean(),
enableRichTextRelationship: joi.boolean(),
group: joi.alternatives().try(joi.string(), joi.object().pattern(joi.string(), [joi.string()])),
hidden: joi.alternatives().try(joi.boolean(), joi.func()),
hideAPIURL: joi.bool(),
listSearchableFields: joi.array().items(joi.string()),
livePreview: joi.object(livePreviewSchema),
meta: joi.object({
description: joi.string(),
openGraph: openGraphSchema,
}),
pagination: joi.object({
defaultLimit: joi.number(),
limits: joi.array().items(joi.number()),
}),
preview: joi.func(),
useAsTitle: joi.string(),
}),
auth: joi.alternatives().try(
joi.object({
cookies: joi.object().keys({
domain: joi.string(),
sameSite: joi.string(), // TODO: add further specificity with joi.xor
secure: joi.boolean(),
}),
depth: joi.number(),
disableLocalStrategy: joi.boolean().valid(true),
forgotPassword: joi.object().keys({
generateEmailHTML: joi.func(),
generateEmailSubject: joi.func(),
}),
lockTime: joi.number(),
loginWithUsername: joi.alternatives().try(
joi.boolean(),
joi.object().keys({
allowEmailLogin: joi.boolean(),
requireEmail: joi.boolean(),
}),
),
maxLoginAttempts: joi.number(),
removeTokenFromResponses: joi.boolean().valid(true),
strategies: joi.array().items(
joi.object().keys({
name: joi.string().required(),
authenticate: joi.func().required(),
}),
),
tokenExpiration: joi.number(),
useAPIKey: joi.boolean(),
verify: joi.alternatives().try(
joi.boolean(),
joi.object().keys({
generateEmailHTML: joi.func(),
generateEmailSubject: joi.func(),
}),
),
}),
joi.boolean(),
),
custom: joi.object().pattern(joi.string(), joi.any()),
dbName: joi.alternatives().try(joi.string(), joi.func()),
defaultSort: joi.string(),
disableDuplicate: joi.bool(),
endpoints: endpointsSchema,
fields: joi.array(),
graphQL: joi.alternatives().try(
joi.object().keys({
pluralName: joi.string(),
singularName: joi.string(),
}),
joi.boolean(),
),
hooks: joi.object({
afterChange: joi.array().items(joi.func()),
afterDelete: joi.array().items(joi.func()),
afterForgotPassword: joi.array().items(joi.func()),
afterLogin: joi.array().items(joi.func()),
afterLogout: joi.array().items(joi.func()),
afterMe: joi.array().items(joi.func()),
afterOperation: joi.array().items(joi.func()),
afterRead: joi.array().items(joi.func()),
afterRefresh: joi.array().items(joi.func()),
beforeChange: joi.array().items(joi.func()),
beforeDelete: joi.array().items(joi.func()),
beforeLogin: joi.array().items(joi.func()),
beforeOperation: joi.array().items(joi.func()),
beforeRead: joi.array().items(joi.func()),
beforeValidate: joi.array().items(joi.func()),
me: joi.array().items(joi.func()),
refresh: joi.array().items(joi.func()),
}),
labels: joi.object({
plural: joi
.alternatives()
.try(joi.func(), joi.string(), joi.object().pattern(joi.string(), [joi.string()])),
singular: joi
.alternatives()
.try(joi.func(), joi.string(), joi.object().pattern(joi.string(), [joi.string()])),
}),
timestamps: joi.boolean(),
typescript: joi.object().keys({
interface: joi.string(),
}),
upload: joi.alternatives().try(
joi.object({
adapter: joi.string(),
adminThumbnail: joi.alternatives().try(joi.string(), componentSchema),
crop: joi.bool(),
disableLocalStorage: joi.bool(),
externalFileHeaderFilter: joi.func(),
filesRequiredOnCreate: joi.bool(),
focalPoint: joi.bool(),
formatOptions: joi.object().keys({
format: joi.string(),
options: joi.object(),
}),
handlers: joi.array().items(joi.func()),
imageSizes: joi.array().items(
joi
.object()
.keys({
name: joi.string(),
crop: joi.string(), // TODO: add further specificity with joi.xor
height: joi.number().integer().allow(null),
width: joi.number().integer().allow(null),
})
.unknown(),
),
mimeTypes: joi.array().items(joi.string()),
modifyResponseHeaders: joi.func(),
resizeOptions: joi
.object()
.keys({
background: joi.string(),
fastShrinkOnLoad: joi.bool(),
fit: joi.string(),
height: joi.number().allow(null),
kernel: joi.string(),
position: joi.alternatives().try(joi.string(), joi.number()),
width: joi.number().allow(null),
withoutEnlargement: joi.bool(),
})
.allow(null),
staticDir: joi.string(),
tempFileDir: joi.string(),
trimOptions: joi.alternatives().try(
joi.object().keys({
format: joi.string(),
options: joi.object(),
}),
joi.string(),
joi.number(),
),
useTempFiles: joi.bool(),
}),
joi.boolean(),
),
versions: joi.alternatives().try(
joi.object({
drafts: joi.alternatives().try(
joi.object({
autosave: joi.alternatives().try(
joi.boolean(),
joi.object({
interval: joi.number(),
}),
),
validate: joi.boolean(),
}),
joi.boolean(),
),
maxPerDoc: joi.number(),
}),
joi.boolean(),
),
})
export default collectionSchema

View File

@@ -28,7 +28,12 @@ import type {
} from '../../config/types.js'
import type { DBIdentifierName } from '../../database/types.js'
import type { Field } from '../../fields/config/types.js'
import type { CollectionSlug, TypedAuthOperations, TypedCollection } from '../../index.js'
import type {
CollectionSlug,
JsonObject,
TypedAuthOperations,
TypedCollection,
} from '../../index.js'
import type { PayloadRequest, RequestContext } from '../../types/index.js'
import type { SanitizedUploadConfig, UploadConfig } from '../../uploads/types.js'
import type {
@@ -41,7 +46,7 @@ export type DataFromCollectionSlug<TSlug extends CollectionSlug> = TypedCollecti
export type AuthOperationsFromCollectionSlug<TSlug extends CollectionSlug> =
TypedAuthOperations[TSlug]
export type RequiredDataFromCollection<TData extends Record<string, any>> = MarkOptional<
export type RequiredDataFromCollection<TData extends JsonObject> = MarkOptional<
TData,
'createdAt' | 'id' | 'sizes' | 'updatedAt'
>

View File

@@ -2,7 +2,7 @@ import type { BatchLoadFn } from 'dataloader'
import DataLoader from 'dataloader'
import type { PayloadRequest } from '../types/index.js'
import type { JsonValue, PayloadRequest } from '../types/index.js'
import type { TypeWithID } from './config/types.js'
import { isValidID } from '../utilities/isValidID.js'
@@ -119,7 +119,7 @@ const batchAndLoadDocs =
showHiddenFields: Boolean(showHiddenFields),
where: {
id: {
in: ids,
in: ids as JsonValue,
},
},
})

View File

@@ -1,6 +1,6 @@
import crypto from 'crypto'
import type { CollectionSlug } from '../../index.js'
import type { CollectionSlug, JsonObject } from '../../index.js'
import type { Document, PayloadRequest } from '../../types/index.js'
import type {
AfterChangeHook,
@@ -184,7 +184,7 @@ export const createOperation = async <TSlug extends CollectionSlug>(
// beforeChange - Fields
// /////////////////////////////////////
const resultWithLocales = await beforeChange<Record<string, unknown>>({
const resultWithLocales = await beforeChange<JsonObject>({
collection: collectionConfig,
context: req.context,
data,

View File

@@ -1,4 +1,4 @@
import type { CollectionSlug } from '../../index.js'
import type { CollectionSlug, JsonObject, TypeWithID } from '../../index.js'
import type { PayloadRequest } from '../../types/index.js'
import type { BeforeOperationHook, Collection, DataFromCollectionSlug } from '../config/types.js'

View File

@@ -194,7 +194,7 @@ export const duplicateOperation = async <TSlug extends CollectionSlug>(
// beforeChange - Fields
// /////////////////////////////////////
result = await beforeChange<DataFromCollectionSlug<TSlug>>({
result = await beforeChange({
id,
collection: collectionConfig,
context: req.context,
@@ -280,7 +280,7 @@ export const duplicateOperation = async <TSlug extends CollectionSlug>(
// afterChange - Fields
// /////////////////////////////////////
result = await afterChange<DataFromCollectionSlug<TSlug>>({
result = await afterChange({
collection: collectionConfig,
context: req.context,
data: versionDoc,

View File

@@ -263,7 +263,7 @@ export const updateOperation = async <TSlug extends CollectionSlug>(
// beforeChange - Fields
// /////////////////////////////////////
let result = await beforeChange<DataFromCollectionSlug<TSlug>>({
let result = await beforeChange({
id,
collection: collectionConfig,
context: req.context,
@@ -349,7 +349,7 @@ export const updateOperation = async <TSlug extends CollectionSlug>(
// afterChange - Fields
// /////////////////////////////////////
result = await afterChange<DataFromCollectionSlug<TSlug>>({
result = await afterChange({
collection: collectionConfig,
context: req.context,
data,

View File

@@ -236,7 +236,7 @@ export const updateByIDOperation = async <TSlug extends CollectionSlug>(
// beforeChange - Fields
// /////////////////////////////////////
let result = await beforeChange<DataFromCollectionSlug<TSlug>>({
let result = await beforeChange({
id,
collection: collectionConfig,
context: req.context,
@@ -341,7 +341,7 @@ export const updateByIDOperation = async <TSlug extends CollectionSlug>(
// afterChange - Fields
// /////////////////////////////////////
result = await afterChange<DataFromCollectionSlug<TSlug>>({
result = await afterChange({
collection: collectionConfig,
context: req.context,
data,

View File

@@ -43,6 +43,27 @@ export type ClientConfig = {
globals: ClientGlobalConfig[]
} & Omit<SanitizedConfig, 'admin' | 'collections' | 'globals' | ServerOnlyRootProperties>
const serverOnlyConfigProperties: readonly Partial<ServerOnlyRootProperties>[] = [
'endpoints',
'db',
'editor',
'plugins',
'sharp',
'onInit',
'secret',
'hooks',
'bin',
'typescript',
'cors',
'csrf',
'email',
'custom',
'graphQL',
// `admin`, `onInit`, `localization`, `collections`, and `globals` are all handled separately
]
const serverOnlyAdminProperties: readonly Partial<ServerOnlyRootAdminProperties>[] = ['components']
export const createClientConfig = async ({
config,
t,
@@ -53,49 +74,28 @@ export const createClientConfig = async ({
}): Promise<ClientConfig> => {
const clientConfig: ClientConfig = { ...config }
const serverOnlyConfigProperties: Partial<ServerOnlyRootProperties>[] = [
'endpoints',
'db',
'editor',
'plugins',
'sharp',
'onInit',
'secret',
'hooks',
'bin',
'typescript',
'cors',
'csrf',
'email',
'custom',
'graphQL',
// `admin`, `onInit`, `localization`, `collections`, and `globals` are all handled separately
]
serverOnlyConfigProperties.forEach((key) => {
for (const key of serverOnlyConfigProperties) {
if (key in clientConfig) {
delete clientConfig[key]
}
})
}
if ('localization' in clientConfig && clientConfig.localization) {
clientConfig.localization = { ...clientConfig.localization }
clientConfig.localization.locales.forEach((locale) => {
for (const locale of clientConfig.localization.locales) {
delete locale.toString
})
}
}
if ('admin' in clientConfig) {
clientConfig.admin = { ...clientConfig.admin }
const serverOnlyAdminProperties: Partial<ServerOnlyRootAdminProperties>[] = ['components']
serverOnlyAdminProperties.forEach((key) => {
for (const key of serverOnlyAdminProperties) {
if (key in clientConfig.admin) {
delete clientConfig.admin[key]
}
})
}
if ('livePreview' in clientConfig.admin) {
clientConfig.admin.livePreview = { ...clientConfig.admin.livePreview }

View File

@@ -1,7 +1,7 @@
import type { AcceptedLanguages } from '@payloadcms/translations'
import { en } from '@payloadcms/translations/languages/en'
import merge from 'deepmerge'
import { deepMergeSimple } from '@payloadcms/translations/utilities'
import type {
Config,
@@ -17,8 +17,7 @@ import { InvalidConfiguration } from '../errors/index.js'
import { sanitizeGlobals } from '../globals/config/sanitize.js'
import getPreferencesCollection from '../preferences/preferencesCollection.js'
import checkDuplicateCollections from '../utilities/checkDuplicateCollections.js'
import { deepMerge } from '../utilities/deepMerge.js'
import { isPlainObject } from '../utilities/isPlainObject.js'
import { deepMergeWithReactComponents } from '../utilities/deepMerge.js'
import { defaults } from './defaults.js'
const sanitizeAdminConfig = (configToSanitize: Config): Partial<SanitizedConfig> => {
@@ -48,9 +47,7 @@ const sanitizeAdminConfig = (configToSanitize: Config): Partial<SanitizedConfig>
}
export const sanitizeConfig = async (incomingConfig: Config): Promise<SanitizedConfig> => {
const configWithDefaults: Config = merge(defaults, incomingConfig, {
isMergeableObject: isPlainObject,
}) as Config
const configWithDefaults: Config = deepMergeWithReactComponents(defaults, incomingConfig)
if (!configWithDefaults?.serverURL) {
configWithDefaults.serverURL = ''
@@ -163,7 +160,7 @@ export const sanitizeConfig = async (incomingConfig: Config): Promise<SanitizedC
isRoot: true,
})
if (config.editor.i18n && Object.keys(config.editor.i18n).length >= 0) {
config.i18n.translations = deepMerge(config.i18n.translations, config.editor.i18n)
config.i18n.translations = deepMergeSimple(config.i18n.translations, config.editor.i18n)
}
}

View File

@@ -1,217 +0,0 @@
import joi from 'joi'
import { adminViewSchema } from './shared/adminViewSchema.js'
import { componentSchema, livePreviewSchema } from './shared/componentSchema.js'
import { openGraphSchema } from './shared/openGraphSchema.js'
const component = joi.alternatives().try(joi.object().unknown(), joi.func())
export const endpointsSchema = joi.alternatives().try(
joi.array().items(
joi.object({
custom: joi.object().pattern(joi.string(), joi.any()),
handler: joi.alternatives().try(joi.array().items(joi.func()), joi.func()),
method: joi
.string()
.valid('get', 'head', 'post', 'put', 'patch', 'delete', 'connect', 'options'),
path: joi.string(),
root: joi.bool(),
}),
),
joi.boolean(),
)
export default joi.object({
admin: joi.object({
autoLogin: joi.alternatives().try(
joi.object().keys({
email: joi.string(),
password: joi.string(),
prefillOnly: joi.boolean(),
username: joi.string(),
}),
joi.boolean(),
),
avatar: joi.alternatives().try(joi.string(), component),
buildPath: joi.string(),
components: joi.object().keys({
Nav: component,
actions: joi.array().items(component),
afterDashboard: joi.array().items(component),
afterLogin: joi.array().items(component),
afterNavLinks: joi.array().items(component),
beforeDashboard: joi.array().items(component),
beforeLogin: joi.array().items(component),
beforeNavLinks: joi.array().items(component),
graphics: joi.object({
Icon: component,
Logo: component,
}),
logout: joi.object({
Button: component,
}),
providers: joi.array().items(component),
views: joi.alternatives().try(
joi.object({
Account: joi.alternatives().try(component, adminViewSchema),
Dashboard: joi.alternatives().try(component, adminViewSchema),
}),
joi.object().pattern(joi.string(), component),
),
}),
custom: joi.object().pattern(joi.string(), joi.any()),
dateFormat: joi.string(),
disable: joi.bool(),
livePreview: joi.object({
...livePreviewSchema,
collections: joi.array().items(joi.string()),
globals: joi.array().items(joi.string()),
}),
meta: joi.object().keys({
defaultOGImageType: joi.string().valid('off', 'dynamic', 'static'),
description: joi.string(),
icons: joi.array().items(
joi.object().keys({
type: joi.string(),
color: joi.string(),
fetchPriority: joi.string().valid('auto', 'high', 'low'),
media: joi.string(),
rel: joi.string(),
sizes: joi.string(),
url: joi.string(),
}),
),
openGraph: openGraphSchema,
titleSuffix: joi.string(),
}),
routes: joi.object({
account: joi.string(),
createFirstUser: joi.string(),
forgot: joi.string(),
inactivity: joi.string(),
login: joi.string(),
logout: joi.string(),
reset: joi.string(),
unauthorized: joi.string(),
}),
user: joi.string(),
}),
bin: joi.array().items(
joi.object().keys({
key: joi.string(),
scriptPath: joi.string(),
}),
),
collections: joi.array(),
cookiePrefix: joi.string(),
cors: [
joi.string().valid('*'),
joi.array().items(joi.string()),
joi.object().keys({
headers: joi.array().items(joi.string()),
origins: [joi.string().valid('*'), joi.array().items(joi.string())],
}),
],
csrf: joi.array().items(joi.string().allow('')).sparse(),
custom: joi.object().pattern(joi.string(), joi.any()),
db: joi.any(),
debug: joi.boolean(),
defaultDepth: joi.number().min(0).max(30),
defaultMaxTextLength: joi.number(),
editor: joi
.object()
.optional()
.keys({
CellComponent: componentSchema.optional(),
FieldComponent: componentSchema.optional(),
afterReadPromise: joi.func().optional(),
outputSchema: joi.func().optional(),
populationPromise: joi.func().optional(),
validate: joi.func().required(),
})
.unknown(),
email: joi.alternatives().try(joi.object(), joi.func()),
endpoints: endpointsSchema,
globals: joi.array(),
graphQL: joi.object().keys({
disable: joi.boolean(),
disablePlaygroundInProduction: joi.boolean(),
maxComplexity: joi.number(),
mutations: joi.function(),
queries: joi.function(),
schemaOutputFile: joi.string(),
}),
hooks: joi.object().keys({
afterError: joi.func(),
}),
i18n: joi.object(),
indexSortableFields: joi.boolean(),
local: joi.boolean(),
localization: joi.alternatives().try(
joi.object().keys({
defaultLocale: joi.string(),
fallback: joi.boolean(),
localeCodes: joi.array().items(joi.string()),
locales: joi.alternatives().try(
joi.array().items(
joi.object().keys({
code: joi.string(),
fallbackLocale: joi.string(),
label: joi
.alternatives()
.try(
joi.object().pattern(joi.string(), [joi.string()]),
joi.string(),
joi.valid(false),
),
rtl: joi.boolean(),
toString: joi.func(),
}),
),
joi.array().items(joi.string()),
),
}),
joi.boolean(),
),
maxDepth: joi.number().min(0).max(100),
onInit: joi.func(),
plugins: joi.array().items(joi.func()),
routes: joi.object({
admin: joi.string(),
api: joi.string(),
graphQL: joi.string(),
graphQLPlayground: joi.string(),
}),
secret: joi.string(),
serverURL: joi
.string()
.uri()
.allow('')
.custom((value, helper) => {
const urlWithoutProtocol = value.split('//')[1]
if (!urlWithoutProtocol) {
return helper.message({
custom: 'You need to include either "https://" or "http://" in your serverURL.',
})
}
if (urlWithoutProtocol.indexOf('/') > -1) {
return helper.message({
custom:
'Your serverURL cannot have a path. It can only contain a protocol, a domain, and an optional port.',
})
}
return value
}),
sharp: joi.any(),
telemetry: joi.boolean(),
typescript: joi.object({
autoGenerate: joi.boolean(),
declare: joi.alternatives().try(joi.boolean(), joi.object({ ignoreTSError: joi.boolean() })),
outputFile: joi.string(),
schema: joi.array().items(joi.func()),
}),
upload: joi.object(),
})

View File

@@ -1,13 +0,0 @@
import joi from 'joi'
import { componentSchema } from './componentSchema.js'
export const adminViewSchema = joi.array().items(
joi.object().keys({
Component: componentSchema,
exact: joi.bool(),
path: joi.string().required(),
sensitive: joi.bool(),
strict: joi.bool(),
}),
)

View File

@@ -1,31 +0,0 @@
import joi from 'joi'
export const componentSchema = joi.alternatives().try(joi.object().unknown(), joi.func())
export const documentTabSchema = {
condition: joi.func(),
href: joi.alternatives().try(joi.string(), joi.func()).required(),
isActive: joi.alternatives().try(joi.func(), joi.boolean()),
label: joi.alternatives().try(joi.string(), joi.func()).required(),
newTab: joi.boolean(),
pillLabel: joi.alternatives().try(joi.string(), joi.func()),
}
export const customViewSchema = joi.object({
Component: componentSchema,
Tab: joi.alternatives().try(documentTabSchema, componentSchema),
actions: joi.array().items(componentSchema),
path: joi.string(),
})
export const livePreviewSchema = {
breakpoints: joi.array().items(
joi.object({
name: joi.string(),
height: joi.alternatives().try(joi.number(), joi.string()),
label: joi.string(),
width: joi.alternatives().try(joi.number(), joi.string()),
}),
),
url: joi.alternatives().try(joi.string(), joi.func()),
}

View File

@@ -1,16 +0,0 @@
import joi from 'joi'
const ogImageObj = joi.object({
type: joi.string(),
alt: joi.string(),
height: joi.alternatives().try(joi.string(), joi.number()),
url: joi.string(),
width: joi.alternatives().try(joi.string(), joi.number()),
})
export const openGraphSchema = joi.object({
description: joi.string(),
images: joi.alternatives().try(ogImageObj, joi.array().items(ogImageObj)),
siteName: joi.string(),
title: joi.string(),
})

View File

@@ -453,16 +453,15 @@ export type CORSConfig = {
export type Config = {
/** Configure admin dashboard */
admin?: {
/** Automatically log in as a user when visiting the admin dashboard. */
/** Automatically log in as a user */
autoLogin?:
| {
/**
* The email address of the user to login as
*
*/
email?: string
/** The password of the user to login as */
password: string
/** The password of the user to login as. This is only needed if `prefillOnly` is set to true */
password?: string
/**
* If set to true, the login credentials will be prefilled but the user will still need to click the login button.
*
@@ -473,9 +472,9 @@ export type Config = {
username?: string
}
| false
/** Set account profile picture. Options: gravatar, default or a custom React component. */
avatar?: 'default' | 'gravatar' | React.ComponentType<any>
/**
* Add extra and/or replace built-in components with custom components
*

View File

@@ -1,111 +0,0 @@
import type { ValidationResult } from 'joi'
import type { Logger } from 'pino'
import type { SanitizedCollectionConfig } from '../collections/config/types.js'
import type { SanitizedGlobalConfig } from '../globals/config/types.js'
import type { SanitizedConfig } from './types.js'
import collectionSchema from '../collections/config/schema.js'
import fieldSchema, { idField } from '../fields/config/schema.js'
import { fieldAffectsData } from '../fields/config/types.js'
import globalSchema from '../globals/config/schema.js'
import schema from './schema.js'
const validateFields = (
context: string,
entity: SanitizedCollectionConfig | SanitizedGlobalConfig,
): string[] => {
const errors: string[] = []
entity.fields.forEach((field) => {
let idResult: Partial<ValidationResult> = { error: null }
if (fieldAffectsData(field) && field.name === 'id') {
idResult = idField.validate(field, { abortEarly: false })
}
const result = fieldSchema.validate(field, { abortEarly: false })
if (idResult.error) {
idResult.error.details.forEach(({ message }) => {
errors.push(
`${context} "${entity.slug}" > Field${
fieldAffectsData(field) ? ` "${field.name}" >` : ''
} ${message}`,
)
})
}
if (result.error) {
result.error.details.forEach(({ message }) => {
errors.push(
`${context} "${entity.slug}" > Field${
fieldAffectsData(field) ? ` "${field.name}" >` : ''
} ${message}`,
)
})
}
})
return errors
}
const validateCollections = (collections: SanitizedCollectionConfig[]): string[] => {
const errors: string[] = []
collections.forEach((collection) => {
const result = collectionSchema.validate(collection, { abortEarly: false })
if (result.error) {
result.error.details.forEach(({ message }) => {
errors.push(`Collection "${collection.slug}" > ${message}`)
})
}
errors.push(...validateFields('Collection', collection))
})
return errors
}
const validateGlobals = (globals: SanitizedGlobalConfig[]): string[] => {
const errors: string[] = []
globals.forEach((global) => {
const result = globalSchema.validate(global, { abortEarly: false })
if (result.error) {
result.error.details.forEach(({ message }) => {
errors.push(`Globals "${global.slug}" > ${message}`)
})
}
errors.push(...validateFields('Global', global))
})
return errors
}
export const validateSchema = (config: SanitizedConfig, logger: Logger): SanitizedConfig => {
const result = schema.validate(config, {
abortEarly: false,
})
const nestedErrors = [
...validateCollections(config.collections),
...validateGlobals(config.globals),
]
if (result.error || nestedErrors.length > 0) {
logger.error(
`There were ${
(result.error?.details?.length || 0) + nestedErrors.length
} errors validating your Payload config`,
)
let i = 0
if (result.error) {
result.error.details.forEach(({ message }) => {
i += 1
logger.error(`${i}: ${message}`)
})
}
nestedErrors.forEach((message) => {
i += 1
logger.error(`${i}: ${message}`)
})
process.exit(1)
}
return result.value
}

View File

@@ -1,5 +1,8 @@
import httpStatus from 'http-status'
// This gets dynamically reassigned during compilation
export let APIErrorName = 'APIError'
class ExtendableError<TData extends object = { [key: string]: unknown }> extends Error {
data: TData
@@ -14,6 +17,7 @@ class ExtendableError<TData extends object = { [key: string]: unknown }> extends
// show data in cause
cause: data,
})
APIErrorName = this.constructor.name
this.name = this.constructor.name
this.message = message
this.status = status

View File

@@ -5,6 +5,9 @@ import httpStatus from 'http-status'
import { APIError } from './APIError.js'
// This gets dynamically reassigned during compilation
export let ValidationErrorName = 'ValidationError'
export class ValidationError extends APIError<{
collection?: string
errors: { field: string; message: string }[]
@@ -25,5 +28,7 @@ export class ValidationError extends APIError<{
httpStatus.BAD_REQUEST,
results,
)
ValidationErrorName = this.constructor.name
}
}

View File

@@ -1,4 +1,4 @@
export { APIError } from './APIError.js'
export { APIError, APIErrorName } from './APIError.js'
export { AuthenticationError } from './AuthenticationError.js'
export { DuplicateCollection } from './DuplicateCollection.js'
export { DuplicateFieldName } from './DuplicateFieldName.js'
@@ -19,4 +19,4 @@ export { MissingFile } from './MissingFile.js'
export { NotFound } from './NotFound.js'
export { QueryError } from './QueryError.js'
export { ReservedFieldName } from './ReservedFieldName.js'
export { ValidationError } from './ValidationError.js'
export { ValidationError, ValidationErrorName } from './ValidationError.js'

View File

@@ -24,10 +24,18 @@ export { formatFilesize } from '../uploads/formatFilesize.js'
export { isImage } from '../uploads/isImage.js'
export { deepCopyObject } from '../utilities/deepCopyObject.js'
export { deepMerge } from '../utilities/deepMerge.js'
export {
deepCopyObject,
deepCopyObjectComplex,
deepCopyObjectSimple,
} from '../utilities/deepCopyObject.js'
export {
deepMerge,
deepMergeWithCombinedArrays,
deepMergeWithReactComponents,
deepMergeWithSourceArrays,
} from '../utilities/deepMerge.js'
export { fieldSchemaToJSON } from '../utilities/fieldSchemaToJSON.js'
export { getDataByPath } from '../utilities/getDataByPath.js'
@@ -55,3 +63,5 @@ export { wait } from '../utilities/wait.js'
export { default as wordBoundariesRegex } from '../utilities/wordBoundariesRegex.js'
export { versionDefaults } from '../versions/defaults.js'
export { deepMergeSimple } from '@payloadcms/translations/utilities'

View File

@@ -9,12 +9,7 @@ import type {
TextField,
} from './types.js'
import {
InvalidFieldName,
InvalidFieldRelationship,
MissingFieldType,
ReservedFieldName,
} from '../../errors/index.js'
import { InvalidFieldName, InvalidFieldRelationship, MissingFieldType } from '../../errors/index.js'
import { sanitizeFields } from './sanitize.js'
describe('sanitizeFields', () => {
@@ -52,23 +47,6 @@ describe('sanitizeFields', () => {
}).rejects.toThrow(InvalidFieldName)
})
it('should throw on a reserved field name', async () => {
const fields: Field[] = [
{
name: 'hash',
type: 'text',
label: 'hash',
},
]
await expect(async () => {
await sanitizeFields({
config,
fields,
validRelationships: [],
})
}).rejects.toThrow(ReservedFieldName)
})
describe('auto-labeling', () => {
it('should populate label if missing', async () => {
const fields: Field[] = [

View File

@@ -1,3 +1,6 @@
import { deepMergeSimple } from '@payloadcms/translations/utilities'
import type { CollectionConfig } from '../../collections/config/types.js'
import type { Config, SanitizedConfig } from '../../config/types.js'
import type { Field } from './types.js'
@@ -7,9 +10,7 @@ import {
InvalidFieldName,
InvalidFieldRelationship,
MissingFieldType,
ReservedFieldName,
} from '../../errors/index.js'
import { deepMerge } from '../../utilities/deepMerge.js'
import { formatLabels, toWords } from '../../utilities/formatLabels.js'
import { baseBlockFields } from '../baseFields/baseBlockFields.js'
import { baseIDField } from '../baseFields/baseIDField.js'
@@ -18,6 +19,7 @@ import validations from '../validations.js'
import { fieldAffectsData, tabHasName } from './types.js'
type Args = {
collectionConfig?: CollectionConfig
config: Config
existingFieldNames?: Set<string>
fields: Field[]
@@ -40,9 +42,8 @@ type Args = {
validRelationships: null | string[]
}
export const reservedFieldNames = ['__v', 'salt', 'hash', 'file']
export const sanitizeFields = async ({
collectionConfig,
config,
existingFieldNames = new Set(),
fields,
@@ -62,11 +63,6 @@ export const sanitizeFields = async ({
throw new InvalidFieldName(field, field.name)
}
// assert that field names are not one of reserved names
if (fieldAffectsData(field) && reservedFieldNames.includes(field.name)) {
throw new ReservedFieldName(field, field.name)
}
// Auto-label
if (
'name' in field &&
@@ -116,10 +112,12 @@ export const sanitizeFields = async ({
}
if (field.type === 'blocks' && field.blocks) {
field.blocks = field.blocks.map((block) => ({
...block,
fields: block.fields.concat(baseBlockFields),
}))
field.blocks = field.blocks.map((block) => {
return {
...block,
fields: block.fields.concat(baseBlockFields),
}
})
}
if (field.type === 'array' && field.fields) {
@@ -178,7 +176,7 @@ export const sanitizeFields = async ({
}
if (field.editor.i18n && Object.keys(field.editor.i18n).length >= 0) {
config.i18n.translations = deepMerge(config.i18n.translations, field.editor.i18n)
config.i18n.translations = deepMergeSimple(config.i18n.translations, field.editor.i18n)
}
}
if (richTextSanitizationPromises) {

View File

@@ -1,592 +0,0 @@
import joi from 'joi'
import { componentSchema } from '../../config/shared/componentSchema.js'
export const baseAdminComponentFields = joi
.object()
.keys({
Cell: componentSchema,
Description: componentSchema,
Field: componentSchema,
Filter: componentSchema,
})
.default({})
export const baseAdminFields = joi.object().keys({
className: joi.string(),
components: baseAdminComponentFields,
condition: joi.func(),
custom: joi.object().pattern(joi.string(), joi.any()),
description: joi
.alternatives()
.try(joi.string(), joi.object().pattern(joi.string(), [joi.string()]), joi.function()),
disableBulkEdit: joi.boolean().default(false),
disableListColumn: joi.boolean().default(false),
disableListFilter: joi.boolean().default(false),
disabled: joi.boolean().default(false),
hidden: joi.boolean().default(false),
initCollapsed: joi.boolean().default(false),
position: joi.string().valid('sidebar'),
readOnly: joi.boolean().default(false),
style: joi.object().unknown(),
width: joi.string(),
})
export const baseField = joi
.object()
.keys({
access: joi.object().keys({
create: joi.func(),
read: joi.func(),
update: joi.func(),
}),
admin: baseAdminFields.default(),
custom: joi.object().pattern(joi.string(), joi.any()),
hidden: joi.boolean().default(false),
hooks: joi
.object()
.keys({
afterChange: joi.array().items(joi.func()).default([]),
afterRead: joi.array().items(joi.func()).default([]),
beforeChange: joi.array().items(joi.func()).default([]),
beforeDuplicate: joi.array().items(joi.func()).default([]),
beforeValidate: joi.array().items(joi.func()).default([]),
})
.default(),
index: joi.boolean().default(false),
label: joi
.alternatives()
.try(
joi.func(),
joi.object().pattern(joi.string(), [joi.string()]),
joi.string(),
joi.valid(false),
),
localized: joi.boolean().default(false),
required: joi.boolean().default(false),
saveToJWT: joi.alternatives().try(joi.boolean(), joi.string()).default(false),
typescriptSchema: joi.array().items(joi.func()),
unique: joi.boolean().default(false),
validate: joi.func(),
})
.default()
export const idField = baseField.keys({
name: joi.string().valid('id'),
type: joi.string().valid('text', 'number'),
localized: joi.invalid(true),
required: joi.not(false, 0).default(true),
})
export const text = baseField.keys({
name: joi.string().required(),
type: joi.string().valid('text').required(),
admin: baseAdminFields.keys({
autoComplete: joi.string(),
components: baseAdminComponentFields.keys({
Error: componentSchema,
Label: componentSchema,
afterInput: joi.array().items(componentSchema),
beforeInput: joi.array().items(componentSchema),
}),
placeholder: joi
.alternatives()
.try(joi.object().pattern(joi.string(), [joi.string()]), joi.string()),
rtl: joi.boolean(),
}),
defaultValue: joi.alternatives().try(joi.string().allow(''), joi.func()),
hasMany: joi.boolean().default(false),
maxLength: joi.number(),
maxRows: joi.number().when('hasMany', { is: joi.not(true), then: joi.forbidden() }),
minLength: joi.number(),
minRows: joi.number().when('hasMany', { is: joi.not(true), then: joi.forbidden() }),
})
export const number = baseField.keys({
name: joi.string().required(),
type: joi.string().valid('number').required(),
admin: baseAdminFields.keys({
autoComplete: joi.string(),
components: baseAdminComponentFields.keys({
Error: componentSchema,
Label: componentSchema,
afterInput: joi
.array()
.items(componentSchema)
.when('hasMany', { not: true, otherwise: joi.forbidden() }),
beforeInput: joi
.array()
.items(componentSchema)
.when('hasMany', { not: true, otherwise: joi.forbidden() }),
}),
placeholder: joi.string(),
step: joi.number(),
}),
defaultValue: joi
.alternatives()
.try(
joi.number(),
joi.func(),
joi.array().when('hasMany', { not: true, then: joi.forbidden() }),
),
hasMany: joi.boolean().default(false),
max: joi.number(),
maxRows: joi.number().when('hasMany', { is: joi.not(true), then: joi.forbidden() }),
min: joi.number(),
minRows: joi.number().when('hasMany', { is: joi.not(true), then: joi.forbidden() }),
})
export const textarea = baseField.keys({
name: joi.string().required(),
type: joi.string().valid('textarea').required(),
admin: baseAdminFields.keys({
components: baseAdminComponentFields.keys({
Error: componentSchema,
Label: componentSchema,
afterInput: joi.array().items(componentSchema),
beforeInput: joi.array().items(componentSchema),
}),
placeholder: joi.string(),
rows: joi.number(),
rtl: joi.boolean(),
}),
defaultValue: joi.alternatives().try(joi.string().allow(''), joi.func()),
maxLength: joi.number(),
minLength: joi.number(),
})
export const email = baseField.keys({
name: joi.string().required(),
type: joi.string().valid('email').required(),
admin: baseAdminFields.keys({
autoComplete: joi.string(),
components: baseAdminComponentFields.keys({
Error: componentSchema,
Label: componentSchema,
afterInput: joi.array().items(componentSchema),
beforeInput: joi.array().items(componentSchema),
}),
placeholder: joi.string(),
}),
defaultValue: joi.alternatives().try(joi.string().allow(''), joi.func()),
maxLength: joi.number(),
minLength: joi.number(),
})
export const code = baseField.keys({
name: joi.string().required(),
type: joi.string().valid('code').required(),
admin: baseAdminFields.keys({
components: baseAdminComponentFields.keys({
Error: componentSchema,
Label: componentSchema,
}),
editorOptions: joi.object().unknown(), // Editor['options'] @monaco-editor/react
language: joi.string(),
}),
defaultValue: joi.alternatives().try(joi.string().allow(''), joi.func()),
})
export const json = baseField.keys({
name: joi.string().required(),
type: joi.string().valid('json').required(),
admin: baseAdminFields.keys({
components: baseAdminComponentFields.keys({
Error: componentSchema,
Label: componentSchema,
}),
editorOptions: joi.object().unknown(), // Editor['options'] @monaco-editor/react
}),
defaultValue: joi.alternatives().try(joi.array(), joi.func(), joi.object()),
jsonSchema: joi.object().unknown(),
})
export const select = baseField.keys({
name: joi.string().required(),
type: joi.string().valid('select').required(),
admin: baseAdminFields.keys({
components: baseAdminComponentFields.keys({
Error: componentSchema,
Label: componentSchema,
}),
isClearable: joi.boolean().default(false),
isSortable: joi.boolean().default(false),
}),
dbName: joi.alternatives().try(joi.string(), joi.func()),
defaultValue: joi
.alternatives()
.try(joi.string().allow(''), joi.array().items(joi.string().allow('')), joi.func()),
enumName: joi.alternatives().try(joi.string(), joi.func()),
hasMany: joi.boolean().default(false),
options: joi
.array()
.min(1)
.items(
joi.alternatives().try(
joi.string(),
joi.object({
label: joi
.alternatives()
.try(joi.func(), joi.string(), joi.object().pattern(joi.string(), [joi.string()])),
value: joi.string().required().allow(''),
}),
),
)
.required(),
})
export const radio = baseField.keys({
name: joi.string().required(),
type: joi.string().valid('radio').required(),
admin: baseAdminFields.keys({
components: baseAdminComponentFields.keys({
Error: componentSchema,
Label: componentSchema,
}),
layout: joi.string().valid('vertical', 'horizontal'),
}),
defaultValue: joi.alternatives().try(joi.string().allow(''), joi.func()),
enumName: joi.alternatives().try(joi.string(), joi.func()),
options: joi
.array()
.min(1)
.items(
joi.alternatives().try(
joi.string(),
joi.object({
label: joi
.alternatives()
.try(joi.func(), joi.string(), joi.object().pattern(joi.string(), [joi.string()]))
.required(),
value: joi.string().required().allow(''),
}),
),
)
.required(),
})
export const row = baseField.keys({
type: joi.string().valid('row').required(),
admin: baseAdminFields.default(),
fields: joi.array().items(joi.link('#field')),
})
export const collapsible = baseField.keys({
type: joi.string().valid('collapsible').required(),
admin: baseAdminFields
.keys({
components: baseAdminComponentFields
.keys({
RowLabel: componentSchema.optional(),
})
.default({}),
})
.default({}),
fields: joi.array().items(joi.link('#field')),
label: joi.alternatives().conditional('admin.components.RowLabel', {
is: joi.exist(),
otherwise: joi
.alternatives()
.try(joi.string(), joi.object().pattern(joi.string(), [joi.string()]), joi.function())
.required(),
then: joi
.alternatives()
.try(joi.string(), joi.object().pattern(joi.string(), [joi.string()]), joi.function())
.optional(),
}),
})
const tab = baseField.keys({
name: joi.string().when('localized', { is: joi.exist(), then: joi.required() }),
description: joi.alternatives().try(joi.string(), componentSchema),
fields: joi.array().items(joi.link('#field')).required(),
interfaceName: joi.string().when('name', { not: joi.exist(), then: joi.forbidden() }),
label: joi
.alternatives()
.try(joi.string(), joi.object().pattern(joi.string(), [joi.string()]))
.when('name', { is: joi.not(), then: joi.required() }),
localized: joi.boolean(),
saveToJWT: joi.alternatives().try(joi.boolean(), joi.string()),
})
export const tabs = baseField.keys({
type: joi.string().valid('tabs').required(),
admin: baseAdminFields.keys({
description: joi.forbidden(),
}),
fields: joi.forbidden(),
localized: joi.forbidden(),
tabs: joi.array().items(tab).required(),
})
export const group = baseField.keys({
name: joi.string().required(),
type: joi.string().valid('group').required(),
admin: baseAdminFields.keys({
hideGutter: joi.boolean().default(true),
}),
defaultValue: joi.alternatives().try(joi.object(), joi.func()),
fields: joi.array().items(joi.link('#field')),
interfaceName: joi.string(),
})
export const array = baseField.keys({
name: joi.string().required(),
type: joi.string().valid('array').required(),
admin: baseAdminFields
.keys({
components: baseAdminComponentFields
.keys({
RowLabel: componentSchema,
})
.default({}),
isSortable: joi.boolean(),
})
.default({}),
dbName: joi.alternatives().try(joi.string(), joi.func()),
defaultValue: joi.alternatives().try(joi.array().items(joi.object()), joi.func()),
fields: joi.array().items(joi.link('#field')).required(),
interfaceName: joi.string(),
labels: joi.object({
plural: joi
.alternatives()
.try(joi.string(), joi.object().pattern(joi.string(), [joi.string()])),
singular: joi
.alternatives()
.try(joi.string(), joi.object().pattern(joi.string(), [joi.string()])),
}),
maxRows: joi.number(),
minRows: joi.number(),
})
export const upload = baseField.keys({
name: joi.string().required(),
type: joi.string().valid('upload').required(),
admin: baseAdminFields.keys({
components: baseAdminComponentFields.keys({
Error: componentSchema,
Label: componentSchema,
}),
}),
defaultValue: joi.alternatives().try(joi.object(), joi.func()),
filterOptions: joi.alternatives().try(joi.object(), joi.func()),
maxDepth: joi.number(),
relationTo: joi.string().required(),
})
export const checkbox = baseField.keys({
name: joi.string().required(),
type: joi.string().valid('checkbox').required(),
admin: baseAdminFields.keys({
components: baseAdminComponentFields.keys({
Error: componentSchema,
Label: componentSchema,
afterInput: joi.array().items(componentSchema),
beforeInput: joi.array().items(componentSchema),
}),
}),
defaultValue: joi.alternatives().try(joi.boolean(), joi.func()),
})
export const point = baseField.keys({
name: joi.string().required(),
type: joi.string().valid('point').required(),
admin: baseAdminFields.keys({
components: baseAdminComponentFields.keys({
Error: componentSchema,
Label: componentSchema,
afterInput: joi.array().items(componentSchema),
beforeInput: joi.array().items(componentSchema),
}),
}),
defaultValue: joi.alternatives().try(joi.array().items(joi.number()).max(2).min(2), joi.func()),
})
export const relationship = baseField.keys({
name: joi.string().required(),
type: joi.string().valid('relationship').required(),
admin: baseAdminFields.keys({
allowCreate: joi.boolean().default(true),
components: baseAdminComponentFields.keys({
Error: componentSchema,
Label: componentSchema,
}),
isSortable: joi.boolean().default(false),
sortOptions: joi.alternatives().conditional(joi.ref('...relationTo'), {
is: joi.string(),
otherwise: joi.object().pattern(joi.string(), joi.string()),
then: joi.string(),
}),
}),
defaultValue: joi.alternatives().try(joi.func()),
filterOptions: joi.alternatives().try(joi.object(), joi.func()),
hasMany: joi.boolean().default(false),
max: joi
.number()
.when('hasMany', { is: joi.not(true), then: joi.forbidden() })
.warning('deprecated', { message: 'Use maxRows instead.' }),
maxDepth: joi.number(),
maxRows: joi.number().when('hasMany', { is: joi.not(true), then: joi.forbidden() }),
min: joi
.number()
.when('hasMany', { is: joi.not(true), then: joi.forbidden() })
.warning('deprecated', { message: 'Use minRows instead.' }),
minRows: joi.number().when('hasMany', { is: joi.not(true), then: joi.forbidden() }),
relationTo: joi.alternatives().try(joi.string().required(), joi.array().items(joi.string())),
})
export const blocks = baseField.keys({
name: joi.string().required(),
type: joi.string().valid('blocks').required(),
admin: baseAdminFields
.keys({
isSortable: joi.boolean(),
})
.default({}),
blocks: joi
.array()
.items(
joi.object({
slug: joi.string().required(),
admin: joi.object().keys({
components: joi.object().keys({
Label: componentSchema,
}),
custom: joi.object().pattern(joi.string(), joi.any()),
}),
custom: joi.object().pattern(joi.string(), joi.any()),
dbName: joi.alternatives().try(joi.string(), joi.func()),
fields: joi.array().items(joi.link('#field')),
graphQL: joi.object().keys({
singularName: joi.string(),
}),
imageAltText: joi.string(),
imageURL: joi.string(),
interfaceName: joi.string(),
labels: joi.object({
plural: joi
.alternatives()
.try(joi.string(), joi.object().pattern(joi.string(), [joi.string()])),
singular: joi
.alternatives()
.try(joi.string(), joi.object().pattern(joi.string(), [joi.string()])),
}),
}),
)
.required(),
defaultValue: joi.alternatives().try(joi.array().items(joi.object()), joi.func()),
labels: joi.object({
plural: joi
.alternatives()
.try(joi.string(), joi.object().pattern(joi.string(), [joi.string()])),
singular: joi
.alternatives()
.try(joi.string(), joi.object().pattern(joi.string(), [joi.string()])),
}),
maxRows: joi.number(),
minRows: joi.number(),
})
export const richText = baseField.keys({
name: joi.string().required(),
type: joi.string().valid('richText').required(),
admin: baseAdminFields.keys({
components: baseAdminComponentFields.keys({
Error: componentSchema,
Label: componentSchema,
}),
}),
defaultValue: joi.alternatives().try(joi.array().items(joi.object()), joi.func(), joi.object()),
editor: joi
.object()
.keys({
CellComponent: componentSchema.optional(),
FieldComponent: componentSchema.optional(),
afterReadPromise: joi.func().optional(),
graphQLPopulationPromises: joi.func().optional(),
outputSchema: joi.func().optional(),
validate: joi.func().required(),
})
.unknown(),
maxDepth: joi.number(),
})
export const date = baseField.keys({
name: joi.string().required(),
type: joi.string().valid('date').required(),
admin: baseAdminFields.keys({
components: baseAdminComponentFields.keys({
Error: componentSchema,
Label: componentSchema,
afterInput: joi.array().items(componentSchema),
beforeInput: joi.array().items(componentSchema),
}),
date: joi.object({
displayFormat: joi.string(),
maxDate: joi.date(),
maxTime: joi.date(),
minDate: joi.date(),
minTime: joi.date(),
monthsToShow: joi.number(),
overrides: joi.object().unknown(),
pickerAppearance: joi.string(),
timeFormat: joi.string(),
timeIntervals: joi.number(),
}),
placeholder: joi.string(),
}),
defaultValue: joi.alternatives().try(joi.string(), joi.func()),
})
export const ui = joi.object().keys({
name: joi.string().required(),
type: joi.string().valid('ui').required(),
admin: joi
.object()
.keys({
components: joi
.object()
.keys({
Cell: componentSchema,
Field: componentSchema,
})
.default({}),
condition: joi.func(),
custom: joi.object().pattern(joi.string(), joi.any()),
disableListColumn: joi.boolean().default(false),
position: joi.string().valid('sidebar'),
width: joi.string(),
})
.default(),
custom: joi.object().pattern(joi.string(), joi.any()),
label: joi.alternatives().try(joi.string(), joi.object().pattern(joi.string(), [joi.string()])),
})
const fieldSchema = joi
.alternatives()
.try(
text,
number,
textarea,
email,
code,
json,
select,
group,
array,
row,
collapsible,
tabs,
radio,
relationship,
checkbox,
upload,
richText,
blocks,
date,
point,
ui,
)
.id('field')
export default fieldSchema

View File

@@ -1,29 +1,31 @@
import type { PayloadRequest } from '../types/index.js'
import type { JsonValue, PayloadRequest } from '../types/index.js'
import { deepCopyObject } from '../utilities/deepCopyObject.js'
import { deepCopyObjectSimple } from '../utilities/deepCopyObject.js'
type Args = {
defaultValue: unknown
defaultValue: ((args: any) => JsonValue) | any
locale: string | undefined
user: PayloadRequest['user']
value?: unknown
value?: JsonValue
}
const getValueWithDefault = ({ defaultValue, locale, user, value }: Args): unknown => {
export const getDefaultValue = async ({
defaultValue,
locale,
user,
value,
}: Args): Promise<JsonValue> => {
if (typeof value !== 'undefined') {
return value
}
if (defaultValue && typeof defaultValue === 'function') {
return defaultValue({ locale, user })
return await defaultValue({ locale, user })
}
if (typeof defaultValue === 'object') {
return deepCopyObject(defaultValue)
return deepCopyObjectSimple(defaultValue)
}
return defaultValue
}
// eslint-disable-next-line no-restricted-exports
export default getValueWithDefault

View File

@@ -1,24 +1,24 @@
import type { SanitizedCollectionConfig } from '../../../collections/config/types.js'
import type { SanitizedGlobalConfig } from '../../../globals/config/types.js'
import type { PayloadRequest, RequestContext } from '../../../types/index.js'
import type { JsonObject, PayloadRequest, RequestContext } from '../../../types/index.js'
import { deepCopyObject } from '../../../utilities/deepCopyObject.js'
import { deepCopyObjectSimple } from '../../../utilities/deepCopyObject.js'
import { traverseFields } from './traverseFields.js'
type Args<T> = {
type Args<T extends JsonObject> = {
collection: SanitizedCollectionConfig | null
context: RequestContext
/**
* The data before hooks
*/
data: Record<string, unknown> | T
data: T
/**
* The data after hooks
*/
doc: Record<string, unknown> | T
doc: T
global: SanitizedGlobalConfig | null
operation: 'create' | 'update'
previousDoc: Record<string, unknown> | T
previousDoc: T
req: PayloadRequest
}
@@ -26,7 +26,7 @@ type Args<T> = {
* This function is responsible for the following actions, in order:
* - Execute field hooks
*/
export const afterChange = async <T extends Record<string, unknown>>({
export const afterChange = async <T extends JsonObject>({
collection,
context,
data,
@@ -36,7 +36,7 @@ export const afterChange = async <T extends Record<string, unknown>>({
previousDoc,
req,
}: Args<T>): Promise<T> => {
const doc = deepCopyObject(incomingDoc)
const doc = deepCopyObjectSimple(incomingDoc)
await traverseFields({
collection,

View File

@@ -1,7 +1,7 @@
import type { RichTextAdapter } from '../../../admin/RichText.js'
import type { SanitizedCollectionConfig } from '../../../collections/config/types.js'
import type { SanitizedGlobalConfig } from '../../../globals/config/types.js'
import type { PayloadRequest, RequestContext } from '../../../types/index.js'
import type { JsonObject, PayloadRequest, RequestContext } from '../../../types/index.js'
import type { Field, TabAsField } from '../../config/types.js'
import { MissingEditorProp } from '../../../errors/index.js'
@@ -12,8 +12,8 @@ import { traverseFields } from './traverseFields.js'
type Args = {
collection: SanitizedCollectionConfig | null
context: RequestContext
data: Record<string, unknown>
doc: Record<string, unknown>
data: JsonObject
doc: JsonObject
field: Field | TabAsField
global: SanitizedGlobalConfig | null
operation: 'create' | 'update'
@@ -25,11 +25,11 @@ type Args = {
* The parent's schemaPath (path without indexes).
*/
parentSchemaPath: string[]
previousDoc: Record<string, unknown>
previousSiblingDoc: Record<string, unknown>
previousDoc: JsonObject
previousSiblingDoc: JsonObject
req: PayloadRequest
siblingData: Record<string, unknown>
siblingDoc: Record<string, unknown>
siblingData: JsonObject
siblingDoc: JsonObject
}
// This function is responsible for the following actions, in order:
@@ -101,11 +101,11 @@ export const promise = async ({
operation,
path: fieldPath,
previousDoc,
previousSiblingDoc: previousDoc[field.name] as Record<string, unknown>,
previousSiblingDoc: previousDoc[field.name] as JsonObject,
req,
schemaPath: fieldSchemaPath,
siblingData: (siblingData?.[field.name] as Record<string, unknown>) || {},
siblingDoc: siblingDoc[field.name] as Record<string, unknown>,
siblingData: (siblingData?.[field.name] as JsonObject) || {},
siblingDoc: siblingDoc[field.name] as JsonObject,
})
break
@@ -128,11 +128,11 @@ export const promise = async ({
operation,
path: [...fieldPath, i],
previousDoc,
previousSiblingDoc: previousDoc?.[field.name]?.[i] || ({} as Record<string, unknown>),
previousSiblingDoc: previousDoc?.[field.name]?.[i] || ({} as JsonObject),
req,
schemaPath: fieldSchemaPath,
siblingData: siblingData?.[field.name]?.[i] || {},
siblingDoc: { ...row } || {},
siblingDoc: ({ ...(row as JsonObject) } as JsonObject) || {},
}),
)
})
@@ -147,7 +147,9 @@ export const promise = async ({
if (Array.isArray(rows)) {
const promises = []
rows.forEach((row, i) => {
const block = field.blocks.find((blockType) => blockType.slug === row.blockType)
const block = field.blocks.find(
(blockType) => blockType.slug === (row as JsonObject).blockType,
)
if (block) {
promises.push(
@@ -161,12 +163,11 @@ export const promise = async ({
operation,
path: [...fieldPath, i],
previousDoc,
previousSiblingDoc:
previousDoc?.[field.name]?.[i] || ({} as Record<string, unknown>),
previousSiblingDoc: previousDoc?.[field.name]?.[i] || ({} as JsonObject),
req,
schemaPath: fieldSchemaPath,
siblingData: siblingData?.[field.name]?.[i] || {},
siblingDoc: { ...row } || {},
siblingDoc: ({ ...(row as JsonObject) } as JsonObject) || {},
}),
)
}
@@ -205,9 +206,9 @@ export const promise = async ({
let tabPreviousSiblingDoc = siblingDoc
if (tabHasName(field)) {
tabSiblingData = siblingData[field.name] as Record<string, unknown>
tabSiblingDoc = siblingDoc[field.name] as Record<string, unknown>
tabPreviousSiblingDoc = previousDoc[field.name] as Record<string, unknown>
tabSiblingData = siblingData[field.name] as JsonObject
tabSiblingDoc = siblingDoc[field.name] as JsonObject
tabPreviousSiblingDoc = previousDoc[field.name] as JsonObject
}
await traverseFields({

View File

@@ -1,6 +1,6 @@
import type { SanitizedCollectionConfig } from '../../../collections/config/types.js'
import type { SanitizedGlobalConfig } from '../../../globals/config/types.js'
import type { PayloadRequest, RequestContext } from '../../../types/index.js'
import type { JsonObject, PayloadRequest, RequestContext } from '../../../types/index.js'
import type { Field, TabAsField } from '../../config/types.js'
import { promise } from './promise.js'
@@ -8,18 +8,18 @@ import { promise } from './promise.js'
type Args = {
collection: SanitizedCollectionConfig | null
context: RequestContext
data: Record<string, unknown>
doc: Record<string, unknown>
data: JsonObject
doc: JsonObject
fields: (Field | TabAsField)[]
global: SanitizedGlobalConfig | null
operation: 'create' | 'update'
path: (number | string)[]
previousDoc: Record<string, unknown>
previousSiblingDoc: Record<string, unknown>
previousDoc: JsonObject
previousSiblingDoc: JsonObject
req: PayloadRequest
schemaPath: string[]
siblingData: Record<string, unknown>
siblingDoc: Record<string, unknown>
siblingData: JsonObject
siblingDoc: JsonObject
}
export const traverseFields = async ({

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