Compare commits

...

44 Commits

Author SHA1 Message Date
Jacob Fletcher
6124e26468 Merge branch 'main' into perf/field-store 2025-05-16 15:31:40 -04:00
Germán Jabloñski
d4899b84cc fix(templates): make images visible in live preview if it is not running on port 3000 (#12432)
I couldn't find much information on the internet about
`__NEXT_PRIVATE_ORIGIN`, but I could observe that when port 3000 was
busy and 3001 was used, `NEXT_PUBLIC_SERVER_URL` was
`http://localhost:3000`, while `__NEXT_PRIVATE_ORIGIN` was
`http://localhost:3001`.

Fixes #12431
2025-05-16 13:57:57 -03:00
Anyu Jiang
6fb2beb983 fix(ui): render missing group children fields for unnamed group (#12433)
### What?
Basically an unnamed group moves all of its children to the same level
with the group. When another field at the same level has a unique access
setting, the permissions will return a json of permissions for each
fields at the same level instead of return a default `true` value. For
traditional group field, there will be a `fields` property inside the
permissions object, so it can use ```permissions={permissions === true ?
permissions : permissions?.fields``` as the attribution of
<RenderFields> in `packages/ui/src/fields/Group/index.tsx`. Right now,
since we somehow "promote" the group's children to the upper level,
which makes the `fields` property no longer exists in the `permissions`
object. Hence, the `permissions?.fields` mentioned above will always be
undefined, which will lead to return null for this field, because the
getFieldPermissions will always get read permission as undefined.

### Why?
The only reason we use `permissions : permissions?.fields` before
because the traditional group field moves all its children to a child
property `fields`. Since we somehow promoted those children to upper
level, so there is no need to access the fields property anymore.

### How?
For the permissions attribute for unnamed group's <RenderFields>, simple
pass in `permissions={permissions}` instead of `{permissions === true ?
permissions : permissions?.fields}`, since you have already gotten all
you want in permissions. No worry about the extra permission property
brought in(the access permission in the unnamed group level), because
`getFieldPermissions` will filter those redundant ones out.

Fixes #12430
2025-05-16 16:00:26 +00:00
Elliot DeNolf
4166621966 templates: bump for v3.38.0 (#12434)
🤖 Automated bump of templates for v3.38.0

Triggered by user: @paulpopus

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-05-16 08:46:56 -07:00
Paul
e395a0aa66 chore: add ignores .next folder in eslint config for templates template (#12423)
The automated PR will override this config in other templates, so I'm
just copying it into the base template eslint config

```
 {
    ignores: ['.next/'],
  },
```
2025-05-16 10:47:05 -04:00
ch-jwoo
cead312d4b fix(plugin-seo): fix genImageResponse result parsing (#12301)
<!--

Thank you for the PR! Please go through the checklist below and make
sure you've completed all the steps.

Please review the
[CONTRIBUTING.md](https://github.com/payloadcms/payload/blob/main/CONTRIBUTING.md)
document in this repository if you haven't already.

The following items will ensure that your PR is handled as smoothly as
possible:

- PR Title must follow conventional commits format. For example, `feat:
my new feature`, `fix(plugin-seo): my fix`.
- Minimal description explained as if explained to someone not
immediately familiar with the code.
- Provide before/after screenshots or code diffs if applicable.
- Link any related issues/discussions from GitHub or Discord.
- Add review comments if necessary to explain to the reviewer the logic
behind a change

### What?

### Why?

### How?

Fixes #

-->


### What?
`Auto-generate` button of Meta image doesn't work

### Why?
`/plugin-seo/generate-image` return imageId as below when using
`genImageResponse.text()`.
"\"result\":\"68139a9d0effac229865fbc9\""

### How?
Change `text()` to `json()` to parse the response.
2025-05-16 09:50:12 -04:00
Jacob Fletcher
5342d303ea poc 2025-05-15 17:36:26 -04:00
Jacob Fletcher
766236f38e wip 2025-05-15 17:36:23 -04:00
Sasha
219fd01717 fix(db-postgres): allow the same block slug in different places with a different localized value (#12414)
Fixes https://github.com/payloadcms/payload/issues/12409
Now Payload automatically resolves table names conflicts in those cases,
as well as Drizzle relation names.

---------

Co-authored-by: Dan Ribbens <dan.ribbens@gmail.com>
2025-05-15 16:48:41 -04:00
Sasha
1f6efe9a46 fix: respect hidden: true for virtual fields that have reference to a relationship field (#12219)
Previously, `hidden: true` on a virtual field that references a
relationship field didn't work. Now, this field doesn't get calculated
if there's `hidden: true` and no `showHiddenFields` was passed.

Co-authored-by: Dan Ribbens <dan.ribbens@gmail.com>
2025-05-15 16:48:08 -04:00
Jarrod Flesch
88769c8244 feat(ui): extracts relationship input for external use (#12339) 2025-05-15 14:54:26 -04:00
Jarrod Flesch
bd6ee317c1 fix(ui): req not being threaded through to views (#12213) 2025-05-15 14:49:37 -04:00
Elliot DeNolf
561708720d chore(release): v3.38.0 [skip ci] 2025-05-15 14:39:34 -04:00
Sasha
58fc2f9a74 fix(db-postgres): build near sort query properly for point fields (#12240)
Continuation of https://github.com/payloadcms/payload/pull/12185 and fix
https://github.com/payloadcms/payload/issues/12221

The mentioned PR introduced auto sorting by the point field when a
`near` query is used, but it didn't build actual needed query to order
results by their distance to a _given_ (from the `near` query) point.

Now, we build:
```sql
order by pont_field <-> ST_SetSRID(ST_MakePoint(lng, lat), 4326)
```

Which does what we want
2025-05-15 13:45:33 -04:00
Sasha
5fce501589 fix(db-postgres): dbName in arrays regression with long generated drizzle relation names (#12237)
Fixes https://github.com/payloadcms/payload/issues/12136 which caused by
regression from https://github.com/payloadcms/payload/pull/11995

The previous PR solved an issue where the generated drizzle relation
name was too long because of Payload field names, for example
```
{
  name: 'thisIsALongFieldNameThatWillCauseAPostgresErrorEvenThoughWeSetAShorterDBName',
  dbName: 'shortname',
  type: 'array',
  fields: [
    {
      name: 'nested_field_1',
      type: 'array',
      dbName: 'short_nested_1',
      fields: [],
    },
    {
      name: 'nested_field_2',
      type: 'text',
    },
  ],
},
```
But it caused regression, when custom `dbName` vice versa caused long
relation names:
```
export const Header: GlobalConfig = {
  slug: 'header',
  fields: [
    {
      name: 'itemsLvl1',
      type: 'array',
      dbName: 'header_items_lvl1',
      fields: [
        {
          name: 'label',
          type: 'text',
        },
        {
          name: 'itemsLvl2',
          type: 'array',
          dbName: 'header_items_lvl2',
          fields: [
            {
              name: 'label',
              type: 'text',
            },
            {
              name: 'itemsLvl3',
              type: 'array',
              dbName: 'header_items_lvl3',
              fields: [
                {
                  name: 'label',
                  type: 'text',
                },
                {
                  name: 'itemsLvl4',
                  type: 'array',
                  dbName: 'header_items_lvl4',
                  fields: [
                    {
                      name: 'label',
                      type: 'text',
                    },
                  ],
                },
              ],
            },
          ],
        },
      ],
    },
  ],
}
```

Notice if you calculate the generated relation name for `itemsLvl4` you
get:

`header__header_items_lvl1__header_items_lvl2__header_items_lvl3_header_items_lvl4`
- 81 characters, Drizzle, for joining shrink the alias to 63 characters
-`header__header_items_lvl1__header_items_lvl2__header_items_lvl3` and
Postgres throws:
```
error: table name "header__header_items_lvl1__header_items_lvl2__header_items_lvl3" specified more than once
```
2025-05-15 13:40:24 -04:00
Paul
3e7db302ee fix(richtext-lexical): newTab not being able to be checked to true by default (#12389)
Previously the value of new tab checkbox in the link feature was not
able to be set to true by default because we were passing `false` as a
default value.

This fixes that and adds test coverage for customising that link drawer.
2025-05-15 15:57:23 +00:00
Jarrod Flesch
7498d09f1c fix(next): tells webpack not to bundle the require-in-the-middle pkg (#12417) 2025-05-15 11:21:23 -04:00
Dan Ribbens
3edfd7cc6d fix(db-postgres): v2-v3 migration errors with relation already exists (#12310)
This fixes issues identified in the predefined migration for
postgres v2-v3 including the following:


### relation already exists
Can error with the following: 
```ts
{
  err: [DatabaseError],
  msg: 'Error running migration 20250502_020052_relationships_v2_v3 column "relation_id" of relation "table_name" already exists.'
}
```
This was happening when you run a migration with both a required
relationship or upload field and no schema specified in the db adapter.
When both of these are true the function that replaces `ADD COLUMN` and
`ALTER COLUMN` in order to add `NOT NULL` constraints for requried
fields, wasn't working. This resulted in the `ADD COLUMN` statement from
being being called multiple times instead of altering it after data had
been copied over.

### camelCase column change

Enum columns from using `select` or `radio` have changed from camelCase
to snake case in v3. This change was not accounted for in the
relationship migration and needed to be accounted for.

### DROP CONSTRAINT

It was pointed out by
[here](https://github.com/payloadcms/payload/issues/10162#issuecomment-2610018940)
that the `DROP CONSTRAINT` needs to include `IF EXISTS` so that it can
continue if the contraint was already removed in a previous statement.

fixes https://github.com/payloadcms/payload/issues/10162
2025-05-15 09:02:15 -04:00
Dmitrijs Trifonovs
77bb7e3638 feat: add latvian language support (#12363)
This PR adds Latvian language support, based on the instructions
provided in the documentation
2025-05-15 03:15:50 +00:00
Sasha
8ebadd4190 fix(ui): respect filterOptions: { id: { in: [] } } (#12408)
Fixes the issue where this returns all the documents:
```
{
  name: 'post',
  type: 'relationship',
  relationTo: 'posts',
  filterOptions: { id: { in: [] } }
}
```

The issue isn't with the Local API but with how we send the query to the
REST API through `qs.stringify`. `qs.stringify({ id: { in: [] } }`
becomes `""`, so the server ignores the original query. I don't think
it's possible to encode empty arrays with this library
https://github.com/sindresorhus/query-string/issues/231, so I just made
sanitization to `{ exists: false }` for this case.
2025-05-14 22:13:15 -04:00
Paul
e258cd73ef feat: allow group fields to have an optional name (#12318)
Adds the ability to completely omit `name` from group fields now so that
they're entirely presentational.

New config:
```ts
import type { CollectionConfig } from 'payload'

export const ExampleCollection: CollectionConfig = {
  slug: 'posts',
  fields: [
    {
      label: 'Page header',
      type: 'group', // required
      fields: [
        {
          name: 'title',
          type: 'text',
          required: true,
        },
      ],
    },
  ],
}
```

will create
<img width="332" alt="image"
src="https://github.com/user-attachments/assets/10b4315e-92d6-439e-82dd-7c815a844035"
/>


but the data response will still be

```
{
    "createdAt": "2025-05-05T13:42:20.326Z",
    "updatedAt": "2025-05-05T13:42:20.326Z",
    "title": "example post",
    "id": "6818c03ce92b7f92be1540f0"

}
```

Checklist:
- [x] Added int tests
- [x] Modify mongo, drizzle and graphql packages
- [x] Add type tests
- [x] Add e2e tests
2025-05-14 23:45:34 +00:00
Alessio Gravili
d63c8baea5 fix(plugin-cloud): ensure scheduled publishing works if no custom jobs are defined (#12410)
Previously, plugin-cloud would only set up job auto-running if a job configuration was present in the custom config at initialization time.

However, some jobs - such as the scheduled publish job which is added during sanitization - are added after plugin-cloud has initialized. This means relying solely on the initial state of the job config is insufficient for determining whether to enable auto-running.

This PR removes that check and ensures auto-running is always initialized, allowing later-added jobs to run as expected.

## Weakening type

This PR also weakens to `config.jobs.tasks` type and makes that property optional. It's totally permissible to only have workflows that define inline tasks, and to not have any static tasks defined in `config.jobs.tasks`. Thus it makes no sense to make that property required.
2025-05-14 21:58:25 +00:00
Jacob Fletcher
93d79b9c62 perf: remove duplicative deep loops during field sanitization (#12402)
Optimizes the field sanitization process by removing duplicative deep
loops over the config. We were previously iterating over all fields of
each collection potentially multiple times in order validate field
configs, check reserved field names, etc. Now, we perform all necessary
sanitization within a single loop.
2025-05-14 15:25:44 -04:00
Jacob Fletcher
9779cf7f7d feat: prevent query preset lockout (#12322)
Prevents an accidental lockout of query preset documents. An "accidental
lockout" occurs when the user sets access control on a preset and
excludes themselves. This can happen in a variety of scenarios,
including:

 - You select `specificUsers` without specifying yourself
- You select `specificRoles` without specifying a role that you are a
part of
 - Etc.

#### How it works

To make this happen, we use a custom validation function that executes
access against the user's proposed changes. If those changes happen to
remove access for them, we throw a validation error and prevent that
change from ever taking place. This means that only a user with proper
access can remove another user from the preset. You cannot remove
yourself.

To do this, we create a temporary record in the database that we can
query against. We use transactions to ensure that the temporary record
is not persisted once our work is completed. Since not all Payload
projects have transactions enabled, we flag these temporary records with
the `isTemp` field.

Once created, we query the temp document to determine its permissions.
If any of the operations throw an error, this means the user can no
longer act on them, and we throw a validation error.

#### Alternative Approach
 
A previous approach that was explored was to add an `owner` field to the
presets collection. This way, the "owner" of the preset would be able to
completely bypass all access control, effectively eliminating the
possibility of a lockout event.

But this doesn't work for other users who may have update access. E.g.
they could still accidentally remove themselves from the read or update
operation, preventing them from accessing that preset after submitting
the form. We need a solution that works for all users, not just the
owner.
2025-05-14 19:25:32 +00:00
Ruslan
b7b2b390fc feat(ui): fixed toolbar group customization (#12108)
### What

This PR introduces a comprehensive customization system for toolbar
groups in the Lexical Rich Text Editor. It allows developers to override
not just the order, but virtually any aspect of toolbar components (such
as format, align, indent) through the `FixedToolbarFeature`
configuration. Customizable properties include order, icons, group type,
and more.

### Why

Previously, toolbar group configurations were hardcoded in their
respective components with no way to modify them without changing the
source code. This made it difficult for developers to:

1. Reorder toolbar components to match specific UX requirements
2. Replace icons with custom ones to maintain design consistency 
3. Transform dropdown groups into button groups or vice versa
4. Apply other customizations needed for specific projects

This enhancement provides full flexibility for tailoring the rich text
editor interface while maintaining a clean and maintainable codebase.

### How

The implementation consists of three key parts:

1. **Enhanced the FixedToolbarFeature API**:
- Added a new `customGroups` property to `FixedToolbarFeatureProps` that
accepts a record mapping group keys to partial `ToolbarGroup` objects
- These partial objects can override any property of the default toolbar
group configuration

2. **Leveraged existing deep merge utility**:
- Used Payload's existing `deepMerge` utility to properly combine
default configurations with custom overrides
- This ensures that only specified properties are overridden while
preserving all other default behaviors
3. **Applied customizations in the sanitization process**:
- Updated the `sanitizeClientFeatures` function to identify and apply
custom group configurations
- Applied deep merging before the sorting process to ensure proper
ordering with customized configurations
- Maintained backward compatibility for users who don't need
customization

### Usage Example

```typescript
import { FixedToolbarFeature } from '@payloadcms/richtext-lexical'
import { CustomIcon } from './icons/CustomIcon'

{
  name: 'content',
  type: 'richText',
  admin: {
    features: [
      // Other features...
      FixedToolbarFeature({
        customGroups: {
            'text': {
              order: 10,
              ChildComponent: CustomIcon,
            },
            'format': {
              order: 15,
            },
            'add': {
              type: 'buttons',
              order: 20,
            },
        }
      })
    ]
  }
}
```

### Demo


https://github.com/user-attachments/assets/c3a59b60-b6c2-4721-bbc0-4954bdf52625

---------

Co-authored-by: Germán Jabloñski <43938777+GermanJablo@users.noreply.github.com>
2025-05-14 19:25:02 +00:00
Jacob Fletcher
7130834152 feat: thread overrideAccess through field validations (#12399)
Threads the `overrideAccess` property through the field-level
validations. This way custom `validate` functions can be aware of its
value and adjust their logic accordingly.

See #12322 for an example use case.
2025-05-14 14:10:46 -04:00
Philipp Schneider
1d5d96d2c3 perf: actually debounce rich text editor field value updates to only process latest state (#12086)
Follow-up work to #12046, which was misnamed. It improved UI
responsiveness of the rich text field on CPU-limited clients, but didn't
actually reduce work by debouncing. It only improved scheduling.

Using `requestIdleCallback` lead to better scheduling of change event
handling in the rich text editor, but on CPU-starved clients, this leads
to a large backlog of unprocessed idle callbacks. Since idle callbacks
are called by the browser in submission order, the latest callback will
be processed last, potentially leading to large time delays between a
user typing, and the form state having been updated. An example: When a
user types "I", and the change events for the character "I" is scheduled
to happen in the next browser idle time, but then the user goes on to
type "love Payload", there will be 12 more callbacks scheduled. On a
slow system it's preferable if the browser right away only processes the
event that has the full editor state "I love Payload", instead of only
processing that after 11 other idle callbacks.

So this code change keeps track when requesting an idle callback and
cancels the previous one when a new change event with an updated editor
state occurs.
2025-05-14 13:14:29 -03:00
Jarrod Flesch
faa7794cc7 feat(plugin-multi-tenant): prompt the user to confirm the change of tenant before actually updating (#12382) 2025-05-14 09:45:00 -04:00
Anyu Jiang
98283ca18c fix(db-postgres): ensure module augmentation for generated schema is picked up correctly in turborepo (#12312)
### What?
Turborepo fails to compile due to type error in the generated drizzle
schema.
### Why?
TypeScript may not include the module augmentation for
@payloadcms/db-postgres, especially in monorepo or isolated module
builds. This causes type errors during the compilation process of
turborepo project. Adding the type-only import guarantees that
TypeScript loads the relevant type definitions and augmentations,
resolving these errors.
### How?
This PR adds a type-only import statement to ensure TypeScript
recognizes the module augmentation for @payloadcms/db-postgres in the
generated drizzle schema from payload, and there is no runtime effect.

Fixes #12311

-->

![image](https://github.com/user-attachments/assets/cdec275c-c062-4eb7-9e6a-c3bc3871dd65)
2025-05-13 11:23:27 -07:00
Paul
e93d0baf89 chore: add NODE_OPTIONS to vscode settings by default in the repo for playwright extension (#12390)
The official playwright extension when using the debug button to run
tests in debug mode doesn't pick up the `tests/test.env` file as
expected.

I've added the same `NODE_OPTIONS` to the vscode settings JSON for this
extension which fixes an error when running e2e tests in debug mode.
2025-05-13 10:31:06 -07:00
Paul
cd455741e5 docs: remove link to outdated ecommerce template from stripe plugin docs (#12353)
Closes https://github.com/payloadcms/payload/issues/12347

We previously linked to a non existent ecommerce template and example
from the stripe plugin docs.
2025-05-13 13:20:46 -04:00
Paul
735d699804 chore: add no-frozen-lockfile flag for templates script (#12394) 2025-05-13 07:15:38 -07:00
Jessica Rynkar
d9c0c43154 fix(ui): passes value to server component args (#12352)
### What?
Allows the field value (if defined) to be accessed from `args` with
custom server components.

### Why?
Documentation states that the user can access `args.value` to get the
value of the field at time of render (if a value is defined) when using
a custom server component - however this isn't currently setup.

<img width="469" alt="Screenshot 2025-05-08 at 4 51 30 PM"
src="https://github.com/user-attachments/assets/9c167f80-5c5e-4fea-a31c-166281d9f7db"
/>

Link to docs
[here](https://payloadcms.com/docs/fields/overview#default-props).

### How?
Passes the value from `data` if it exists (does not exist for all field
types) and adds `value` to the server component types as an optional
property.

Fixes #10389
2025-05-13 11:13:23 +01:00
Jacob Fletcher
a9cc747038 docs: add local api instructions for vercel content link (#12385)
The docs for Vercel Content Link only included instructions on how to
enable content source maps for the REST API. The Local API, although
supported, was lacking documentation.
2025-05-12 17:06:37 -04:00
Sasha
fd67d461ac fix(db-mongodb): sort by fields in relationships with draft: true (#12387)
Fixes sorting by fields in relationships, e.g `sort: "author.name"` when
using `draft: true`. The existing test that includes check with `draft:
true` was accidentally passing because it used to sort by the
relationship field itself.
2025-05-12 22:35:16 +03:00
Sasha
8219c046de fix(db-postgres): selectDistinct might remove expected rows when querying with nested fields or relations (#12365)
Fixes https://github.com/payloadcms/payload/issues/12263
This was caused by passing not needed columns to the `SELECT DISTINCT`
query, which we execute in case if we have a filter / sort by a nested
field / relationship. Since the only columns that we need to pass to the
`SELECT DISTINCT` query are: ID and field(s) specified in `sort`, we now
filter the `selectFields` variable.
2025-05-12 12:34:15 -07:00
Paul
021932cc8b chore: bump node version in monorepo and add new flag for node 23.6+ (#12328)
This PR does two things:
- Adds a new ` --no-experimental-strip-types` flag to the playwright
test env
- This is needed since 23.6.0 automatically enables this flag by default
and it breaks e2e tests
- Bumps the tooling config files to use node 23.11.0
2025-05-12 09:41:18 -04:00
Germán Jabloñski
edeb381fb4 chore(plugin-stripe): enable TypeScript strict (#12303) 2025-05-12 09:02:03 -04:00
Paul
c43891b2ba fix(db-mongodb): localized dates being returned as date objects instead of strings (#12354)
Fixes https://github.com/payloadcms/payload/issues/12334

We weren't passing locale through to the Date transformer function so
localized dates were being read as objects instead of strings.
2025-05-10 17:15:15 -07:00
Sasha
3701de5056 templates: fix categories search sync (#12359)
Fixes https://github.com/payloadcms/payload/issues/9449

Previously, search sync with categories didn't work and additionally
caused problems with Postgres. Additionally, ensures that when doing
synchronization, all the categories are populated, since we don't always
have populated data inside hooks.
2025-05-09 11:24:48 +01:00
Rot4tion
09f15ff874 templates: add eslint ignore rule for '.next/' (#12332)
### What?
Standardizes ESLint configurations across all template projects like
website template to ensure consistent code quality enforcement.

### Why?
Previously, there were inconsistencies in the ESLint configurations
between different template projects. Some templates were missing the
.next/ ignore pattern, which could lead to unnecessary linting of build
files. By standardizing these configurations, we ensure consistent code
quality standards and developer experience across all template projects.

### How?
Added the missing ignores: ['.next/'] configuration to templates that
were missing it
2025-05-08 11:06:33 -07:00
jeepman32
72662257a8 fix(drizzle): improve db push schema comparison (#12193)
### What?
Swaps out `deepAssertEqual` for `dequal` package. Further details and
motivation in [this
discussion](https://github.com/payloadcms/payload/discussions/12192).

### Why?
Dequal is about 100x faster in limited local testing. Dequal package
shows 3-5x speed over `deepAssertEqual` in benchmarks. Memory usage is
within acceptable levels.

### How?
Move the result of dequal to a `const` for readability. Replace the `try
{ ... } catch { ... }` with `if { ... } else { ... }` for minimum impact
and change.
2025-05-08 07:48:13 -07:00
Rot4tion
18693775e4 templates: fix Media component failing when setting a custom serverURL (#12214)
### What?
Fixes #12171

### Why?
Previously, the ImageMedia component was not properly handling URL
formatting when a serverURL was configured in Payload. This caused
images to fail to load when using a custom serverURL. By extracting the
URL handling logic into a separate utility function, we ensure
consistent URL processing across both image and video components.

### How?
1. Created a new utility function getMediaUrl in
`src/utilities/getMediaUrl.ts` that:
   - Properly checks for HTTP/HTTPS protocols
   - Handles null or undefined URL values
   - Supports cache tags to prevent caching issues
   - Uses `getClientSideURL()` for relative paths
2. Updated the ImageMedia component to use this utility function instead
of inline URL processing logic
3. Updated the VideoMedia component to also use the same utility
function for consistency
2025-05-07 15:45:12 -07:00
Tobias Odendahl
b3cac753d6 feat(ui): display the actual error message on unpublish if available (#11898)
### What?
If an error occurs while unpublishing a document in the edit view UI,
the toast which shows the error message now displays the actual message
which is sent from the server, if available.

### Why?
Only a generic error message was shown if an unpublish operation failed.
Some errors might be solvable by the user, so that there is value in
showing the actual, actionable error message instead of a generic one.

### How?
The server response is parsed for error message if an unpublish
operation fails and displayed in the toast, instead of the generic error
message.


![image](https://github.com/user-attachments/assets/774d68c6-b36b-4447-93a0-b437845694a9)
2025-05-06 17:27:05 -07:00
332 changed files with 15210 additions and 5645 deletions

View File

@@ -6,7 +6,7 @@ inputs:
node-version:
description: Node.js version
required: true
default: 22.6.0
default: 23.11.0
pnpm-version:
description: Pnpm version
required: true

View File

@@ -16,7 +16,7 @@ concurrency:
cancel-in-progress: true
env:
NODE_VERSION: 22.6.0
NODE_VERSION: 23.11.0
PNPM_VERSION: 9.7.1
DO_NOT_TRACK: 1 # Disable Turbopack telemetry
NEXT_TELEMETRY_DISABLED: 1 # Disable Next telemetry

View File

@@ -7,7 +7,7 @@ on:
workflow_dispatch:
env:
NODE_VERSION: 22.6.0
NODE_VERSION: 23.11.0
PNPM_VERSION: 9.7.1
DO_NOT_TRACK: 1 # Disable Turbopack telemetry
NEXT_TELEMETRY_DISABLED: 1 # Disable Next telemetry

View File

@@ -12,7 +12,7 @@ on:
default: ''
env:
NODE_VERSION: 22.6.0
NODE_VERSION: 23.11.0
PNPM_VERSION: 9.7.1
DO_NOT_TRACK: 1 # Disable Turbopack telemetry
NEXT_TELEMETRY_DISABLED: 1 # Disable Next telemetry

View File

@@ -7,7 +7,7 @@ on:
workflow_dispatch:
env:
NODE_VERSION: 22.6.0
NODE_VERSION: 23.11.0
PNPM_VERSION: 9.7.1
DO_NOT_TRACK: 1 # Disable Turbopack telemetry
NEXT_TELEMETRY_DISABLED: 1 # Disable Next telemetry

View File

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

View File

@@ -1 +1 @@
v22.6.0
v23.11.0

2
.nvmrc
View File

@@ -1 +1 @@
v22.6.0
v23.11.0

View File

@@ -1,2 +1,2 @@
pnpm 9.7.1
nodejs 22.6.0
nodejs 23.11.0

7
.vscode/launch.json vendored
View File

@@ -63,6 +63,13 @@
"request": "launch",
"type": "node-terminal"
},
{
"command": "pnpm tsx --no-deprecation test/dev.ts query-presets",
"cwd": "${workspaceFolder}",
"name": "Run Dev Query Presets",
"request": "launch",
"type": "node-terminal"
},
{
"command": "pnpm tsx --no-deprecation test/dev.ts login-with-username",
"cwd": "${workspaceFolder}",

View File

@@ -24,5 +24,8 @@
"runtimeArgs": ["--no-deprecation"]
},
// Essentially disables bun test buttons
"bun.test.filePattern": "bun.test.ts"
"bun.test.filePattern": "bun.test.ts",
"playwright.env": {
"NODE_OPTIONS": "--no-deprecation --no-experimental-strip-types"
}
}

View File

@@ -35,9 +35,9 @@ export const MyGroupField: Field = {
| Option | Description |
| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`name`** \* | To be used as the property name when stored and retrieved from the database. [More](/docs/fields/overview#field-names) |
| **`name`** | To be used as the property name when stored and retrieved from the database. [More](/docs/fields/overview#field-names) |
| **`fields`** \* | Array of field types to nest within this Group. |
| **`label`** | Used as a heading in the Admin Panel and to name the generated GraphQL type. |
| **`label`** | Used as a heading in the Admin Panel and to name the generated GraphQL type. Required when name is undefined, defaults to name converted to words. |
| **`validate`** | Provide a custom validation function that will be executed on both the Admin Panel and the backend. [More](/docs/fields/overview#validation) |
| **`saveToJWT`** | If this field is top-level and nested in a config supporting [Authentication](/docs/authentication/overview), include its data in the user JWT. |
| **`hooks`** | Provide Field Hooks to control logic for this field. [More details](../hooks/fields). |
@@ -86,7 +86,7 @@ export const ExampleCollection: CollectionConfig = {
slug: 'example-collection',
fields: [
{
name: 'pageMeta', // required
name: 'pageMeta',
type: 'group', // required
interfaceName: 'Meta', // optional
fields: [
@@ -110,3 +110,38 @@ export const ExampleCollection: CollectionConfig = {
],
}
```
## Presentational group fields
You can also use the Group field to create a presentational group of fields. This is useful when you want to group fields together visually without affecting the data structure.
The label will be required when a `name` is not provided.
```ts
import type { CollectionConfig } from 'payload'
export const ExampleCollection: CollectionConfig = {
slug: 'example-collection',
fields: [
{
label: 'Page meta',
type: 'group', // required
fields: [
{
name: 'title',
type: 'text',
required: true,
minLength: 20,
maxLength: 100,
},
{
name: 'description',
type: 'textarea',
required: true,
minLength: 40,
maxLength: 160,
},
],
},
],
}
```

View File

@@ -63,19 +63,50 @@ const config = buildConfig({
export default config
```
Now in your Next.js app, include the `?encodeSourceMaps=true` parameter in any of your API requests. For performance reasons, this should only be done when in draft mode or on preview deployments.
## Enabling Content Source Maps
Now in your Next.js app, you need to add the `encodeSourceMaps` query parameter to your API requests. This will tell Payload to include the Content Source Maps in the API response.
<Banner type="warning">
**Note:** For performance reasons, this should only be done when in draft mode
or on preview deployments.
</Banner>
#### REST API
If you're using the REST API, include the `?encodeSourceMaps=true` search parameter.
```ts
if (isDraftMode || process.env.VERCEL_ENV === 'preview') {
const res = await fetch(
`${process.env.NEXT_PUBLIC_PAYLOAD_CMS_URL}/api/pages?where[slug][equals]=${slug}&encodeSourceMaps=true`,
`${process.env.NEXT_PUBLIC_PAYLOAD_CMS_URL}/api/pages?encodeSourceMaps=true&where[slug][equals]=${slug}`,
)
}
```
#### Local API
If you're using the Local API, include the `encodeSourceMaps` via the `context` property.
```ts
if (isDraftMode || process.env.VERCEL_ENV === 'preview') {
const res = await payload.find({
collection: 'pages',
where: {
slug: {
equals: slug,
},
},
context: {
encodeSourceMaps: true,
},
})
}
```
And that's it! You are now ready to enter Edit Mode and begin visually editing your content.
#### Edit Mode
## Edit Mode
To see Content Link on your site, you first need to visit any preview deployment on Vercel and login using the Vercel Toolbar. When Content Source Maps are detected on the page, a pencil icon will appear in the toolbar. Clicking this icon will enable Edit Mode, highlighting all editable fields on the page in blue.
@@ -94,7 +125,9 @@ const { cleaned, encoded } = vercelStegaSplit(text)
### Blocks and array fields
All `blocks` and `array` fields by definition do not have plain text strings to encode. For this reason, they are given an additional `_encodedSourceMap` property, which you can use to enable Content Link on entire _sections_ of your site. You can then specify the editing container by adding the `data-vercel-edit-target` HTML attribute to any top-level element of your block.
All `blocks` and `array` fields by definition do not have plain text strings to encode. For this reason, they are automatically given an additional `_encodedSourceMap` property, which you can use to enable Content Link on entire _sections_ of your site.
You can then specify the editing container by adding the `data-vercel-edit-target` HTML attribute to any top-level element of your block.
```ts
<div data-vercel-edit-target>

View File

@@ -309,7 +309,3 @@ import {
...
} from '@payloadcms/plugin-stripe/types';
```
## Examples
The [Templates Directory](https://github.com/payloadcms/payload/tree/main/templates) contains an official [E-commerce Template](https://github.com/payloadcms/payload/tree/main/templates/ecommerce) which demonstrates exactly how to configure this plugin in Payload and implement it on your front-end. You can also check out [How to Build An E-Commerce Site With Next.js](https://payloadcms.com/blog/how-to-build-an-e-commerce-site-with-nextjs) post for a bit more context around this template.

View File

@@ -74,6 +74,7 @@ export const rootEslintConfig = [
'no-console': 'off',
'perfectionist/sort-object-types': 'off',
'perfectionist/sort-objects': 'off',
'payload/no-relative-monorepo-imports': 'off',
},
},
]

View File

@@ -1,11 +1,11 @@
import { BeforeSync, DocToSync } from '@payloadcms/plugin-search/types'
export const beforeSyncWithSearch: BeforeSync = async ({ originalDoc, searchDoc, payload }) => {
export const beforeSyncWithSearch: BeforeSync = async ({ req, originalDoc, searchDoc }) => {
const {
doc: { relationTo: collection },
} = searchDoc
const { slug, id, categories, title, meta, excerpt } = originalDoc
const { slug, id, categories, title, meta } = originalDoc
const modifiedDoc: DocToSync = {
...searchDoc,
@@ -20,24 +20,40 @@ export const beforeSyncWithSearch: BeforeSync = async ({ originalDoc, searchDoc,
}
if (categories && Array.isArray(categories) && categories.length > 0) {
// get full categories and keep a flattened copy of their most important properties
try {
const mappedCategories = categories.map((category) => {
const { id, title } = category
const populatedCategories: { id: string | number; title: string }[] = []
for (const category of categories) {
if (!category) {
continue
}
return {
relationTo: 'categories',
id,
title,
}
if (typeof category === 'object') {
populatedCategories.push(category)
continue
}
const doc = await req.payload.findByID({
collection: 'categories',
id: category,
disableErrors: true,
depth: 0,
select: { title: true },
req,
})
modifiedDoc.categories = mappedCategories
} catch (err) {
console.error(
`Failed. Category not found when syncing collection '${collection}' with id: '${id}' to search.`,
)
if (doc !== null) {
populatedCategories.push(doc)
} else {
console.error(
`Failed. Category not found when syncing collection '${collection}' with id: '${id}' to search.`,
)
}
}
modifiedDoc.categories = populatedCategories.map((each) => ({
relationTo: 'categories',
categoryID: String(each.id),
title: each.title,
}))
}
return modifiedDoc

View File

@@ -52,7 +52,7 @@ export const searchFields: Field[] = [
type: 'text',
},
{
name: 'id',
name: 'categoryID',
type: 'text',
},
{

View File

@@ -1,6 +1,6 @@
{
"name": "payload-monorepo",
"version": "3.37.0",
"version": "3.38.0",
"private": true,
"type": "module",
"scripts": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/admin-bar",
"version": "3.37.0",
"version": "3.38.0",
"description": "An admin bar for React apps using Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "create-payload-app",
"version": "3.37.0",
"version": "3.38.0",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",

View File

@@ -22,7 +22,9 @@ const updateEnvExampleVariables = (
const [key] = line.split('=')
if (!key) {return}
if (!key) {
return
}
if (key === 'DATABASE_URI' || key === 'POSTGRES_URL' || key === 'MONGODB_URI') {
const dbChoice = databaseType ? dbChoiceRecord[databaseType] : null

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/db-mongodb",
"version": "3.37.0",
"version": "3.38.0",
"description": "The officially supported MongoDB database adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -372,36 +372,61 @@ const group: FieldSchemaGenerator<GroupField> = (
buildSchemaOptions,
parentIsLocalized,
): void => {
const formattedBaseSchema = formatBaseSchema({ buildSchemaOptions, field, parentIsLocalized })
if (fieldAffectsData(field)) {
const formattedBaseSchema = formatBaseSchema({ buildSchemaOptions, field, parentIsLocalized })
// carry indexSortableFields through to versions if drafts enabled
const indexSortableFields =
buildSchemaOptions.indexSortableFields &&
field.name === 'version' &&
buildSchemaOptions.draftsEnabled
// carry indexSortableFields through to versions if drafts enabled
const indexSortableFields =
buildSchemaOptions.indexSortableFields &&
field.name === 'version' &&
buildSchemaOptions.draftsEnabled
const baseSchema: SchemaTypeOptions<any> = {
...formattedBaseSchema,
type: buildSchema({
buildSchemaOptions: {
disableUnique: buildSchemaOptions.disableUnique,
draftsEnabled: buildSchemaOptions.draftsEnabled,
indexSortableFields,
options: {
_id: false,
id: false,
minimize: false,
const baseSchema: SchemaTypeOptions<any> = {
...formattedBaseSchema,
type: buildSchema({
buildSchemaOptions: {
disableUnique: buildSchemaOptions.disableUnique,
draftsEnabled: buildSchemaOptions.draftsEnabled,
indexSortableFields,
options: {
_id: false,
id: false,
minimize: false,
},
},
},
configFields: field.fields,
parentIsLocalized: parentIsLocalized || field.localized,
payload,
}),
}
configFields: field.fields,
parentIsLocalized: parentIsLocalized || field.localized,
payload,
}),
}
schema.add({
[field.name]: localizeSchema(field, baseSchema, payload.config.localization, parentIsLocalized),
})
schema.add({
[field.name]: localizeSchema(
field,
baseSchema,
payload.config.localization,
parentIsLocalized,
),
})
} else {
field.fields.forEach((subField) => {
if (fieldIsVirtual(subField)) {
return
}
const addFieldSchema = getSchemaGenerator(subField.type)
if (addFieldSchema) {
addFieldSchema(
subField,
schema,
payload,
buildSchemaOptions,
(parentIsLocalized || field.localized) ?? false,
)
}
})
}
}
const json: FieldSchemaGenerator<JSONField> = (

View File

@@ -57,12 +57,8 @@ const relationshipSort = ({
return false
}
for (const [i, segment] of segments.entries()) {
if (versions && i === 0 && segment === 'version') {
segments.shift()
continue
}
for (let i = 0; i < segments.length; i++) {
const segment = segments[i]
const field = currentFields.find((each) => each.name === segment)
if (!field) {
@@ -71,6 +67,10 @@ const relationshipSort = ({
if ('fields' in field) {
currentFields = field.flattenedFields
if (field.name === 'version' && versions && i === 0) {
segments.shift()
i--
}
} else if (
(field.type === 'relationship' || field.type === 'upload') &&
i !== segments.length - 1
@@ -106,7 +106,7 @@ const relationshipSort = ({
as: `__${path}`,
foreignField: '_id',
from: foreignCollection.Model.collection.name,
localField: relationshipPath,
localField: versions ? `version.${relationshipPath}` : relationshipPath,
pipeline: [
{
$project: {

View File

@@ -105,6 +105,7 @@ export const sanitizeQueryValue = ({
| undefined => {
let formattedValue = val
let formattedOperator = operator
if (['array', 'blocks', 'group', 'tab'].includes(field.type) && path.includes('.')) {
const segments = path.split('.')
segments.shift()

View File

@@ -151,6 +151,7 @@ export const queryDrafts: QueryDrafts = async function queryDrafts(
query: versionQuery,
session: paginationOptions.options?.session ?? undefined,
sort: paginationOptions.sort as object,
sortAggregation,
useEstimatedCount: paginationOptions.useEstimatedCount,
})
} else {

View File

@@ -128,7 +128,6 @@ const traverseFields = ({
break
}
case 'blocks': {
const blocksSelect = select[field.name] as SelectType

View File

@@ -425,6 +425,7 @@ export const transform = ({
for (const locale of config.localization.localeCodes) {
sanitizeDate({
field,
locale,
ref: fieldRef,
value: fieldRef[locale],
})

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/db-sqlite",
"version": "3.37.0",
"version": "3.38.0",
"description": "The officially supported SQLite database adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/db-vercel-postgres",
"version": "3.37.0",
"version": "3.38.0",
"description": "Vercel Postgres adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/drizzle",
"version": "3.37.0",
"version": "3.38.0",
"description": "A library of shared functions used by different payload database adapters",
"homepage": "https://payloadcms.com",
"repository": {
@@ -53,6 +53,7 @@
},
"dependencies": {
"console-table-printer": "2.12.1",
"dequal": "2.0.3",
"drizzle-orm": "0.36.1",
"prompts": "2.4.2",
"to-snake-case": "1.0.0",

View File

@@ -7,6 +7,7 @@ import type { DrizzleAdapter } from '../types.js'
import buildQuery from '../queries/buildQuery.js'
import { selectDistinct } from '../queries/selectDistinct.js'
import { transform } from '../transform/read/index.js'
import { getNameFromDrizzleTable } from '../utilities/getNameFromDrizzleTable.js'
import { getTransaction } from '../utilities/getTransaction.js'
import { buildFindManyArgs } from './buildFindManyArgs.js'
@@ -75,6 +76,26 @@ export const findMany = async function find({
tableName,
versions,
})
if (orderBy) {
for (const key in selectFields) {
const column = selectFields[key]
if (column.primary) {
continue
}
if (
!orderBy.some(
(col) =>
col.column.name === column.name &&
getNameFromDrizzleTable(col.column.table) === getNameFromDrizzleTable(column.table),
)
) {
delete selectFields[key]
}
}
}
const selectDistinctResult = await selectDistinct({
adapter,
db,

View File

@@ -22,9 +22,14 @@ import type { Result } from './buildFindManyArgs.js'
import buildQuery from '../queries/buildQuery.js'
import { getTableAlias } from '../queries/getTableAlias.js'
import { operatorMap } from '../queries/operatorMap.js'
import { getArrayRelationName } from '../utilities/getArrayRelationName.js'
import { getNameFromDrizzleTable } from '../utilities/getNameFromDrizzleTable.js'
import { jsonAggBuildObject } from '../utilities/json.js'
import { rawConstraint } from '../utilities/rawConstraint.js'
import {
InternalBlockTableNameIndex,
resolveBlockTableName,
} from '../utilities/validateExistingBlockIsIdentical.js'
const flattenAllWherePaths = (where: Where, paths: string[]) => {
for (const k in where) {
@@ -196,7 +201,12 @@ export const traverseFields = ({
}
}
const relationName = field.dbName ? `_${arrayTableName}` : `${path}${field.name}`
const relationName = getArrayRelationName({
field,
path: `${path}${field.name}`,
tableName: arrayTableName,
})
currentArgs.with[relationName] = withArray
traverseFields({
@@ -244,7 +254,7 @@ export const traverseFields = ({
;(field.blockReferences ?? field.blocks).forEach((_block) => {
const block = typeof _block === 'string' ? adapter.payload.blocks[_block] : _block
const blockKey = `_blocks_${block.slug}`
const blockKey = `_blocks_${block.slug}${!block[InternalBlockTableNameIndex] ? '' : `_${block[InternalBlockTableNameIndex]}`}`
let blockSelect: boolean | SelectType | undefined
@@ -284,8 +294,9 @@ export const traverseFields = ({
with: {},
}
const tableName = adapter.tableNameMap.get(
`${topLevelTableName}_blocks_${toSnakeCase(block.slug)}`,
const tableName = resolveBlockTableName(
block,
adapter.tableNameMap.get(`${topLevelTableName}_blocks_${toSnakeCase(block.slug)}`),
)
if (typeof blockSelect === 'object') {

View File

@@ -1,49 +1,126 @@
export type Groups =
| 'addColumn'
| 'addConstraint'
| 'alterType'
| 'createIndex'
| 'createTable'
| 'createType'
| 'disableRowSecurity'
| 'dropColumn'
| 'dropConstraint'
| 'dropIndex'
| 'dropTable'
| 'dropType'
| 'notNull'
| 'renameColumn'
| 'setDefault'
/**
* Convert an "ADD COLUMN" statement to an "ALTER COLUMN" statement
* example: ALTER TABLE "pages_blocks_my_block" ADD COLUMN "person_id" integer NOT NULL;
* to: ALTER TABLE "pages_blocks_my_block" ALTER COLUMN "person_id" SET NOT NULL;
* @param sql
* Convert an "ADD COLUMN" statement to an "ALTER COLUMN" statement.
* Works with or without a schema name.
*
* Examples:
* 'ALTER TABLE "pages_blocks_my_block" ADD COLUMN "person_id" integer NOT NULL;'
* => 'ALTER TABLE "pages_blocks_my_block" ALTER COLUMN "person_id" SET NOT NULL;'
*
* 'ALTER TABLE "public"."pages_blocks_my_block" ADD COLUMN "person_id" integer NOT NULL;'
* => 'ALTER TABLE "public"."pages_blocks_my_block" ALTER COLUMN "person_id" SET NOT NULL;'
*/
function convertAddColumnToAlterColumn(sql) {
// Regular expression to match the ADD COLUMN statement with its constraints
const regex = /ALTER TABLE ("[^"]+")\.(".*?") ADD COLUMN ("[^"]+") [\w\s]+ NOT NULL;/
const regex = /ALTER TABLE ((?:"[^"]+"\.)?"[^"]+") ADD COLUMN ("[^"]+") [^;]*?NOT NULL;/i
// Replace the matched part with "ALTER COLUMN ... SET NOT NULL;"
return sql.replace(regex, 'ALTER TABLE $1.$2 ALTER COLUMN $3 SET NOT NULL;')
return sql.replace(regex, 'ALTER TABLE $1 ALTER COLUMN $2 SET NOT NULL;')
}
export const groupUpSQLStatements = (list: string[]): Record<Groups, string[]> => {
const groups = {
/**
* example: ALTER TABLE "posts" ADD COLUMN "category_id" integer
*/
addColumn: 'ADD COLUMN',
// example: ALTER TABLE "posts" ADD COLUMN "category_id" integer
/**
* example:
* DO $$ BEGIN
* ALTER TABLE "pages_blocks_my_block" ADD CONSTRAINT "pages_blocks_my_block_person_id_users_id_fk" FOREIGN KEY ("person_id") REFERENCES "users"("id") ON DELETE cascade ON UPDATE no action;
* EXCEPTION
* WHEN duplicate_object THEN null;
* END $$;
*/
addConstraint: 'ADD CONSTRAINT',
//example:
// DO $$ BEGIN
// ALTER TABLE "pages_blocks_my_block" ADD CONSTRAINT "pages_blocks_my_block_person_id_users_id_fk" FOREIGN KEY ("person_id") REFERENCES "users"("id") ON DELETE cascade ON UPDATE no action;
// EXCEPTION
// WHEN duplicate_object THEN null;
// END $$;
/**
* example: CREATE TABLE IF NOT EXISTS "payload_locked_documents" (
* "id" serial PRIMARY KEY NOT NULL,
* "global_slug" varchar,
* "updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
* "created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
* );
*/
createTable: 'CREATE TABLE',
/**
* example: ALTER TABLE "_posts_v_rels" DROP COLUMN IF EXISTS "posts_id";
*/
dropColumn: 'DROP COLUMN',
// example: ALTER TABLE "_posts_v_rels" DROP COLUMN IF EXISTS "posts_id";
/**
* example: ALTER TABLE "_posts_v_rels" DROP CONSTRAINT "_posts_v_rels_posts_fk";
*/
dropConstraint: 'DROP CONSTRAINT',
// example: ALTER TABLE "_posts_v_rels" DROP CONSTRAINT "_posts_v_rels_posts_fk";
/**
* example: DROP TABLE "pages_rels";
*/
dropTable: 'DROP TABLE',
// example: DROP TABLE "pages_rels";
/**
* example: ALTER TABLE "pages_blocks_my_block" ALTER COLUMN "person_id" SET NOT NULL;
*/
notNull: 'NOT NULL',
// example: ALTER TABLE "pages_blocks_my_block" ALTER COLUMN "person_id" SET NOT NULL;
/**
* example: CREATE TYPE "public"."enum__pages_v_published_locale" AS ENUM('en', 'es');
*/
createType: 'CREATE TYPE',
/**
* example: ALTER TYPE "public"."enum_pages_blocks_cta" ADD VALUE 'copy';
*/
alterType: 'ALTER TYPE',
/**
* example: ALTER TABLE "categories_rels" DISABLE ROW LEVEL SECURITY;
*/
disableRowSecurity: 'DISABLE ROW LEVEL SECURITY;',
/**
* example: DROP INDEX IF EXISTS "pages_title_idx";
*/
dropIndex: 'DROP INDEX IF EXISTS',
/**
* example: ALTER TABLE "pages" ALTER COLUMN "_status" SET DEFAULT 'draft';
*/
setDefault: 'SET DEFAULT',
/**
* example: CREATE INDEX IF NOT EXISTS "payload_locked_documents_global_slug_idx" ON "payload_locked_documents" USING btree ("global_slug");
*/
createIndex: 'INDEX IF NOT EXISTS',
/**
* example: DROP TYPE "public"."enum__pages_v_published_locale";
*/
dropType: 'DROP TYPE',
/**
* columns were renamed from camelCase to snake_case
* example: ALTER TABLE "forms" RENAME COLUMN "confirmationType" TO "confirmation_type";
*/
renameColumn: 'RENAME COLUMN',
}
const result = Object.keys(groups).reduce((result, group: Groups) => {
@@ -51,7 +128,17 @@ export const groupUpSQLStatements = (list: string[]): Record<Groups, string[]> =
return result
}, {}) as Record<Groups, string[]>
// push multi-line changes to a single grouping
let isCreateTable = false
for (const line of list) {
if (isCreateTable) {
result.createTable.push(line)
if (line.includes(');')) {
isCreateTable = false
}
continue
}
Object.entries(groups).some(([key, value]) => {
if (line.endsWith('NOT NULL;')) {
// split up the ADD COLUMN and ALTER COLUMN NOT NULL statements
@@ -64,7 +151,11 @@ export const groupUpSQLStatements = (list: string[]): Record<Groups, string[]> =
return true
}
if (line.includes(value)) {
result[key].push(line)
let statement = line
if (key === 'dropConstraint') {
statement = line.replace('" DROP CONSTRAINT "', '" DROP CONSTRAINT IF EXISTS "')
}
result[key].push(statement)
return true
}
})

View File

@@ -20,6 +20,17 @@ type Args = {
req?: Partial<PayloadRequest>
}
const runStatementGroup = async ({ adapter, db, debug, statements }) => {
const addColumnsStatement = statements.join('\n')
if (debug) {
adapter.payload.logger.info(debug)
adapter.payload.logger.info(addColumnsStatement)
}
await db.execute(sql.raw(addColumnsStatement))
}
/**
* Moves upload and relationship columns from the join table and into the tables while moving data
* This is done in the following order:
@@ -40,16 +51,7 @@ export const migratePostgresV2toV3 = async ({ debug, payload, req }: Args) => {
// get the drizzle migrateUpSQL from drizzle using the last schema
const { generateDrizzleJson, generateMigration, upSnapshot } = adapter.requireDrizzleKit()
const toSnapshot: Record<string, unknown> = {}
for (const key of Object.keys(adapter.schema).filter(
(key) => !key.startsWith('payload_locked_documents'),
)) {
toSnapshot[key] = adapter.schema[key]
}
const drizzleJsonAfter = generateDrizzleJson(toSnapshot) as DrizzleSnapshotJSON
const drizzleJsonAfter = generateDrizzleJson(adapter.schema) as DrizzleSnapshotJSON
// Get the previous migration snapshot
const previousSnapshot = fs
@@ -81,18 +83,62 @@ export const migratePostgresV2toV3 = async ({ debug, payload, req }: Args) => {
const sqlUpStatements = groupUpSQLStatements(generatedSQL)
const addColumnsStatement = sqlUpStatements.addColumn.join('\n')
if (debug) {
payload.logger.info('CREATING NEW RELATIONSHIP COLUMNS')
payload.logger.info(addColumnsStatement)
}
const db = await getTransaction(adapter, req)
await db.execute(sql.raw(addColumnsStatement))
await runStatementGroup({
adapter,
db,
debug: debug ? 'CREATING TYPES' : null,
statements: sqlUpStatements.createType,
})
await runStatementGroup({
adapter,
db,
debug: debug ? 'ALTERING TYPES' : null,
statements: sqlUpStatements.alterType,
})
await runStatementGroup({
adapter,
db,
debug: debug ? 'CREATING TABLES' : null,
statements: sqlUpStatements.createTable,
})
await runStatementGroup({
adapter,
db,
debug: debug ? 'RENAMING COLUMNS' : null,
statements: sqlUpStatements.renameColumn,
})
await runStatementGroup({
adapter,
db,
debug: debug ? 'CREATING NEW RELATIONSHIP COLUMNS' : null,
statements: sqlUpStatements.addColumn,
})
// SET DEFAULTS
await runStatementGroup({
adapter,
db,
debug: debug ? 'SETTING DEFAULTS' : null,
statements: sqlUpStatements.setDefault,
})
await runStatementGroup({
adapter,
db,
debug: debug ? 'CREATING INDEXES' : null,
statements: sqlUpStatements.createIndex,
})
for (const collection of payload.config.collections) {
if (collection.slug === 'payload-locked-documents') {
continue
}
const tableName = adapter.tableNameMap.get(toSnakeCase(collection.slug))
const pathsToQuery: PathsToQuery = new Set()
@@ -238,52 +284,58 @@ export const migratePostgresV2toV3 = async ({ debug, payload, req }: Args) => {
}
// ADD CONSTRAINT
const addConstraintsStatement = sqlUpStatements.addConstraint.join('\n')
if (debug) {
payload.logger.info('ADDING CONSTRAINTS')
payload.logger.info(addConstraintsStatement)
}
await db.execute(sql.raw(addConstraintsStatement))
await runStatementGroup({
adapter,
db,
debug: debug ? 'ADDING CONSTRAINTS' : null,
statements: sqlUpStatements.addConstraint,
})
// NOT NULL
const notNullStatements = sqlUpStatements.notNull.join('\n')
if (debug) {
payload.logger.info('NOT NULL CONSTRAINTS')
payload.logger.info(notNullStatements)
}
await db.execute(sql.raw(notNullStatements))
await runStatementGroup({
adapter,
db,
debug: debug ? 'NOT NULL CONSTRAINTS' : null,
statements: sqlUpStatements.notNull,
})
// DROP TABLE
const dropTablesStatement = sqlUpStatements.dropTable.join('\n')
await runStatementGroup({
adapter,
db,
debug: debug ? 'DROPPING TABLES' : null,
statements: sqlUpStatements.dropTable,
})
if (debug) {
payload.logger.info('DROPPING TABLES')
payload.logger.info(dropTablesStatement)
}
await db.execute(sql.raw(dropTablesStatement))
// DROP INDEX
await runStatementGroup({
adapter,
db,
debug: debug ? 'DROPPING INDEXES' : null,
statements: sqlUpStatements.dropIndex,
})
// DROP CONSTRAINT
const dropConstraintsStatement = sqlUpStatements.dropConstraint.join('\n')
if (debug) {
payload.logger.info('DROPPING CONSTRAINTS')
payload.logger.info(dropConstraintsStatement)
}
await db.execute(sql.raw(dropConstraintsStatement))
await runStatementGroup({
adapter,
db,
debug: debug ? 'DROPPING CONSTRAINTS' : null,
statements: sqlUpStatements.dropConstraint,
})
// DROP COLUMN
const dropColumnsStatement = sqlUpStatements.dropColumn.join('\n')
await runStatementGroup({
adapter,
db,
debug: debug ? 'DROPPING COLUMNS' : null,
statements: sqlUpStatements.dropColumn,
})
if (debug) {
payload.logger.info('DROPPING COLUMNS')
payload.logger.info(dropColumnsStatement)
}
await db.execute(sql.raw(dropColumnsStatement))
// DROP TYPES
await runStatementGroup({
adapter,
db,
debug: debug ? 'DROPPING TYPES' : null,
statements: sqlUpStatements.dropType,
})
}

View File

@@ -56,7 +56,7 @@ export const migrateRelationships = async ({
${where} ORDER BY parent_id LIMIT 500 OFFSET ${offset * 500};
`
paginationResult = await adapter.drizzle.execute(sql.raw(`${paginationStatement}`))
paginationResult = await db.execute(sql.raw(`${paginationStatement}`))
if (paginationResult.rows.length === 0) {
return
@@ -72,7 +72,7 @@ export const migrateRelationships = async ({
payload.logger.info(statement)
}
const result = await adapter.drizzle.execute(sql.raw(`${statement}`))
const result = await db.execute(sql.raw(`${statement}`))
const docsToResave: DocsToResave = {}

View File

@@ -1,7 +1,7 @@
import type { Table } from 'drizzle-orm'
import type { SQL, Table } from 'drizzle-orm'
import type { FlattenedField, Sort } from 'payload'
import { asc, desc } from 'drizzle-orm'
import { asc, desc, or } from 'drizzle-orm'
import type { DrizzleAdapter, GenericColumn } from '../types.js'
import type { BuildQueryJoinAliases, BuildQueryResult } from './buildQuery.js'
@@ -16,6 +16,7 @@ type Args = {
joins: BuildQueryJoinAliases
locale?: string
parentIsLocalized: boolean
rawSort?: SQL
selectFields: Record<string, GenericColumn>
sort?: Sort
tableName: string
@@ -31,6 +32,7 @@ export const buildOrderBy = ({
joins,
locale,
parentIsLocalized,
rawSort,
selectFields,
sort,
tableName,
@@ -74,12 +76,18 @@ export const buildOrderBy = ({
value: sortProperty,
})
if (sortTable?.[sortTableColumnName]) {
let order = sortDirection === 'asc' ? asc : desc
if (rawSort) {
order = () => rawSort
}
orderBy.push({
column:
aliasTable && tableName === getNameFromDrizzleTable(sortTable)
? aliasTable[sortTableColumnName]
: sortTable[sortTableColumnName],
order: sortDirection === 'asc' ? asc : desc,
order,
})
selectFields[sortTableColumnName] = sortTable[sortTableColumnName]

View File

@@ -79,6 +79,7 @@ const buildQuery = function buildQuery({
joins,
locale,
parentIsLocalized,
rawSort: context.rawSort,
selectFields,
sort: context.sort,
tableName,

View File

@@ -19,6 +19,7 @@ import type { DrizzleAdapter, GenericColumn } from '../types.js'
import type { BuildQueryJoinAliases } from './buildQuery.js'
import { isPolymorphicRelationship } from '../utilities/isPolymorphicRelationship.js'
import { resolveBlockTableName } from '../utilities/validateExistingBlockIsIdentical.js'
import { addJoinTable } from './addJoinTable.js'
import { getTableAlias } from './getTableAlias.js'
@@ -193,8 +194,9 @@ export const getTableColumnFromPath = ({
(block) => typeof block !== 'string' && block.slug === blockType,
) as FlattenedBlock | undefined)
newTableName = adapter.tableNameMap.get(
`${tableName}_blocks_${toSnakeCase(block.slug)}`,
newTableName = resolveBlockTableName(
block,
adapter.tableNameMap.get(`${tableName}_blocks_${toSnakeCase(block.slug)}`),
)
const { newAliasTable } = getTableAlias({ adapter, tableName: newTableName })
@@ -220,7 +222,11 @@ export const getTableColumnFromPath = ({
const hasBlockField = (field.blockReferences ?? field.blocks).some((_block) => {
const block = typeof _block === 'string' ? adapter.payload.blocks[_block] : _block
newTableName = adapter.tableNameMap.get(`${tableName}_blocks_${toSnakeCase(block.slug)}`)
newTableName = resolveBlockTableName(
block,
adapter.tableNameMap.get(`${tableName}_blocks_${toSnakeCase(block.slug)}`),
)
constraintPath = `${constraintPath}${field.name}.%.`
let result: TableColumn

View File

@@ -14,7 +14,7 @@ import { buildAndOrConditions } from './buildAndOrConditions.js'
import { getTableColumnFromPath } from './getTableColumnFromPath.js'
import { sanitizeQueryValue } from './sanitizeQueryValue.js'
export type QueryContext = { sort: Sort }
export type QueryContext = { rawSort?: SQL; sort: Sort }
type Args = {
adapter: DrizzleAdapter
@@ -348,6 +348,7 @@ export function parseParams({
}
if (geoConstraints.length) {
context.sort = relationOrPath
context.rawSort = sql`${table[columnName]} <-> ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326)`
constraints.push(and(...geoConstraints))
}
break

View File

@@ -32,6 +32,7 @@ type Args = {
* ie. indexes, multiple columns, etc
*/
baseIndexes?: Record<string, RawIndex>
blocksTableNameMap: Record<string, number>
buildNumbers?: boolean
buildRelationships?: boolean
compoundIndexes?: SanitizedCompoundIndex[]
@@ -70,6 +71,7 @@ export const buildTable = ({
baseColumns = {},
baseForeignKeys = {},
baseIndexes = {},
blocksTableNameMap,
compoundIndexes,
disableNotNull,
disableRelsTableUnique = false,
@@ -120,6 +122,7 @@ export const buildTable = ({
hasManyTextField,
} = traverseFields({
adapter,
blocksTableNameMap,
columns,
disableNotNull,
disableRelsTableUnique,

View File

@@ -56,6 +56,7 @@ export const buildRawSchema = ({
buildTable({
adapter,
blocksTableNameMap: {},
compoundIndexes: collection.sanitizedIndexes,
disableNotNull: !!collection?.versions?.drafts,
disableUnique: false,
@@ -75,6 +76,7 @@ export const buildRawSchema = ({
buildTable({
adapter,
blocksTableNameMap: {},
compoundIndexes: buildVersionCompoundIndexes({ indexes: collection.sanitizedIndexes }),
disableNotNull: !!collection.versions?.drafts,
disableUnique: true,
@@ -96,6 +98,7 @@ export const buildRawSchema = ({
buildTable({
adapter,
blocksTableNameMap: {},
disableNotNull: !!global?.versions?.drafts,
disableUnique: false,
fields: global.flattenedFields,
@@ -118,6 +121,7 @@ export const buildRawSchema = ({
buildTable({
adapter,
blocksTableNameMap: {},
disableNotNull: !!global.versions?.drafts,
disableUnique: true,
fields: versionFields,

View File

@@ -1,8 +1,7 @@
import type { CompoundIndex, FlattenedField } from 'payload'
import type { FlattenedField } from 'payload'
import { InvalidConfiguration } from 'payload'
import {
array,
fieldAffectsData,
fieldIsVirtual,
fieldShouldBeLocalized,
@@ -23,14 +22,20 @@ import type {
import { createTableName } from '../createTableName.js'
import { buildIndexName } from '../utilities/buildIndexName.js'
import { getArrayRelationName } from '../utilities/getArrayRelationName.js'
import { hasLocalesTable } from '../utilities/hasLocalesTable.js'
import { validateExistingBlockIsIdentical } from '../utilities/validateExistingBlockIsIdentical.js'
import {
InternalBlockTableNameIndex,
setInternalBlockIndex,
validateExistingBlockIsIdentical,
} from '../utilities/validateExistingBlockIsIdentical.js'
import { buildTable } from './build.js'
import { idToUUID } from './idToUUID.js'
import { withDefault } from './withDefault.js'
type Args = {
adapter: DrizzleAdapter
blocksTableNameMap: Record<string, number>
columnPrefix?: string
columns: Record<string, RawColumn>
disableNotNull: boolean
@@ -71,6 +76,7 @@ type Result = {
export const traverseFields = ({
adapter,
blocksTableNameMap,
columnPrefix,
columns,
disableNotNull,
@@ -249,6 +255,7 @@ export const traverseFields = ({
baseColumns,
baseForeignKeys,
baseIndexes,
blocksTableNameMap,
disableNotNull: disableNotNullFromHere,
disableRelsTableUnique: true,
disableUnique,
@@ -288,7 +295,11 @@ export const traverseFields = ({
}
}
const relationName = field.dbName ? `_${arrayTableName}` : fieldName
const relationName = getArrayRelationName({
field,
path: fieldName,
tableName: arrayTableName,
})
relationsToBuild.set(relationName, {
type: 'many',
@@ -364,7 +375,7 @@ export const traverseFields = ({
;(field.blockReferences ?? field.blocks).forEach((_block) => {
const block = typeof _block === 'string' ? adapter.payload.blocks[_block] : _block
const blockTableName = createTableName({
let blockTableName = createTableName({
adapter,
config: block,
parentTableName: rootTableName,
@@ -372,6 +383,27 @@ export const traverseFields = ({
throwValidationError,
versionsCustomName: versions,
})
if (typeof blocksTableNameMap[blockTableName] === 'undefined') {
blocksTableNameMap[blockTableName] = 1
} else if (
!validateExistingBlockIsIdentical({
block,
localized: field.localized,
rootTableName,
table: adapter.rawTables[blockTableName],
tableLocales: adapter.rawTables[`${blockTableName}${adapter.localesSuffix}`],
})
) {
blocksTableNameMap[blockTableName]++
setInternalBlockIndex(block, blocksTableNameMap[blockTableName])
blockTableName = `${blockTableName}_${blocksTableNameMap[blockTableName]}`
}
let relationName = `_blocks_${block.slug}`
if (typeof block[InternalBlockTableNameIndex] !== 'undefined') {
relationName = `_blocks_${block.slug}_${block[InternalBlockTableNameIndex]}`
}
if (!adapter.rawTables[blockTableName]) {
const baseColumns: Record<string, RawColumn> = {
_order: {
@@ -451,6 +483,7 @@ export const traverseFields = ({
baseColumns,
baseForeignKeys,
baseIndexes,
blocksTableNameMap,
disableNotNull: disableNotNullFromHere,
disableRelsTableUnique: true,
disableUnique,
@@ -501,7 +534,7 @@ export const traverseFields = ({
},
],
references: ['id'],
relationName: `_blocks_${block.slug}`,
relationName,
to: rootTableName,
},
}
@@ -549,18 +582,10 @@ export const traverseFields = ({
})
adapter.rawRelations[blockTableName] = blockRelations
} else if (process.env.NODE_ENV !== 'production' && !versions) {
validateExistingBlockIsIdentical({
block,
localized: field.localized,
parentIsLocalized: parentIsLocalized || field.localized,
rootTableName,
table: adapter.rawTables[blockTableName],
tableLocales: adapter.rawTables[`${blockTableName}${adapter.localesSuffix}`],
})
}
// blocks relationships are defined from the collection or globals table down to the block, bypassing any subBlocks
rootRelationsToBuild.set(`_blocks_${block.slug}`, {
rootRelationsToBuild.set(relationName, {
type: 'many',
// blocks are not localized on the parent table
localized: false,
@@ -624,6 +649,7 @@ export const traverseFields = ({
hasManyTextField: groupHasManyTextField,
} = traverseFields({
adapter,
blocksTableNameMap,
columnPrefix: `${columnName}_`,
columns,
disableNotNull: disableNotNullFromHere,
@@ -840,6 +866,7 @@ export const traverseFields = ({
baseColumns,
baseForeignKeys,
baseIndexes,
blocksTableNameMap,
disableNotNull,
disableUnique,
fields: [],

View File

@@ -49,6 +49,7 @@ export const transform = <T extends Record<string, unknown> | TypeWithID>({
}
const blocks = createBlocksMap(data)
const deletions = []
const result = traverseFields<T>({

View File

@@ -6,6 +6,8 @@ import toSnakeCase from 'to-snake-case'
import type { DrizzleAdapter } from '../../types.js'
import type { BlocksMap } from '../../utilities/createBlocksMap.js'
import { getArrayRelationName } from '../../utilities/getArrayRelationName.js'
import { resolveBlockTableName } from '../../utilities/validateExistingBlockIsIdentical.js'
import { transformHasManyNumber } from './hasManyNumber.js'
import { transformHasManyText } from './hasManyText.js'
import { transformRelationship } from './relationship.js'
@@ -121,9 +123,7 @@ export const traverseFields = <T extends Record<string, unknown>>({
`${currentTableName}_${tablePath}${toSnakeCase(field.name)}`,
)
if (field.dbName) {
fieldData = table[`_${arrayTableName}`]
}
fieldData = table[getArrayRelationName({ field, path: fieldName, tableName: arrayTableName })]
if (Array.isArray(fieldData)) {
if (isLocalized) {
@@ -249,8 +249,9 @@ export const traverseFields = <T extends Record<string, unknown>>({
(block) => typeof block !== 'string' && block.slug === row.blockType,
) as FlattenedBlock | undefined)
const tableName = adapter.tableNameMap.get(
`${topLevelTableName}_blocks_${toSnakeCase(block.slug)}`,
const tableName = resolveBlockTableName(
block,
adapter.tableNameMap.get(`${topLevelTableName}_blocks_${toSnakeCase(block.slug)}`),
)
if (block) {
@@ -328,8 +329,11 @@ export const traverseFields = <T extends Record<string, unknown>>({
delete row._index
}
const tableName = adapter.tableNameMap.get(
`${topLevelTableName}_blocks_${toSnakeCase(block.slug)}`,
const tableName = resolveBlockTableName(
block,
adapter.tableNameMap.get(
`${topLevelTableName}_blocks_${toSnakeCase(block.slug)}`,
),
)
acc.push(

View File

@@ -6,6 +6,7 @@ import toSnakeCase from 'to-snake-case'
import type { DrizzleAdapter } from '../../types.js'
import type { BlockRowToInsert, RelationshipToDelete } from './types.js'
import { resolveBlockTableName } from '../../utilities/validateExistingBlockIsIdentical.js'
import { traverseFields } from './traverseFields.js'
type Args = {
@@ -66,10 +67,6 @@ export const transformBlocks = ({
}
const blockType = toSnakeCase(blockRow.blockType)
if (!blocks[blockType]) {
blocks[blockType] = []
}
const newRow: BlockRowToInsert = {
arrays: {},
locales: {},
@@ -86,7 +83,14 @@ export const transformBlocks = ({
newRow.row._locale = withinArrayOrBlockLocale
}
const blockTableName = adapter.tableNameMap.get(`${baseTableName}_blocks_${blockType}`)
const blockTableName = resolveBlockTableName(
matchedBlock,
adapter.tableNameMap.get(`${baseTableName}_blocks_${blockType}`),
)
if (!blocks[blockTableName]) {
blocks[blockTableName] = []
}
const hasUUID = adapter.tables[blockTableName]._uuid
@@ -124,6 +128,6 @@ export const transformBlocks = ({
withinArrayOrBlockLocale,
})
blocks[blockType].push(newRow)
blocks[blockTableName].push(newRow)
})
}

View File

@@ -8,6 +8,7 @@ import type { DrizzleAdapter } from '../../types.js'
import type { ArrayRowToInsert, BlockRowToInsert, RelationshipToDelete } from './types.js'
import { isArrayOfRows } from '../../utilities/isArrayOfRows.js'
import { resolveBlockTableName } from '../../utilities/validateExistingBlockIsIdentical.js'
import { transformArray } from './array.js'
import { transformBlocks } from './blocks.js'
import { transformNumbers } from './numbers.js'
@@ -175,7 +176,17 @@ export const traverseFields = ({
if (field.type === 'blocks') {
;(field.blockReferences ?? field.blocks).forEach((block) => {
blocksToDelete.add(toSnakeCase(typeof block === 'string' ? block : block.slug))
const matchedBlock =
typeof block === 'string'
? adapter.payload.config.blocks.find((each) => each.slug === block)
: block
blocksToDelete.add(
resolveBlockTableName(
matchedBlock,
adapter.tableNameMap.get(`${baseTableName}_blocks_${toSnakeCase(matchedBlock.slug)}`),
),
)
})
if (isLocalized) {

View File

@@ -28,7 +28,7 @@ export type RowToInsert = {
[tableName: string]: ArrayRowToInsert[]
}
blocks: {
[blockType: string]: BlockRowToInsert[]
[tableName: string]: BlockRowToInsert[]
}
blocksToDelete: Set<string>
locales: {

View File

@@ -134,16 +134,16 @@ export const upsertRow = async <T extends Record<string, unknown> | TypeWithID>(
// If there are blocks, add parent to each, and then
// store by table name and rows
Object.keys(rowToInsert.blocks).forEach((blockName) => {
rowToInsert.blocks[blockName].forEach((blockRow) => {
Object.keys(rowToInsert.blocks).forEach((tableName) => {
rowToInsert.blocks[tableName].forEach((blockRow) => {
blockRow.row._parentID = insertedRow.id
if (!blocksToInsert[blockName]) {
blocksToInsert[blockName] = []
if (!blocksToInsert[tableName]) {
blocksToInsert[tableName] = []
}
if (blockRow.row.uuid) {
delete blockRow.row.uuid
}
blocksToInsert[blockName].push(blockRow)
blocksToInsert[tableName].push(blockRow)
})
})
@@ -258,12 +258,11 @@ export const upsertRow = async <T extends Record<string, unknown> | TypeWithID>(
const insertedBlockRows: Record<string, Record<string, unknown>[]> = {}
if (operation === 'update') {
for (const blockName of rowToInsert.blocksToDelete) {
const blockTableName = adapter.tableNameMap.get(`${tableName}_blocks_${blockName}`)
const blockTable = adapter.tables[blockTableName]
for (const tableName of rowToInsert.blocksToDelete) {
const blockTable = adapter.tables[tableName]
await adapter.deleteWhere({
db,
tableName: blockTableName,
tableName,
where: eq(blockTable._parentID, insertedRow.id),
})
}
@@ -272,15 +271,14 @@ export const upsertRow = async <T extends Record<string, unknown> | TypeWithID>(
// When versions are enabled, this is used to track mapping between blocks/arrays ObjectID to their numeric generated representation, then we use it for nested to arrays/blocks select hasMany in versions.
const arraysBlocksUUIDMap: Record<string, number | string> = {}
for (const [blockName, blockRows] of Object.entries(blocksToInsert)) {
const blockTableName = adapter.tableNameMap.get(`${tableName}_blocks_${blockName}`)
insertedBlockRows[blockName] = await adapter.insert({
for (const [tableName, blockRows] of Object.entries(blocksToInsert)) {
insertedBlockRows[tableName] = await adapter.insert({
db,
tableName: blockTableName,
tableName,
values: blockRows.map(({ row }) => row),
})
insertedBlockRows[blockName].forEach((row, i) => {
insertedBlockRows[tableName].forEach((row, i) => {
blockRows[i].row = row
if (
typeof row._uuid === 'string' &&
@@ -310,7 +308,7 @@ export const upsertRow = async <T extends Record<string, unknown> | TypeWithID>(
if (blockLocaleRowsToInsert.length > 0) {
await adapter.insert({
db,
tableName: `${blockTableName}${adapter.localesSuffix}`,
tableName: `${tableName}${adapter.localesSuffix}`,
values: blockLocaleRowsToInsert,
})
}
@@ -319,7 +317,7 @@ export const upsertRow = async <T extends Record<string, unknown> | TypeWithID>(
adapter,
arrays: blockRows.map(({ arrays }) => arrays),
db,
parentRows: insertedBlockRows[blockName],
parentRows: insertedBlockRows[tableName],
uuidMap: arraysBlocksUUIDMap,
})
}

View File

@@ -7,7 +7,11 @@ export const createBlocksMap = (data: Record<string, unknown>): BlocksMap => {
Object.entries(data).forEach(([key, rows]) => {
if (key.startsWith('_blocks_') && Array.isArray(rows)) {
const blockType = key.replace('_blocks_', '')
let blockType = key.replace('_blocks_', '')
const parsed = blockType.split('_')
if (parsed.length === 2 && Number.isInteger(Number(parsed[1]))) {
blockType = parsed[0]
}
rows.forEach((row) => {
if ('_path' in row) {

View File

@@ -267,8 +267,11 @@ declare module '${this.packageName}' {
*/
`
const importTypes = `import type {} from '${this.packageName}'`
let code = [
warning,
importTypes,
...importDeclarationsSanitized,
schemaDeclaration,
...enumDeclarations,

View File

@@ -0,0 +1,17 @@
import type { ArrayField } from 'payload'
export const getArrayRelationName = ({
field,
path,
tableName,
}: {
field: ArrayField
path: string
tableName: string
}) => {
if (field.dbName && path.length > 63) {
return `_${tableName}`
}
return path
}

View File

@@ -1,4 +1,4 @@
import { deepStrictEqual } from 'assert'
import { dequal } from 'dequal'
import prompts from 'prompts'
import type { BasePostgresAdapter } from '../postgres/types.js'
@@ -23,18 +23,18 @@ export const pushDevSchema = async (adapter: DrizzleAdapter) => {
const localeCodes =
adapter.payload.config.localization && adapter.payload.config.localization.localeCodes
try {
deepStrictEqual(previousSchema, {
localeCodes,
rawTables: adapter.rawTables,
})
const equal = dequal(previousSchema, {
localeCodes,
rawTables: adapter.rawTables,
})
if (equal) {
if (adapter.logger) {
adapter.payload.logger.info('No changes detected in schema, skipping schema push.')
}
return
} catch {
} else {
previousSchema.localeCodes = localeCodes
previousSchema.rawTables = adapter.rawTables
}

View File

@@ -1,6 +1,5 @@
import type { Block, Field } from 'payload'
import type { Block, Field, FlattenedBlock } from 'payload'
import { InvalidConfiguration } from 'payload'
import {
fieldAffectsData,
fieldHasSubFields,
@@ -83,14 +82,16 @@ const getFlattenedFieldNames = (args: {
}, [])
}
/**
* returns true if all the fields in a block are identical to the existing table
*/
export const validateExistingBlockIsIdentical = ({
block,
localized,
parentIsLocalized,
rootTableName,
table,
tableLocales,
}: Args): void => {
}: Args): boolean => {
const fieldNames = getFlattenedFieldNames({
fields: block.fields,
parentIsLocalized: parentIsLocalized || localized,
@@ -110,18 +111,21 @@ export const validateExistingBlockIsIdentical = ({
})
if (missingField) {
throw new InvalidConfiguration(
`The table ${rootTableName} has multiple blocks with slug ${
block.slug
}, but the schemas do not match. One block includes the field ${
typeof missingField === 'string' ? missingField : missingField.name
}, while the other block does not.`,
)
return false
}
if (Boolean(localized) !== Boolean(table.columns._locale)) {
throw new InvalidConfiguration(
`The table ${rootTableName} has multiple blocks with slug ${block.slug}, but the schemas do not match. One is localized, but another is not. Block schemas of the same name must match exactly.`,
)
}
return Boolean(localized) === Boolean(table.columns._locale)
}
export const InternalBlockTableNameIndex = Symbol('InternalBlockTableNameIndex')
export const setInternalBlockIndex = (block: FlattenedBlock, index: number) => {
block[InternalBlockTableNameIndex] = index
}
export const resolveBlockTableName = (block: FlattenedBlock, originalTableName: string) => {
if (!block[InternalBlockTableNameIndex]) {
return originalTableName
}
return `${originalTableName}_${block[InternalBlockTableNameIndex]}`
}

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/graphql",
"version": "3.37.0",
"version": "3.38.0",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",

View File

@@ -145,27 +145,37 @@ export function buildMutationInputType({
},
}),
group: (inputObjectTypeConfig: InputObjectTypeConfig, field: GroupField) => {
const requiresAtLeastOneField = groupOrTabHasRequiredSubfield(field)
const fullName = combineParentName(parentName, toWords(field.name, true))
let type: GraphQLType = buildMutationInputType({
name: fullName,
config,
fields: field.fields,
graphqlResult,
parentIsLocalized: parentIsLocalized || field.localized,
parentName: fullName,
})
if (fieldAffectsData(field)) {
const requiresAtLeastOneField = groupOrTabHasRequiredSubfield(field)
const fullName = combineParentName(parentName, toWords(field.name, true))
let type: GraphQLType = buildMutationInputType({
name: fullName,
config,
fields: field.fields,
graphqlResult,
parentIsLocalized: parentIsLocalized || field.localized,
parentName: fullName,
})
if (!type) {
return inputObjectTypeConfig
}
if (!type) {
return inputObjectTypeConfig
}
if (requiresAtLeastOneField) {
type = new GraphQLNonNull(type)
}
return {
...inputObjectTypeConfig,
[formatName(field.name)]: { type },
if (requiresAtLeastOneField) {
type = new GraphQLNonNull(type)
}
return {
...inputObjectTypeConfig,
[formatName(field.name)]: { type },
}
} else {
return field.fields.reduce((acc, subField: CollapsibleField) => {
const addSubField = fieldToSchemaMap[subField.type]
if (addSubField) {
return addSubField(acc, subField)
}
return acc
}, inputObjectTypeConfig)
}
},
json: (inputObjectTypeConfig: InputObjectTypeConfig, field: JSONField) => ({

View File

@@ -41,7 +41,7 @@ import {
} from 'graphql'
import { DateTimeResolver, EmailAddressResolver } from 'graphql-scalars'
import { combineQueries, createDataloaderCacheKey, MissingEditorProp, toWords } from 'payload'
import { tabHasName } from 'payload/shared'
import { fieldAffectsData, tabHasName } from 'payload/shared'
import type { Context } from '../resolvers/types.js'
@@ -302,44 +302,64 @@ export const fieldToSchemaMap: FieldToSchemaMap = {
field,
forceNullable,
graphqlResult,
newlyCreatedBlockType,
objectTypeConfig,
parentIsLocalized,
parentName,
}) => {
const interfaceName =
field?.interfaceName || combineParentName(parentName, toWords(field.name, true))
if (fieldAffectsData(field)) {
const interfaceName =
field?.interfaceName || combineParentName(parentName, toWords(field.name, true))
if (!graphqlResult.types.groupTypes[interfaceName]) {
const objectType = buildObjectType({
name: interfaceName,
config,
fields: field.fields,
forceNullable: isFieldNullable({ field, forceNullable, parentIsLocalized }),
graphqlResult,
parentIsLocalized: field.localized || parentIsLocalized,
parentName: interfaceName,
})
if (!graphqlResult.types.groupTypes[interfaceName]) {
const objectType = buildObjectType({
name: interfaceName,
config,
fields: field.fields,
forceNullable: isFieldNullable({ field, forceNullable, parentIsLocalized }),
graphqlResult,
parentIsLocalized: field.localized || parentIsLocalized,
parentName: interfaceName,
})
if (Object.keys(objectType.getFields()).length) {
graphqlResult.types.groupTypes[interfaceName] = objectType
if (Object.keys(objectType.getFields()).length) {
graphqlResult.types.groupTypes[interfaceName] = objectType
}
}
}
if (!graphqlResult.types.groupTypes[interfaceName]) {
return objectTypeConfig
}
if (!graphqlResult.types.groupTypes[interfaceName]) {
return objectTypeConfig
}
return {
...objectTypeConfig,
[formatName(field.name)]: {
type: graphqlResult.types.groupTypes[interfaceName],
resolve: (parent, args, context: Context) => {
return {
...parent[field.name],
_id: parent._id ?? parent.id,
}
return {
...objectTypeConfig,
[formatName(field.name)]: {
type: graphqlResult.types.groupTypes[interfaceName],
resolve: (parent, args, context: Context) => {
return {
...parent[field.name],
_id: parent._id ?? parent.id,
}
},
},
},
}
} else {
return field.fields.reduce((objectTypeConfigWithCollapsibleFields, subField) => {
const addSubField: GenericFieldToSchemaMap = fieldToSchemaMap[subField.type]
if (addSubField) {
return addSubField({
config,
field: subField,
forceNullable,
graphqlResult,
newlyCreatedBlockType,
objectTypeConfig: objectTypeConfigWithCollapsibleFields,
parentIsLocalized,
parentName,
})
}
return objectTypeConfigWithCollapsibleFields
}, objectTypeConfig)
}
},
join: ({ collectionSlug, field, graphqlResult, objectTypeConfig, parentName }) => {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/live-preview-react",
"version": "3.37.0",
"version": "3.38.0",
"description": "The official React SDK for Payload Live Preview",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/live-preview-vue",
"version": "3.37.0",
"version": "3.38.0",
"description": "The official Vue SDK for Payload Live Preview",
"homepage": "https://payloadcms.com",
"repository": {

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/next",
"version": "3.37.0",
"version": "3.38.0",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",

View File

@@ -1,41 +1,36 @@
import type { DefaultDocumentIDType, NavPreferences, Payload, User } from 'payload'
import type { NavPreferences, PayloadRequest } from 'payload'
import { cache } from 'react'
export const getNavPrefs = cache(
async (
payload: Payload,
userID: DefaultDocumentIDType,
userSlug: string,
): Promise<NavPreferences> => {
return userSlug
? await payload
.find({
collection: 'payload-preferences',
depth: 0,
limit: 1,
pagination: false,
where: {
and: [
{
key: {
equals: 'nav',
},
export const getNavPrefs = cache(async (req: PayloadRequest): Promise<NavPreferences> => {
return req?.user?.collection
? await req.payload
.find({
collection: 'payload-preferences',
depth: 0,
limit: 1,
pagination: false,
req,
where: {
and: [
{
key: {
equals: 'nav',
},
{
'user.relationTo': {
equals: userSlug,
},
},
{
'user.relationTo': {
equals: req.user.collection,
},
{
'user.value': {
equals: userID,
},
},
{
'user.value': {
equals: req?.user?.id,
},
],
},
})
?.then((res) => res?.docs?.[0]?.value)
: null
},
)
},
],
},
})
?.then((res) => res?.docs?.[0]?.value)
: null
})

View File

@@ -1,5 +1,5 @@
import type { EntityToGroup } from '@payloadcms/ui/shared'
import type { ServerProps } from 'payload'
import type { PayloadRequest, ServerProps } from 'payload'
import { Logout } from '@payloadcms/ui'
import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
@@ -15,7 +15,9 @@ const baseClass = 'nav'
import { getNavPrefs } from './getNavPrefs.js'
import { DefaultNavClient } from './index.client.js'
export type NavProps = ServerProps
export type NavProps = {
req?: PayloadRequest
} & ServerProps
export const DefaultNav: React.FC<NavProps> = async (props) => {
const {
@@ -25,6 +27,7 @@ export const DefaultNav: React.FC<NavProps> = async (props) => {
params,
payload,
permissions,
req,
searchParams,
user,
viewType,
@@ -68,7 +71,7 @@ export const DefaultNav: React.FC<NavProps> = async (props) => {
i18n,
)
const navPreferences = await getNavPrefs(payload, user?.id, user?.collection)
const navPreferences = await getNavPrefs(req)
const LogoutComponent = RenderServerComponent({
clientProps: {

View File

@@ -79,7 +79,7 @@ export const RootLayout = async ({
})
}
const navPrefs = await getNavPrefs(req.payload, req.user?.id, req.user?.collection)
const navPrefs = await getNavPrefs(req)
const clientConfig = getClientConfig({
config,

View File

@@ -1,6 +1,7 @@
import type {
CustomComponent,
DocumentSubViewTypes,
PayloadRequest,
ServerProps,
ViewTypes,
VisibleEntities,
@@ -32,6 +33,7 @@ export type DefaultTemplateProps = {
docID?: number | string
documentSubViewType?: DocumentSubViewTypes
globalSlug?: string
req?: PayloadRequest
viewActions?: CustomComponent[]
viewType?: ViewTypes
visibleEntities: VisibleEntities
@@ -49,6 +51,7 @@ export const DefaultTemplate: React.FC<DefaultTemplateProps> = ({
params,
payload,
permissions,
req,
searchParams,
user,
viewActions,
@@ -84,6 +87,7 @@ export const DefaultTemplate: React.FC<DefaultTemplateProps> = ({
params,
payload,
permissions,
req,
searchParams,
user,
}),
@@ -98,6 +102,7 @@ export const DefaultTemplate: React.FC<DefaultTemplateProps> = ({
globalSlug,
collectionSlug,
docID,
req,
],
)

View File

@@ -29,6 +29,7 @@ export const getDocumentData = async ({
}: Args): Promise<null | Record<string, unknown> | TypeWithID> => {
const id = sanitizeID(idArg)
let resolvedData: Record<string, unknown> | TypeWithID = null
const { transactionID, ...rest } = req
try {
if (collectionSlug && id) {
@@ -41,9 +42,7 @@ export const getDocumentData = async ({
locale: locale?.code,
overrideAccess: false,
req: {
query: req?.query,
search: req?.search,
searchParams: req?.searchParams,
...rest,
},
user,
})
@@ -58,9 +57,7 @@ export const getDocumentData = async ({
locale: locale?.code,
overrideAccess: false,
req: {
query: req?.query,
search: req?.search,
searchParams: req?.searchParams,
...rest,
},
user,
})

View File

@@ -167,6 +167,7 @@ export const RootPage = async ({
params={params}
payload={initPageResult?.req.payload}
permissions={initPageResult?.permissions}
req={initPageResult?.req}
searchParams={searchParams}
user={initPageResult?.req.user}
viewActions={serverProps.viewActions}

View File

@@ -132,6 +132,7 @@ export const withPayload = (nextConfig = {}, options = {}) => {
'drizzle-kit/api',
'sharp',
'libsql',
'require-in-the-middle',
],
ignoreWarnings: [
...(incomingWebpackConfig?.ignoreWarnings || []),

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/payload-cloud",
"version": "3.37.0",
"version": "3.38.0",
"description": "The official Payload Cloud plugin",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -16,6 +16,14 @@ export const generateRandomString = (): string => {
return Array.from({ length: 24 }, () => chars[Math.floor(Math.random() * chars.length)]).join('')
}
const DEFAULT_CRON = '* * * * *'
const DEFAULT_LIMIT = 10
const DEFAULT_CRON_JOB = {
cron: DEFAULT_CRON,
limit: DEFAULT_LIMIT,
queue: 'default',
}
export const payloadCloudPlugin =
(pluginOptions?: PluginOptions) =>
async (incomingConfig: Config): Promise<Config> => {
@@ -100,15 +108,6 @@ export const payloadCloudPlugin =
}
// We make sure to only run cronjobs on one instance using a instance identifier stored in a global.
const DEFAULT_CRON = '* * * * *'
const DEFAULT_LIMIT = 10
const DEFAULT_CRON_JOB = {
cron: DEFAULT_CRON,
limit: DEFAULT_LIMIT,
queue: 'default',
}
config.globals = [
...(config.globals || []),
{
@@ -126,13 +125,13 @@ export const payloadCloudPlugin =
},
]
if (pluginOptions?.enableAutoRun === false || !config.jobs) {
if (pluginOptions?.enableAutoRun === false) {
return config
}
const oldAutoRunCopy = config.jobs.autoRun ?? []
const oldAutoRunCopy = config.jobs?.autoRun ?? []
const hasExistingAutorun = Boolean(config.jobs.autoRun)
const hasExistingAutorun = Boolean(config.jobs?.autoRun)
const newShouldAutoRun = async (payload: Payload) => {
if (process.env.PAYLOAD_CLOUD_JOBS_INSTANCE) {
@@ -150,8 +149,8 @@ export const payloadCloudPlugin =
return false
}
if (!config.jobs.shouldAutoRun) {
config.jobs.shouldAutoRun = newShouldAutoRun
if (!config.jobs?.shouldAutoRun) {
;(config.jobs ??= {}).shouldAutoRun = newShouldAutoRun
}
const newAutoRun = async (payload: Payload) => {

View File

@@ -1,6 +1,6 @@
{
"name": "payload",
"version": "3.37.0",
"version": "3.38.0",
"description": "Node, React, Headless CMS and Application Framework built on Next.js",
"keywords": [
"admin panel",

View File

@@ -65,6 +65,7 @@ export type AfterChangeRichTextHookArgs<
/** The previous value of the field, before changes */
previousValue?: TValue
}
export type BeforeValidateRichTextHookArgs<
TData extends TypeWithID = any,
TValue = any,
@@ -102,11 +103,11 @@ export type BeforeChangeRichTextHookArgs<
mergeLocaleActions?: (() => Promise<void> | void)[]
/** A string relating to which operation the field type is currently executing within. */
operation?: 'create' | 'delete' | 'read' | 'update'
overrideAccess: boolean
/** The sibling data of the document before changes being applied. */
previousSiblingDoc?: TData
/** The previous value of the field, before changes */
previousValue?: TValue
/**
* The original siblingData with locales (not modified by any hooks).
*/
@@ -190,6 +191,7 @@ export type RichTextHooks = {
beforeChange?: BeforeChangeRichTextHook[]
beforeValidate?: BeforeValidateRichTextHook[]
}
type RichTextAdapterBase<
Value extends object = object,
AdapterProps = any,

View File

@@ -91,6 +91,7 @@ export type ServerComponentProps = {
req: PayloadRequest
siblingData: Data
user: User
value?: unknown
}
export type ClientFieldBase<

View File

@@ -28,25 +28,35 @@ const traverseFields = ({
break
}
case 'group': {
let targetResult
if (typeof field.saveToJWT === 'string') {
targetResult = field.saveToJWT
result[field.saveToJWT] = data[field.name]
} else if (field.saveToJWT) {
targetResult = field.name
result[field.name] = data[field.name]
if (fieldAffectsData(field)) {
let targetResult
if (typeof field.saveToJWT === 'string') {
targetResult = field.saveToJWT
result[field.saveToJWT] = data[field.name]
} else if (field.saveToJWT) {
targetResult = field.name
result[field.name] = data[field.name]
}
const groupData: Record<string, unknown> = data[field.name] as Record<string, unknown>
const groupResult = (targetResult ? result[targetResult] : result) as Record<
string,
unknown
>
traverseFields({
data: groupData,
fields: field.fields,
result: groupResult,
})
break
} else {
traverseFields({
data,
fields: field.fields,
result,
})
break
}
const groupData: Record<string, unknown> = data[field.name] as Record<string, unknown>
const groupResult = (targetResult ? result[targetResult] : result) as Record<
string,
unknown
>
traverseFields({
data: groupData,
fields: field.fields,
result: groupResult,
})
break
}
case 'tab': {
if (tabHasName(field)) {

View File

@@ -1,151 +0,0 @@
// @ts-strict-ignore
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

@@ -26,7 +26,6 @@ import {
addDefaultsToCollectionConfig,
addDefaultsToLoginWithUsernameConfig,
} from './defaults.js'
import { sanitizeAuthFields, sanitizeUploadFields } from './reservedFieldNames.js'
import { sanitizeCompoundIndexes } from './sanitizeCompoundIndexes.js'
import { validateUseAsTitle } from './useAsTitle.js'
@@ -43,7 +42,9 @@ export const sanitizeCollection = async (
if (collection._sanitized) {
return collection as SanitizedCollectionConfig
}
collection._sanitized = true
// /////////////////////////////////
// Make copy of collection config
// /////////////////////////////////
@@ -57,7 +58,9 @@ export const sanitizeCollection = async (
const validRelationships = _validRelationships ?? config.collections.map((c) => c.slug) ?? []
const joins: SanitizedJoins = {}
const polymorphicJoins: SanitizedJoin[] = []
sanitized.fields = await sanitizeFields({
collectionConfig: sanitized,
config,
@@ -96,17 +99,21 @@ export const sanitizeCollection = async (
// add default timestamps fields only as needed
let hasUpdatedAt: boolean | null = null
let hasCreatedAt: boolean | null = null
sanitized.fields.some((field) => {
if (fieldAffectsData(field)) {
if (field.name === 'updatedAt') {
hasUpdatedAt = true
}
if (field.name === 'createdAt') {
hasCreatedAt = true
}
}
return hasCreatedAt && hasUpdatedAt
})
if (!hasUpdatedAt) {
sanitized.fields.push({
name: 'updatedAt',
@@ -119,6 +126,7 @@ export const sanitizeCollection = async (
label: ({ t }) => t('general:updatedAt'),
})
}
if (!hasCreatedAt) {
sanitized.fields.push({
name: 'createdAt',
@@ -175,9 +183,6 @@ export const sanitizeCollection = async (
sanitized.upload = {}
}
// sanitize fields for reserved names
sanitizeUploadFields(sanitized.fields, sanitized)
sanitized.upload.cacheTags = sanitized.upload?.cacheTags ?? true
sanitized.upload.bulkUpload = sanitized.upload?.bulkUpload ?? true
sanitized.upload.staticDir = sanitized.upload.staticDir || sanitized.slug
@@ -195,9 +200,6 @@ export const sanitizeCollection = async (
}
if (sanitized.auth) {
// sanitize fields for reserved names
sanitizeAuthFields(sanitized.fields, sanitized)
sanitized.auth = addDefaultsToAuthConfig(
typeof sanitized.auth === 'boolean' ? {} : sanitized.auth,
)

View File

@@ -1,7 +1,7 @@
import type { CollectionConfig } from '../../index.js'
import { InvalidConfiguration } from '../../errors/InvalidConfiguration.js'
import { fieldAffectsData, fieldIsVirtual } from '../../fields/config/types.js'
import { fieldAffectsData } from '../../fields/config/types.js'
import flattenFields from '../../utilities/flattenTopLevelFields.js'
/**

View File

@@ -9,9 +9,6 @@ import type {
TransformCollectionWithSelect,
} from '../../types/index.js'
import type {
AfterChangeHook,
BeforeOperationHook,
BeforeValidateHook,
Collection,
DataFromCollectionSlug,
RequiredDataFromCollectionSlug,
@@ -225,6 +222,7 @@ export const createOperation = async <
docWithLocales: duplicatedFromDocWithLocales,
global: null,
operation: 'create',
overrideAccess,
req,
skipValidation:
shouldSaveDraft &&

View File

@@ -5,7 +5,6 @@ import type { AccessResult } from '../../config/types.js'
import type { CollectionSlug } from '../../index.js'
import type { PayloadRequest, PopulateType, SelectType, Where } from '../../types/index.js'
import type {
BeforeOperationHook,
BulkOperationResult,
Collection,
DataFromCollectionSlug,

View File

@@ -6,7 +6,7 @@ import type {
SelectType,
TransformCollectionWithSelect,
} from '../../types/index.js'
import type { BeforeOperationHook, Collection, DataFromCollectionSlug } from '../config/types.js'
import type { Collection, DataFromCollectionSlug } from '../config/types.js'
import executeAccess from '../../auth/executeAccess.js'
import { hasWhereAccessResult } from '../../auth/types.js'

View File

@@ -139,6 +139,7 @@ export default async function createLocal<
select,
showHiddenFields,
} = options
const collection = payload.collections[collectionSlug]
if (!collection) {
@@ -148,6 +149,7 @@ export default async function createLocal<
}
const req = await createLocalReq(options, payload)
req.file = file ?? (await getFileByPath(filePath))
return createOperation<TSlug, TSelect>({

View File

@@ -109,6 +109,7 @@ export async function duplicate<
select,
showHiddenFields,
} = options
const collection = payload.collections[collectionSlug]
if (!collection) {

View File

@@ -234,6 +234,7 @@ export const updateDocument = async <
docWithLocales: undefined,
global: null,
operation: 'update',
overrideAccess,
req,
skipValidation:
shouldSaveDraft &&

View File

@@ -58,6 +58,7 @@ const sanitizeAdminConfig = (configToSanitize: Config): Partial<SanitizedConfig>
// add default user collection if none provided
if (!sanitizedConfig?.admin?.user) {
const firstCollectionWithAuth = sanitizedConfig.collections.find(({ auth }) => Boolean(auth))
if (firstCollectionWithAuth) {
sanitizedConfig.admin.user = firstCollectionWithAuth.slug
} else {
@@ -69,6 +70,7 @@ const sanitizeAdminConfig = (configToSanitize: Config): Partial<SanitizedConfig>
const userCollection = sanitizedConfig.collections.find(
({ slug }) => slug === sanitizedConfig.admin.user,
)
if (!userCollection || !userCollection.auth) {
throw new InvalidConfiguration(
`${sanitizedConfig.admin.user} is not a valid admin user collection`,

View File

@@ -0,0 +1 @@
export { lv } from '@payloadcms/translations/languages/lv'

View File

@@ -2,7 +2,7 @@ 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'
import { sanitizeCollection } from '../../collections/config/sanitize.js'
describe('reservedFieldNames - collections -', () => {
const config = {
@@ -25,6 +25,7 @@ describe('reservedFieldNames - collections -', () => {
label: 'some-collection',
},
]
await expect(async () => {
await sanitizeCollection(
// @ts-expect-error
@@ -53,6 +54,7 @@ describe('reservedFieldNames - collections -', () => {
label: 'some-collection',
},
]
await expect(async () => {
await sanitizeCollection(
// @ts-expect-error
@@ -93,6 +95,7 @@ describe('reservedFieldNames - collections -', () => {
label: 'some-collection',
},
]
await expect(async () => {
await sanitizeCollection(
// @ts-expect-error
@@ -121,6 +124,7 @@ describe('reservedFieldNames - collections -', () => {
label: 'some-collection',
},
]
await expect(async () => {
await sanitizeCollection(
// @ts-expect-error
@@ -149,6 +153,7 @@ describe('reservedFieldNames - collections -', () => {
label: 'some-collection',
},
]
await expect(async () => {
await sanitizeCollection(
// @ts-expect-error

View File

@@ -0,0 +1,48 @@
/**
* Reserved field names for collections with auth config enabled
*/
export const reservedBaseAuthFieldNames = [
/* 'email',
'resetPasswordToken',
'resetPasswordExpiration', */
'salt',
'hash',
]
/**
* Reserved field names for auth collections with verify: true
*/
export const reservedVerifyFieldNames = [
/* '_verified', '_verificationToken' */
]
/**
* Reserved field names for auth collections with useApiKey: true
*/
export const reservedAPIKeyFieldNames = [
/* 'enableAPIKey', 'apiKeyIndex', 'apiKey' */
]
/**
* Reserved field names for collections with upload config enabled
*/
export const reservedBaseUploadFieldNames = [
'file',
/* 'mimeType',
'thumbnailURL',
'width',
'height',
'filesize',
'filename',
'url',
'focalX',
'focalY',
'sizes', */
]
/**
* Reserved field names for collections with versions enabled
*/
export const reservedVersionsFieldNames = [
/* '__v', '_status' */
]

View File

@@ -11,9 +11,12 @@ import type {
import { InvalidFieldName, InvalidFieldRelationship, MissingFieldType } from '../../errors/index.js'
import { sanitizeFields } from './sanitize.js'
import { CollectionConfig } from '../../index.js'
describe('sanitizeFields', () => {
const config = {} as Config
const collectionConfig = {} as CollectionConfig
it('should throw on missing type field', async () => {
const fields: Field[] = [
// @ts-expect-error
@@ -22,14 +25,17 @@ describe('sanitizeFields', () => {
label: 'some-collection',
},
]
await expect(async () => {
await sanitizeFields({
config,
collectionConfig,
fields,
validRelationships: [],
})
}).rejects.toThrow(MissingFieldType)
})
it('should throw on invalid field name', async () => {
const fields: Field[] = [
{
@@ -38,9 +44,11 @@ describe('sanitizeFields', () => {
label: 'some.collection',
},
]
await expect(async () => {
await sanitizeFields({
config,
collectionConfig,
fields,
validRelationships: [],
})
@@ -55,17 +63,21 @@ describe('sanitizeFields', () => {
type: 'text',
},
]
const sanitizedField = (
await sanitizeFields({
config,
collectionConfig,
fields,
validRelationships: [],
})
)[0] as TextField
expect(sanitizedField.name).toStrictEqual('someField')
expect(sanitizedField.label).toStrictEqual('Some Field')
expect(sanitizedField.type).toStrictEqual('text')
})
it('should allow auto-label override', async () => {
const fields: Field[] = [
{
@@ -74,13 +86,16 @@ describe('sanitizeFields', () => {
label: 'Do not label',
},
]
const sanitizedField = (
await sanitizeFields({
config,
collectionConfig,
fields,
validRelationships: [],
})
)[0] as TextField
expect(sanitizedField.name).toStrictEqual('someField')
expect(sanitizedField.label).toStrictEqual('Do not label')
expect(sanitizedField.type).toStrictEqual('text')
@@ -95,13 +110,16 @@ describe('sanitizeFields', () => {
label: false,
},
]
const sanitizedField = (
await sanitizeFields({
config,
collectionConfig,
fields,
validRelationships: [],
})
)[0] as TextField
expect(sanitizedField.name).toStrictEqual('someField')
expect(sanitizedField.label).toStrictEqual(false)
expect(sanitizedField.type).toStrictEqual('text')
@@ -119,18 +137,22 @@ describe('sanitizeFields', () => {
],
label: false,
}
const sanitizedField = (
await sanitizeFields({
config,
collectionConfig,
fields: [arrayField],
validRelationships: [],
})
)[0] as ArrayField
expect(sanitizedField.name).toStrictEqual('items')
expect(sanitizedField.label).toStrictEqual(false)
expect(sanitizedField.type).toStrictEqual('array')
expect(sanitizedField.labels).toBeUndefined()
})
it('should allow label opt-out for blocks', async () => {
const fields: Field[] = [
{
@@ -150,13 +172,16 @@ describe('sanitizeFields', () => {
label: false,
},
]
const sanitizedField = (
await sanitizeFields({
config,
collectionConfig,
fields,
validRelationships: [],
})
)[0] as BlocksField
expect(sanitizedField.name).toStrictEqual('noLabelBlock')
expect(sanitizedField.label).toStrictEqual(false)
expect(sanitizedField.type).toStrictEqual('blocks')
@@ -177,13 +202,16 @@ describe('sanitizeFields', () => {
],
},
]
const sanitizedField = (
await sanitizeFields({
config,
collectionConfig,
fields,
validRelationships: [],
})
)[0] as ArrayField
expect(sanitizedField.name).toStrictEqual('items')
expect(sanitizedField.label).toStrictEqual('Items')
expect(sanitizedField.type).toStrictEqual('array')
@@ -203,13 +231,16 @@ describe('sanitizeFields', () => {
],
},
]
const sanitizedField = (
await sanitizeFields({
config,
collectionConfig,
fields,
validRelationships: [],
})
)[0] as BlocksField
expect(sanitizedField.name).toStrictEqual('specialBlock')
expect(sanitizedField.label).toStrictEqual('Special Block')
expect(sanitizedField.type).toStrictEqual('blocks')
@@ -217,6 +248,7 @@ describe('sanitizeFields', () => {
plural: 'Special Blocks',
singular: 'Special Block',
})
expect((sanitizedField.blocks[0].fields[0] as NumberField).label).toStrictEqual('Test Number')
})
})
@@ -232,8 +264,9 @@ describe('sanitizeFields', () => {
relationTo: 'some-collection',
},
]
await expect(async () => {
await sanitizeFields({ config, fields, validRelationships })
await sanitizeFields({ config, collectionConfig, fields, validRelationships })
}).not.toThrow()
})
@@ -247,8 +280,9 @@ describe('sanitizeFields', () => {
relationTo: ['some-collection', 'another-collection'],
},
]
await expect(async () => {
await sanitizeFields({ config, fields, validRelationships })
await sanitizeFields({ config, collectionConfig, fields, validRelationships })
}).not.toThrow()
})
@@ -265,6 +299,7 @@ describe('sanitizeFields', () => {
},
],
}
const fields: Field[] = [
{
name: 'layout',
@@ -273,8 +308,9 @@ describe('sanitizeFields', () => {
label: 'Layout Blocks',
},
]
await expect(async () => {
await sanitizeFields({ config, fields, validRelationships })
await sanitizeFields({ config, collectionConfig, fields, validRelationships })
}).not.toThrow()
})
@@ -288,8 +324,9 @@ describe('sanitizeFields', () => {
relationTo: 'not-valid',
},
]
await expect(async () => {
await sanitizeFields({ config, fields, validRelationships })
await sanitizeFields({ config, collectionConfig, fields, validRelationships })
}).rejects.toThrow(InvalidFieldRelationship)
})
@@ -303,8 +340,9 @@ describe('sanitizeFields', () => {
relationTo: ['some-collection', 'not-valid'],
},
]
await expect(async () => {
await sanitizeFields({ config, fields, validRelationships })
await sanitizeFields({ config, collectionConfig, fields, validRelationships })
}).rejects.toThrow(InvalidFieldRelationship)
})
@@ -321,6 +359,7 @@ describe('sanitizeFields', () => {
},
],
}
const fields: Field[] = [
{
name: 'layout',
@@ -329,8 +368,9 @@ describe('sanitizeFields', () => {
label: 'Layout Blocks',
},
]
await expect(async () => {
await sanitizeFields({ config, fields, validRelationships })
await sanitizeFields({ config, collectionConfig, fields, validRelationships })
}).rejects.toThrow(InvalidFieldRelationship)
})
@@ -346,19 +386,23 @@ describe('sanitizeFields', () => {
const sanitizedField = (
await sanitizeFields({
config,
collectionConfig,
fields,
validRelationships: [],
})
)[0] as CheckboxField
expect(sanitizedField.defaultValue).toStrictEqual(false)
})
it('should return empty field array if no fields', async () => {
const sanitizedFields = await sanitizeFields({
config,
collectionConfig,
fields: [],
validRelationships: [],
})
expect(sanitizedFields).toStrictEqual([])
})
})
@@ -385,9 +429,11 @@ describe('sanitizeFields', () => {
label: false,
},
]
const sanitizedField = (
await sanitizeFields({
config,
collectionConfig,
fields,
validRelationships: [],
})
@@ -416,9 +462,11 @@ describe('sanitizeFields', () => {
label: false,
},
]
const sanitizedField = (
await sanitizeFields({
config,
collectionConfig,
fields,
validRelationships: [],
})

View File

@@ -17,6 +17,7 @@ import {
MissingEditorProp,
MissingFieldType,
} from '../../errors/index.js'
import { ReservedFieldName } from '../../errors/ReservedFieldName.js'
import { formatLabels, toWords } from '../../utilities/formatLabels.js'
import { baseBlockFields } from '../baseFields/baseBlockFields.js'
import { baseIDField } from '../baseFields/baseIDField.js'
@@ -24,14 +25,24 @@ import { baseTimezoneField } from '../baseFields/timezone/baseField.js'
import { defaultTimezones } from '../baseFields/timezone/defaultTimezones.js'
import { setDefaultBeforeDuplicate } from '../setDefaultBeforeDuplicate.js'
import { validations } from '../validations.js'
import {
reservedAPIKeyFieldNames,
reservedBaseAuthFieldNames,
reservedBaseUploadFieldNames,
reservedVerifyFieldNames,
} from './reservedFieldNames.js'
import { sanitizeJoinField } from './sanitizeJoinField.js'
import { fieldAffectsData, fieldIsLocalized, tabHasName } from './types.js'
import { fieldAffectsData as _fieldAffectsData, fieldIsLocalized, tabHasName } from './types.js'
type Args = {
collectionConfig?: CollectionConfig
config: Config
existingFieldNames?: Set<string>
fields: Field[]
/**
* Used to prevent unnecessary sanitization of fields that are not top-level.
*/
isTopLevelField?: boolean
joinPath?: string
/**
* When not passed in, assume that join are not supported (globals, arrays, blocks)
@@ -39,7 +50,6 @@ type Args = {
joins?: SanitizedJoins
parentIsLocalized: boolean
polymorphicJoins?: SanitizedJoin[]
/**
* If true, a richText field will require an editor property to be set, as the sanitizeFields function will not add it from the payload config if not present.
*
@@ -59,9 +69,11 @@ type Args = {
}
export const sanitizeFields = async ({
collectionConfig,
config,
existingFieldNames = new Set(),
fields,
isTopLevelField = true,
joinPath = '',
joins,
parentIsLocalized,
@@ -80,6 +92,7 @@ export const sanitizeFields = async ({
if ('_sanitized' in field && field._sanitized === true) {
continue
}
if ('_sanitized' in field) {
field._sanitized = true
}
@@ -88,8 +101,39 @@ export const sanitizeFields = async ({
throw new MissingFieldType(field)
}
const fieldAffectsData = _fieldAffectsData(field)
if (isTopLevelField && fieldAffectsData && field.name) {
if (collectionConfig && collectionConfig.upload) {
if (reservedBaseUploadFieldNames.includes(field.name)) {
throw new ReservedFieldName(field, field.name)
}
}
if (
collectionConfig &&
collectionConfig.auth &&
typeof collectionConfig.auth === 'object' &&
!collectionConfig.auth.disableLocalStrategy
) {
if (reservedBaseAuthFieldNames.includes(field.name)) {
throw new ReservedFieldName(field, field.name)
}
if (collectionConfig.auth.verify) {
if (reservedAPIKeyFieldNames.includes(field.name)) {
throw new ReservedFieldName(field, field.name)
}
if (reservedVerifyFieldNames.includes(field.name)) {
throw new ReservedFieldName(field, field.name)
}
}
}
}
// assert that field names do not contain forbidden characters
if (fieldAffectsData(field) && field.name.includes('.')) {
if (fieldAffectsData && field.name.includes('.')) {
throw new InvalidFieldName(field, field.name)
}
@@ -122,6 +166,7 @@ export const sanitizeFields = async ({
const relationships = Array.isArray(field.relationTo)
? field.relationTo
: [field.relationTo]
relationships.forEach((relationship: string) => {
if (!validRelationships.includes(relationship)) {
throw new InvalidFieldRelationship(field, relationship)
@@ -135,6 +180,7 @@ export const sanitizeFields = async ({
)
field.minRows = field.min
}
if (field.max && !field.maxRows) {
console.warn(
`(payload): The "max" property is deprecated for the Relationship field "${field.name}" and will be removed in a future version. Please use "maxRows" instead.`,
@@ -160,7 +206,7 @@ export const sanitizeFields = async ({
field.labels = field.labels || formatLabels(field.name)
}
if (fieldAffectsData(field)) {
if (fieldAffectsData) {
if (existingFieldNames.has(field.name)) {
throw new DuplicateFieldName(field.name)
} else if (!['blockName', 'id'].includes(field.name)) {
@@ -254,9 +300,11 @@ export const sanitizeFields = async ({
block.fields = block.fields.concat(baseBlockFields)
block.labels = !block.labels ? formatLabels(block.slug) : block.labels
block.fields = await sanitizeFields({
collectionConfig,
config,
existingFieldNames: new Set(),
fields: block.fields,
isTopLevelField: false,
parentIsLocalized: parentIsLocalized || field.localized,
requireFieldLevelRichTextEditor,
richTextSanitizationPromises,
@@ -267,12 +315,12 @@ export const sanitizeFields = async ({
if ('fields' in field && field.fields) {
field.fields = await sanitizeFields({
collectionConfig,
config,
existingFieldNames: fieldAffectsData(field) ? new Set() : existingFieldNames,
existingFieldNames: fieldAffectsData ? new Set() : existingFieldNames,
fields: field.fields,
joinPath: fieldAffectsData(field)
? `${joinPath ? joinPath + '.' : ''}${field.name}`
: joinPath,
isTopLevelField: isTopLevelField && !fieldAffectsData,
joinPath: fieldAffectsData ? `${joinPath ? joinPath + '.' : ''}${field.name}` : joinPath,
joins,
parentIsLocalized: parentIsLocalized || fieldIsLocalized(field),
polymorphicJoins,
@@ -285,7 +333,10 @@ export const sanitizeFields = async ({
if (field.type === 'tabs') {
for (let j = 0; j < field.tabs.length; j++) {
const tab = field.tabs[j]
if (tabHasName(tab) && typeof tab.label === 'undefined') {
const isNamedTab = tabHasName(tab)
if (isNamedTab && typeof tab.label === 'undefined') {
tab.label = toWords(tab.name)
}
@@ -296,21 +347,24 @@ export const sanitizeFields = async ({
!tab.id
) {
// Always attach a UUID to tabs with a condition so there's no conflicts even if there are duplicate nested names
tab.id = tabHasName(tab) ? `${tab.name}_${uuid()}` : uuid()
tab.id = isNamedTab ? `${tab.name}_${uuid()}` : uuid()
}
tab.fields = await sanitizeFields({
collectionConfig,
config,
existingFieldNames: tabHasName(tab) ? new Set() : existingFieldNames,
existingFieldNames: isNamedTab ? new Set() : existingFieldNames,
fields: tab.fields,
joinPath: tabHasName(tab) ? `${joinPath ? joinPath + '.' : ''}${tab.name}` : joinPath,
isTopLevelField: isTopLevelField && !isNamedTab,
joinPath: isNamedTab ? `${joinPath ? joinPath + '.' : ''}${tab.name}` : joinPath,
joins,
parentIsLocalized: parentIsLocalized || (tabHasName(tab) && tab.localized),
parentIsLocalized: parentIsLocalized || (isNamedTab && tab.localized),
polymorphicJoins,
requireFieldLevelRichTextEditor,
richTextSanitizationPromises,
validRelationships,
})
field.tabs[j] = tab
}
}

View File

@@ -404,7 +404,6 @@ export type LabelsClient = {
}
export type BaseValidateOptions<TData, TSiblingData, TValue> = {
/**
/**
* The data of the nearest parent block. If the field is not within a block, `blockData` will be equal to `undefined`.
*/
@@ -414,6 +413,10 @@ export type BaseValidateOptions<TData, TSiblingData, TValue> = {
event?: 'onChange' | 'submit'
id?: number | string
operation?: Operation
/**
* The `overrideAccess` flag that was attached to the request. This is used to bypass access control checks for fields.
*/
overrideAccess?: boolean
/**
* The path of the field, e.g. ["group", "myArray", 1, "textField"]. The path is the schemaPath but with indexes and would be used in the context of field data, not field schemas.
*/
@@ -716,7 +719,7 @@ export type DateFieldClient = {
} & FieldBaseClient &
Pick<DateField, 'timezone' | 'type'>
export type GroupField = {
export type GroupBase = {
admin?: {
components?: {
afterInput?: CustomComponent[]
@@ -726,6 +729,11 @@ export type GroupField = {
hideGutter?: boolean
} & Admin
fields: Field[]
type: 'group'
validate?: Validate<unknown, unknown, unknown, GroupField>
} & Omit<FieldBase, 'validate'>
export type NamedGroupField = {
/** Customize generated GraphQL and Typescript schema names.
* By default, it is bound to the collection.
*
@@ -733,15 +741,39 @@ export type GroupField = {
* **Note**: Top level types can collide, ensure they are unique amongst collections, arrays, groups, blocks, tabs.
*/
interfaceName?: string
type: 'group'
validate?: Validate<unknown, unknown, unknown, GroupField>
} & Omit<FieldBase, 'required' | 'validate'>
} & GroupBase
export type GroupFieldClient = {
admin?: AdminClient & Pick<GroupField['admin'], 'hideGutter'>
export type UnnamedGroupField = {
interfaceName?: never
/**
* Can be either:
* - A string, which will be used as the tab's label.
* - An object, where the key is the language code and the value is the label.
*/
label:
| {
[selectedLanguage: string]: string
}
| LabelFunction
| string
localized?: never
} & Omit<GroupBase, 'name' | 'virtual'>
export type GroupField = NamedGroupField | UnnamedGroupField
export type NamedGroupFieldClient = {
admin?: AdminClient & Pick<NamedGroupField['admin'], 'hideGutter'>
fields: ClientField[]
} & Omit<FieldBaseClient, 'required'> &
Pick<GroupField, 'interfaceName' | 'type'>
Pick<NamedGroupField, 'interfaceName' | 'type'>
export type UnnamedGroupFieldClient = {
admin?: AdminClient & Pick<UnnamedGroupField['admin'], 'hideGutter'>
fields: ClientField[]
} & Omit<FieldBaseClient, 'required'> &
Pick<UnnamedGroupField, 'label' | 'type'>
export type GroupFieldClient = NamedGroupFieldClient | UnnamedGroupFieldClient
export type RowField = {
admin?: Omit<Admin, 'description'>
@@ -1176,7 +1208,7 @@ export type PolymorphicRelationshipField = {
export type PolymorphicRelationshipFieldClient = {
admin?: {
sortOptions?: Pick<PolymorphicRelationshipField['admin'], 'sortOptions'>
sortOptions?: PolymorphicRelationshipField['admin']['sortOptions']
} & RelationshipAdminClient
} & Pick<PolymorphicRelationshipField, 'relationTo'> &
SharedRelationshipPropertiesClient
@@ -1608,6 +1640,7 @@ export type FlattenedBlocksField = {
export type FlattenedGroupField = {
flattenedFields: FlattenedField[]
name: string
} & GroupField
export type FlattenedArrayField = {
@@ -1725,9 +1758,9 @@ export type FieldAffectingData =
| CodeField
| DateField
| EmailField
| GroupField
| JoinField
| JSONField
| NamedGroupField
| NumberField
| PointField
| RadioField
@@ -1746,9 +1779,9 @@ export type FieldAffectingDataClient =
| CodeFieldClient
| DateFieldClient
| EmailFieldClient
| GroupFieldClient
| JoinFieldClient
| JSONFieldClient
| NamedGroupFieldClient
| NumberFieldClient
| PointFieldClient
| RadioFieldClient
@@ -1768,8 +1801,8 @@ export type NonPresentationalField =
| CollapsibleField
| DateField
| EmailField
| GroupField
| JSONField
| NamedGroupField
| NumberField
| PointField
| RadioField
@@ -1790,8 +1823,8 @@ export type NonPresentationalFieldClient =
| CollapsibleFieldClient
| DateFieldClient
| EmailFieldClient
| GroupFieldClient
| JSONFieldClient
| NamedGroupFieldClient
| NumberFieldClient
| PointFieldClient
| RadioFieldClient

View File

@@ -212,25 +212,47 @@ export const promise = async ({
}
case 'group': {
await traverseFields({
blockData,
collection,
context,
data,
doc,
fields: field.fields,
global,
operation,
parentIndexPath: '',
parentIsLocalized: parentIsLocalized || field.localized,
parentPath: path,
parentSchemaPath: schemaPath,
previousDoc,
previousSiblingDoc: previousDoc[field.name] as JsonObject,
req,
siblingData: (siblingData?.[field.name] as JsonObject) || {},
siblingDoc: siblingDoc[field.name] as JsonObject,
})
if (fieldAffectsData(field)) {
await traverseFields({
blockData,
collection,
context,
data,
doc,
fields: field.fields,
global,
operation,
parentIndexPath: '',
parentIsLocalized: parentIsLocalized || field.localized,
parentPath: path,
parentSchemaPath: schemaPath,
previousDoc,
previousSiblingDoc: previousDoc[field.name] as JsonObject,
req,
siblingData: (siblingData?.[field.name] as JsonObject) || {},
siblingDoc: siblingDoc[field.name] as JsonObject,
})
} else {
await traverseFields({
blockData,
collection,
context,
data,
doc,
fields: field.fields,
global,
operation,
parentIndexPath: indexPath,
parentIsLocalized,
parentPath,
parentSchemaPath: schemaPath,
previousDoc,
previousSiblingDoc: { ...previousSiblingDoc },
req,
siblingData: siblingData || {},
siblingDoc: { ...siblingDoc },
})
}
break
}

View File

@@ -186,7 +186,7 @@ export const promise = async ({
case 'group': {
// Fill groups with empty objects so fields with hooks within groups can populate
// themselves virtually as necessary
if (typeof siblingDoc[field.name] === 'undefined') {
if (fieldAffectsData(field) && typeof siblingDoc[field.name] === 'undefined') {
siblingDoc[field.name] = {}
}
@@ -307,7 +307,11 @@ export const promise = async ({
}
}
if ('virtual' in field && typeof field.virtual === 'string') {
if (
'virtual' in field &&
typeof field.virtual === 'string' &&
(!field.hidden || showHiddenFields)
) {
populationPromises.push(
virtualFieldPopulationPromise({
name: field.name,
@@ -609,45 +613,78 @@ export const promise = async ({
}
case 'group': {
let groupDoc = siblingDoc[field.name] as JsonObject
if (fieldAffectsData(field)) {
let groupDoc = siblingDoc[field.name] as JsonObject
if (typeof siblingDoc[field.name] !== 'object') {
groupDoc = {}
if (typeof siblingDoc[field.name] !== 'object') {
groupDoc = {}
}
const groupSelect = select?.[field.name]
traverseFields({
blockData,
collection,
context,
currentDepth,
depth,
doc,
draft,
fallbackLocale,
fieldPromises,
fields: field.fields,
findMany,
flattenLocales,
global,
locale,
overrideAccess,
parentIndexPath: '',
parentIsLocalized: parentIsLocalized || field.localized,
parentPath: path,
parentSchemaPath: schemaPath,
populate,
populationPromises,
req,
select: typeof groupSelect === 'object' ? groupSelect : undefined,
selectMode,
showHiddenFields,
siblingDoc: groupDoc,
triggerAccessControl,
triggerHooks,
})
} else {
traverseFields({
blockData,
collection,
context,
currentDepth,
depth,
doc,
draft,
fallbackLocale,
fieldPromises,
fields: field.fields,
findMany,
flattenLocales,
global,
locale,
overrideAccess,
parentIndexPath: indexPath,
parentIsLocalized,
parentPath,
parentSchemaPath: schemaPath,
populate,
populationPromises,
req,
select,
selectMode,
showHiddenFields,
siblingDoc,
triggerAccessControl,
triggerHooks,
})
}
const groupSelect = select?.[field.name]
traverseFields({
blockData,
collection,
context,
currentDepth,
depth,
doc,
draft,
fallbackLocale,
fieldPromises,
fields: field.fields,
findMany,
flattenLocales,
global,
locale,
overrideAccess,
parentIndexPath: '',
parentIsLocalized: parentIsLocalized || field.localized,
parentPath: path,
parentSchemaPath: schemaPath,
populate,
populationPromises,
req,
select: typeof groupSelect === 'object' ? groupSelect : undefined,
selectMode,
showHiddenFields,
siblingDoc: groupDoc,
triggerAccessControl,
triggerHooks,
})
break
}

View File

@@ -8,6 +8,7 @@ import type { JsonObject, Operation, PayloadRequest } from '../../../types/index
import { ValidationError } from '../../../errors/index.js'
import { deepCopyObjectSimple } from '../../../utilities/deepCopyObject.js'
import { traverseFields } from './traverseFields.js'
export type Args<T extends JsonObject> = {
collection: null | SanitizedCollectionConfig
context: RequestContext
@@ -17,6 +18,7 @@ export type Args<T extends JsonObject> = {
global: null | SanitizedGlobalConfig
id?: number | string
operation: Operation
overrideAccess?: boolean
req: PayloadRequest
skipValidation?: boolean
}
@@ -39,6 +41,7 @@ export const beforeChange = async <T extends JsonObject>({
docWithLocales,
global,
operation,
overrideAccess,
req,
skipValidation,
}: Args<T>): Promise<T> => {
@@ -59,6 +62,7 @@ export const beforeChange = async <T extends JsonObject>({
global,
mergeLocaleActions,
operation,
overrideAccess,
parentIndexPath: '',
parentIsLocalized: false,
parentPath: '',

View File

@@ -45,6 +45,7 @@ type Args = {
id?: number | string
mergeLocaleActions: (() => Promise<void> | void)[]
operation: Operation
overrideAccess: boolean
parentIndexPath: string
parentIsLocalized: boolean
parentPath: string
@@ -80,6 +81,7 @@ export const promise = async ({
global,
mergeLocaleActions,
operation,
overrideAccess,
parentIndexPath,
parentIsLocalized,
parentPath,
@@ -176,6 +178,7 @@ export const promise = async ({
object,
object
>
const validationResult = await validateFn(valueToValidate as never, {
...field,
id,
@@ -186,6 +189,7 @@ export const promise = async ({
// @ts-expect-error
jsonError,
operation,
overrideAccess,
path: pathSegments,
preferences: { fields: {} },
previousValue: siblingDoc[field.name],
@@ -261,6 +265,7 @@ export const promise = async ({
global,
mergeLocaleActions,
operation,
overrideAccess,
parentIndexPath: '',
parentIsLocalized: parentIsLocalized || field.localized,
parentPath: path + '.' + rowIndex,
@@ -326,6 +331,7 @@ export const promise = async ({
global,
mergeLocaleActions,
operation,
overrideAccess,
parentIndexPath: '',
parentIsLocalized: parentIsLocalized || field.localized,
parentPath: path + '.' + rowIndex,
@@ -368,6 +374,7 @@ export const promise = async ({
global,
mergeLocaleActions,
operation,
overrideAccess,
parentIndexPath: indexPath,
parentIsLocalized,
parentPath,
@@ -383,17 +390,42 @@ export const promise = async ({
}
case 'group': {
if (typeof siblingData[field.name] !== 'object') {
siblingData[field.name] = {}
let groupSiblingData = siblingData
let groupSiblingDoc = siblingDoc
let groupSiblingDocWithLocales = siblingDocWithLocales
const isNamedGroup = fieldAffectsData(field)
if (isNamedGroup) {
if (typeof siblingData[field.name] !== 'object') {
siblingData[field.name] = {}
}
if (typeof siblingDoc[field.name] !== 'object') {
siblingDoc[field.name] = {}
}
if (typeof siblingDocWithLocales[field.name] !== 'object') {
siblingDocWithLocales[field.name] = {}
}
if (typeof siblingData[field.name] !== 'object') {
siblingData[field.name] = {}
}
if (typeof siblingDoc[field.name] !== 'object') {
siblingDoc[field.name] = {}
}
if (typeof siblingDocWithLocales[field.name] !== 'object') {
siblingDocWithLocales[field.name] = {}
}
groupSiblingData = siblingData[field.name] as JsonObject
groupSiblingDoc = siblingDoc[field.name] as JsonObject
groupSiblingDocWithLocales = siblingDocWithLocales[field.name] as JsonObject
}
if (typeof siblingDoc[field.name] !== 'object') {
siblingDoc[field.name] = {}
}
if (typeof siblingDocWithLocales[field.name] !== 'object') {
siblingDocWithLocales[field.name] = {}
}
const fallbackLabel = field?.label || (isNamedGroup ? field.name : field?.type)
await traverseFields({
id,
@@ -407,22 +439,20 @@ export const promise = async ({
fieldLabelPath:
field?.label === false
? fieldLabelPath
: buildFieldLabel(
fieldLabelPath,
getTranslatedLabel(field?.label || field?.name, req.i18n),
),
: buildFieldLabel(fieldLabelPath, getTranslatedLabel(fallbackLabel, req.i18n)),
fields: field.fields,
global,
mergeLocaleActions,
operation,
parentIndexPath: '',
overrideAccess,
parentIndexPath: isNamedGroup ? '' : indexPath,
parentIsLocalized: parentIsLocalized || field.localized,
parentPath: path,
parentPath: isNamedGroup ? path : parentPath,
parentSchemaPath: schemaPath,
req,
siblingData: siblingData[field.name] as JsonObject,
siblingDoc: siblingDoc[field.name] as JsonObject,
siblingDocWithLocales: siblingDocWithLocales[field.name] as JsonObject,
siblingData: groupSiblingData,
siblingDoc: groupSiblingDoc,
siblingDocWithLocales: groupSiblingDocWithLocales,
skipValidation: skipValidationFromHere,
})
@@ -480,6 +510,7 @@ export const promise = async ({
mergeLocaleActions,
operation,
originalDoc: doc,
overrideAccess,
parentIsLocalized,
path: pathSegments,
previousSiblingDoc: siblingDoc,
@@ -546,6 +577,7 @@ export const promise = async ({
global,
mergeLocaleActions,
operation,
overrideAccess,
parentIndexPath: isNamedTab ? '' : indexPath,
parentIsLocalized: parentIsLocalized || field.localized,
parentPath: isNamedTab ? path : parentPath,
@@ -578,6 +610,7 @@ export const promise = async ({
global,
mergeLocaleActions,
operation,
overrideAccess,
parentIndexPath: indexPath,
parentIsLocalized,
parentPath: path,

View File

@@ -36,6 +36,7 @@ type Args = {
id?: number | string
mergeLocaleActions: (() => Promise<void> | void)[]
operation: Operation
overrideAccess: boolean
parentIndexPath: string
/**
* @todo make required in v4.0
@@ -78,6 +79,7 @@ export const traverseFields = async ({
global,
mergeLocaleActions,
operation,
overrideAccess,
parentIndexPath,
parentIsLocalized,
parentPath,
@@ -107,6 +109,7 @@ export const traverseFields = async ({
global,
mergeLocaleActions,
operation,
overrideAccess,
parentIndexPath,
parentIsLocalized,
parentPath,

View File

@@ -375,9 +375,10 @@ export const promise = async <T>({
}
}
} else {
// Finally, we traverse fields which do not affect data here
// Finally, we traverse fields which do not affect data here - collapsibles, rows, unnamed groups
switch (field.type) {
case 'collapsible':
case 'group':
case 'row': {
await traverseFields({
id,

View File

@@ -447,16 +447,23 @@ export const promise = async <T>({
}
case 'group': {
if (typeof siblingData[field.name] !== 'object') {
siblingData[field.name] = {}
}
let groupSiblingData = siblingData
let groupSiblingDoc = siblingDoc
if (typeof siblingDoc[field.name] !== 'object') {
siblingDoc[field.name] = {}
}
const isNamedGroup = fieldAffectsData(field)
const groupData = siblingData[field.name] as Record<string, unknown>
const groupDoc = siblingDoc[field.name] as Record<string, unknown>
if (isNamedGroup) {
if (typeof siblingData[field.name] !== 'object') {
siblingData[field.name] = {}
}
if (typeof siblingDoc[field.name] !== 'object') {
siblingDoc[field.name] = {}
}
groupSiblingData = siblingData[field.name] as Record<string, unknown>
groupSiblingDoc = siblingDoc[field.name] as Record<string, unknown>
}
await traverseFields({
id,
@@ -469,13 +476,13 @@ export const promise = async <T>({
global,
operation,
overrideAccess,
parentIndexPath: '',
parentIndexPath: isNamedGroup ? '' : indexPath,
parentIsLocalized: parentIsLocalized || field.localized,
parentPath: path,
parentPath: isNamedGroup ? path : parentPath,
parentSchemaPath: schemaPath,
req,
siblingData: groupData as JsonObject,
siblingDoc: groupDoc as JsonObject,
siblingData: groupSiblingData,
siblingDoc: groupSiblingDoc,
})
break

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