Compare commits

..

186 Commits

Author SHA1 Message Date
Patrik Kozak
58f841cee4 Merge branch 'feat/folders' of github.com:payloadcms/payload into fix/findValueInPath 2025-05-22 08:04:45 -04:00
Jarrod Flesch
f0a157f939 fix failing join test 2025-05-21 22:43:29 -04:00
Jarrod Flesch
19d6b1aac8 lint fix 2025-05-21 22:25:54 -04:00
Patrik Kozak
9b382b6adf fix: prevent top-level fields from shadowing nested group fields in list view 2025-05-21 22:17:40 -04:00
Jarrod Flesch
bf5d31960b fix column docs 2025-05-21 21:47:09 -04:00
Jarrod Flesch
3ad29d251f Merge branch 'main' into feat/folders 2025-05-21 21:27:25 -04:00
Germán Jabloñski
fc83823e5d feat(richtext-lexical): add TextStateFeature (allows applying styles such as color and background color to text) (#9667)
Originally this PR was going to introduce a `TextColorFeature`, but it
ended up becoming a more general-purpose `TextStateFeature`.

## Example of use:
```ts
import { defaultColors, TextStateFeature } from '@payloadcms/richtext-lexical'

TextStateFeature({
  // prettier-ignore
  state: {
    color: {
      ...defaultColors,
      // fancy gradients!
      galaxy: { label: 'Galaxy', css: { background: 'linear-gradient(to right, #0000ff, #ff0000)', color: 'white' } },
      sunset: { label: 'Sunset', css: { background: 'linear-gradient(to top, #ff5f6d, #6a3093)' } },
    },
    // You can have both colored and underlined text at the same time. 
    // If you don't want that, you should group them within the same key.
    // (just like I did with defaultColors and my fancy gradients)
    underline: {
      'solid': { label: 'Solid', css: { 'text-decoration': 'underline', 'text-underline-offset': '4px' } },
       // You'll probably want to use the CSS light-dark() utility.
      'yellow-dashed': { label: 'Yellow Dashed', css: { 'text-decoration': 'underline dashed', 'text-decoration-color': 'light-dark(#EAB308,yellow)', 'text-underline-offset': '4px' } },
    },
  },
}),

```

Which will result in the following:


![image](https://github.com/user-attachments/assets/ed29b30b-8efd-4265-a1b9-125c97ac5fce)


## Challenges & Considerations
Adding colors or styles in general to the Lexical editor is not as
simple as it seems.

1. **Extending TextNode isn't ideal**
- While possible, it's verbose, error-prone, and not composable. If
multiple features extend the same node, conflicts arise.
- That’s why we collaborated with the Lexical team to introduce [the new
State API](https://lexical.dev/docs/concepts/node-replacement)
([PR](https://github.com/facebook/lexical/pull/7117)).
2. **Issues with patchStyles**
- Some community plugins use `patchStyles`, but storing CSS in the
editor’s JSON has drawbacks:
- Style adaptability: Users may want different styles per scenario
(dark/light mode, mobile/web, etc.).
- Migration challenges: Hardcoded colors (e.g., #FF0000) make updates
difficult. Using tokens (e.g., "red") allows flexibility.
      - Larger JSON footprint increases DB size.
3. **Managing overlapping styles**
- Some users may want both text and background colors on the same node,
while others may prefer mutual exclusivity.
    - This approach allows either:
        - Using a single "color" state (e.g., "bg-red" + "text-red").
- Defining separate "bg-color" and "text-color" states for independent
styling.
4. **Good light and dark modes by default**
- Many major editors (Google Docs, OneNote, Word) treat dark mode as an
afterthought, leading to poor UX.
- We provide a well-balanced default palette that looks great in both
themes, serving as a strong foundation for customization.
5. **Feature name. Why TextState?**
- Other names considered were `TextFormatFeature` and
`TextStylesFeature`. The term `format` in Lexical and Payload is already
used to refer to something else (italic, bold, etc.). The term `style`
could be misleading since it is never attached to the editorState.
    - State seems appropriate because:
      - Lexical's new state API is used under the hood.
- Perhaps in the future we'll want to make state features for other
nodes, such as `ElementStateFeature` or `RootStateFeature`.

Note: There's a bug in Lexical's `forEachSelectedTextNode`. When the
selection includes a textNode partially on the left, all state for that
node is removed instead of splitting it along the selection edge.
2025-05-21 23:58:17 +00:00
Anders Semb Hermansen
2a41d3fbb1 feat: show fields inside groups as separate columns in the list view (#7355)
## Description

Group fields are shown as one column, this PR changes this so that the
individual field is now shown separately.

Before change:
<img width="1227" alt="before change"
src="https://github.com/user-attachments/assets/dfae58fd-8ad2-4329-84fd-ed1d4eb20854">

After change:
<img width="1229" alt="after change"
src="https://github.com/user-attachments/assets/d4fd78bb-c474-436e-a0f5-cac4638b91a4">

- [X] I have read and understand the
[CONTRIBUTING.md](https://github.com/payloadcms/payload/blob/main/CONTRIBUTING.md)
document in this repository.

## Type of change

- [X] New feature (non-breaking change which adds functionality)

## Checklist:

- [X] I have added tests that prove my fix is effective or that my
feature works
- [X] Existing test suite passes locally with my changes
- [ ] I have made corresponding changes to the documentation

---------

Co-authored-by: Patrik Kozak <35232443+PatrikKozak@users.noreply.github.com>
2025-05-21 16:25:34 -04:00
Jarrod Flesch
07e9444c09 fix build 2025-05-21 14:45:29 -04:00
Patrik
c772a3207c fix(ui): set gap to 0 in sort column buttons to remove unneeded spacing (#12481)
### What

This PR adjusts the `gap` between buttons in the `SortColumn` component.
The previous spacing (`calc(var(--base) / 4)`) caused too much visual
separation between the sort buttons. It has been replaced with `gap: 0`
to tighten their alignment.

#### Before:
![Screenshot 2025-05-21 at 1 33
17 PM](https://github.com/user-attachments/assets/a5f759fc-647a-46e3-8dac-e3e100fc7b98)

#### After:
![Screenshot 2025-05-21 at 1 34
04 PM](https://github.com/user-attachments/assets/29572620-bd62-4e3e-80b7-d32ed4c81911)
2025-05-21 11:33:14 -07:00
Germán Jabloñski
c701dd41a9 docs: update rich text to HTML conversion documentation (#12465)
Fixes #8168, #8277

The fact that `lexicalHTMLField` doesn't work with live preview was
already clarified at the beginning of the page. I mentioned it again in
the dedicated section because it seems there was still confusion.

Also, I reordered and hierarchized the headings correctly. The
introduction said there were two ways to convert to HTML, but there were
four headings with the same level. I also made the headings a little
shorter to make the table of contents easier to parse.
2025-05-21 15:24:31 -03:00
Jarrod Flesch
5dd13f2873 Merge branch 'feat/folders' into test/folders/e2e 2025-05-21 13:44:25 -04:00
Jarrod Flesch
878be913fd adjust test selectors 2025-05-21 13:43:28 -04:00
Paul
4dfb2d24bb feat(plugin-form-builder): add new date field (#12416)
Adds a new date field to take submission values for.

It can help form serialisers render the right input for this kind of
field as the submissions themselves don't do any validation right now.

Disabled by default as to not cause any conflicts with existing projects
potentially inserting their own date blocks.

Can be enabled like this

```ts
formBuilderPlugin({
   fields: {
     date: true
   }
})
```
2025-05-21 17:34:21 +00:00
Sasha
230128b92e fix(db-mongodb): remove limit from nested querying (#12464)
Fixes https://github.com/payloadcms/payload/issues/12456
2025-05-21 20:22:28 +03:00
Dan Ribbens
23f42040ab chore: ignore .idea run configuration templates (#12439)
Webstorm run configuration template files are trying to sneak into my
commits.
2025-05-21 13:13:55 -04:00
Alessio Gravili
8596ac5694 fix(richtext-lexical): support inline block types in strict mode for JSXConvertersFunction type (#12478)
Same as https://github.com/payloadcms/payload/pull/10398 but for inline
blocks.

> Reproduction steps:
> 1. Set `strict: true` in `templates/website/tsconfig.json`
> 2. You will find a ts error in
`templates/website/src/components/RichText/index.tsx`.
> 
> This is because the blockType property of blocks is generated by
Payload as a literal (e.g. "mediaBlock") and cannot be assigned to a
string.
> 
> To test this PR, you can make the change to `JSXConvertersFunction` in
node_modules of the website template
2025-05-21 16:54:03 +00:00
Jarrod Flesch
0524f198a6 require onCreateSuccess fn in ListCreateNewDocInFolderButton 2025-05-21 12:47:53 -04:00
Jarrod Flesch
5f2e846350 fixes cell not updating fromFolderID and missing onCreateSuccess fn 2025-05-21 12:47:00 -04:00
Jarrod Flesch
d3265b9931 add docs 2025-05-21 12:35:52 -04:00
Jessica Chowdhury
33261b36bf test: add e2e tests folder folder view - WIP 2025-05-21 17:28:01 +01:00
Jessica Chowdhury
73d4201df8 fix(ui): add prefix to folder drawer ids 2025-05-21 17:23:55 +01:00
Keisuke Ikeda
324daff553 docs: fix API capitalization typo in virtual fields documentation (#12477) 2025-05-21 15:56:58 +00:00
Jarrod Flesch
50d3da5824 remove enabled property from folders root config 2025-05-21 11:23:51 -04:00
Jarrod Flesch
41aac41df4 refactor how to enable a folder collection 2025-05-21 10:55:26 -04:00
Jarrod Flesch
6a5b95af7c fixes folderID with postgres 2025-05-21 09:12:49 -04:00
Jacob Fletcher
22b1858ee8 fix: auto inject req.user into query preset constraints (#12461)
In #12322 we prevented against accidental query preset lockout by
throwing a validation error when the user is going to change the preset
in a way that removes their own access to it. This, however, puts the
responsibility on the user to make the corrections and is an unnecessary
step.

For example, the API currently forbids leaving yourself out of the
`users` array when specifying the `specificUsers` constraint, but when
you encounter this error, have to update the field manually and try
again.

To improve the experience, we now automatically inject the requesting
user onto the `users` array when this constraint is selected. This will
guarantee they have access and prevent an accidental lockout while also
avoiding the API error feedback loop.
2025-05-20 17:15:18 -04:00
Jarrod Flesch
2e7bfcbd63 card grid sizing pt 3 2025-05-20 15:23:56 -04:00
Jarrod Flesch
3ee9a32a38 adjust card grid sizes 2025-05-20 15:22:48 -04:00
Jarrod Flesch
c2d38b4109 adjusts card width at larger screens 2025-05-20 15:20:52 -04:00
Jarrod Flesch
5d9c537145 update int tests 2025-05-20 15:08:53 -04:00
Jarrod Flesch
904b6a6dbe fix build 2025-05-20 14:14:52 -04:00
Jarrod Flesch
cc6de7ef42 rm console log 2025-05-20 14:10:40 -04:00
Jarrod Flesch
a3ef4fbfac fix filter bug 2025-05-20 14:10:08 -04:00
Jarrod Flesch
e9ff611879 more folder file name alignment 2025-05-20 14:09:43 -04:00
Jarrod Flesch
5825d0cfc7 align folder names with feature 2025-05-20 13:38:33 -04:00
Jarrod Flesch
103b476c82 Merge branch 'main' into feat/folders 2025-05-20 13:35:23 -04:00
Jarrod Flesch
a3279b319e update route structures to respect config routes 2025-05-20 13:29:58 -04:00
Jarrod Flesch
bbb0ab784c fix auotsave enabled collections 2025-05-20 13:04:17 -04:00
Jarrod Flesch
32eac5b0c2 rename test folder folder-view to folders 2025-05-20 12:28:14 -04:00
Jarrod Flesch
86098c9140 bug fixes 2025-05-20 10:55:45 -04:00
conico974
2ab8e2e194 fix: telemetry in opennext cloudflare (#12327)
<!--

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?
This PR help to fix an issue you'll encounter while running payload in
OpenNext on cloudflare

### Why?
Sending telemetry event will create an infinite loop because it won't be
able to find a `package.json`

### How?
Putting the whole logic of `sendEvent` behind `config.telemetry` allows
to disable it and thus, make it work on cloudflare

See this comment for more info :
https://github.com/opennextjs/opennextjs-cloudflare/issues/263#issuecomment-2851747956
2025-05-20 07:49:23 -07:00
Patrik
1235a183ff fix: prevent resizing of original file with withoutEnlargement on update (#12291)
This PR updates `generateFileData` to skip applying `resizeOptions`
after updating an image if `resizeOptions.withoutEnlargement` is `true`
and the original image size is smaller than the dimensions defined in
`resizeOptions`.

This prevents unintended re-resizing of already resized images when
updating or modifying metadata without uploading a new file.

This change ensures that:

- Resizing is skipped if withoutEnlargement: true

- Resizing still occurs if withoutEnlargement: false or unset

This resolves an issue where images were being resized again
unnecessarily when updating an upload.

Fixes #12280
2025-05-20 06:43:53 -07:00
Sasha
81d333f4b0 test: add test for sorting by a virtual field with a reference (#12351) 2025-05-20 13:07:48 +00:00
Sasha
4fe3423e54 fix(plugin-multi-tenant): multi-locale tenant select label (#12444)
fixes https://github.com/payloadcms/payload/issues/12443
2025-05-20 05:02:47 -07:00
Jarrod Flesch
7fd2cdf04c allow for folders name to be configurable 2025-05-19 21:00:44 -04:00
Paul
e8c2b15e2b fix(plugin-multi-tenant): add missing translation for Assigned Tenant field (#12448)
Previously the "Assigned Tenant" field didn't have a translated label
2025-05-19 13:20:12 -07:00
Paul
3127d6ad6d fix(plugin-import-export): add translations for all UI elements and fields (#12449)
Converts all text and field labels into variables that can be
translated. Also generated the translations for them

So now the UI here is internationalised


![image](https://github.com/user-attachments/assets/40d7c010-ac58-4cd7-8786-01b3de3cabb7)

I've also moved some of the generic labels into the core package since
those could be re-used elsewhere
2025-05-19 13:19:55 -07:00
Paul
72ab319d37 fix(db-*): ensure consistent sorting even when sorting on non-unique fields or no sort parameters at all (#12447)
The databases do not keep track of document order internally so when
sorting by non-unique fields such as shared `order` number values, the
returned order will be random and not consistent.

While this issue is far more noticeable on mongo it could also occur in
postgres on certain environments.

This combined with pagination can lead to the perception of duplicated
or inconsistent data.

This PR adds a second sort parameter to queries so that we always have a
fallback, `-createdAt` will be used by default or `id` if timestamps are
disabled.
2025-05-19 12:59:12 -07:00
Jarrod Flesch
8d507996c8 Merge branch 'main' into feat/folders 2025-05-19 13:24:19 -04:00
Jarrod Flesch
aa82763cb8 fix list menu items test 2025-05-19 13:20:17 -04:00
Jarrod Flesch
e7e6a7dd97 remove multiple listItem toggle clicks in preset query tests 2025-05-19 12:16:21 -04:00
Germán Jabloñski
2a929cf385 chore: fix all lint errors and add mechanisms to prevent them from appearing again (#12401)
I think it's easier to review this PR commit by commit, so I'll explain
it this way:

## Commits
1. [parallelize eslint script (still showing logs results in
serial)](c9ac49c12d):
Previously, `--concurrency 1` was added to the script to make the logs
more readable. However, turborepo has an option specifically for these
use cases: `--log-order=grouped` runs the tasks in parallel but outputs
them serially. As a result, the lint script is now significantly faster.
2. [run pnpm
lint:fix](9c128c276a)
The auto-fix was run, which resolved some eslint errors that were
slipped in due to the use of `no-verify`. Most of these were
`perfectionist` fixes (property ordering) and the removal of unnecessary
assertions. Starting with this PR, this won't happen again in the
future, as we'll be verifying the linter in every PR across the entire
codebase (see commit 7).
3. [fix eslint non-autofixable
errors](700f412a33)
All manual errors have been resolved except for the configuration errors
addressed in commit 5. Most were React compiler violations, which have
been disabled and commented out "TODO" for now. There's also an unused
`use no memo` and a couple of `require` errors.
4. [move react-compiler linter to eslint-config
package](4f7cb4d63a)
To simplify the eslint configuration. My concern was that there would be
a performance regression when used in non-react related packages, but
none was experienced. This is probably because it only runs on .tsx
files.
5. [remove redundant eslint config files and fix
allowDefaultProject](a94347995a)
The main feature introduced by `typescript-eslint` v8 was
`projectService`, which automatically searches each file for the closest
`tsconfig`, greatly simplifying configuration in monorepos
([source](https://typescript-eslint.io/blog/announcing-typescript-eslint-v8#project-service)).
Once I moved `projectService` to `packages/eslint-config`, all the other
configuration files could be easily removed.
I confirmed that pnpm lint still works on individual packages.
The other important change was that the pending eslint errors from
commits 2 and 3 were resolved. That is, some files were giving the
error: "[File] was not found by the project service. Consider either
including it in the tsconfig.json or including it in
allowDefaultProject." Below I copy the explanatory comment I left in the
code:
```ts
// This is necessary because `tsconfig.base.json` defines `"rootDir": "${configDir}/src"`,
// And the following files aren't in src because they aren't transpiled.
// This is typescript-eslint's way of adding files that aren't included in tsconfig.
// See: https://typescript-eslint.io/troubleshooting/typed-linting/#i-get-errors-telling-me--was-not-found-by-the-project-service-consider-either-including-it-in-the-tsconfigjson-or-including-it-in-allowdefaultproject
// The best practice is to have a tsconfig.json that covers ALL files and is used for
// typechecking (with noEmit), and a `tsconfig.build.json` that is used for the build
// (or alternatively, swc, tsup or tsdown). That's what we should ideally do, in which case
// this hardcoded list wouldn't be necessary. Note that these files don't currently go
// through ts, only through eslint.
```

6. [Differentiate errors from warnings in VScode ESLint
Rules](5914d2f48d)
There's no reason to do that. If an eslint rule isn't an error, it
should be disabled or converted to a warning.
7. [Disable skip lint, and lint over the entire repo now that it's
faster](e4b28f1360)
The GitHub action linted only the files that had changed in the PR.
While this seems like a good idea, once exceptions were introduced with
[skip lint], they opened the door to propagating more and more errors.
Often, the linter was skipped, not because someone introduced new
errors, but because they were trying to avoid those that had already
crept in, sometimes accidentally introducing new ones.
On the other hand, `pnpm lint` now runs in parallel (commit 1), so it's
not that slow. Additionally, it runs in parallel with other GitHub
actions like e2e tests, which take much longer, so it can't represent a
bottleneck in CI.
8. [fix lint in next
package](4506595f91)
Small fix missing from commit 5
9. [Merge remote-tracking branch 'origin/main' into
fix-eslint](563d4909c1)
10. [add again eslint.config.js in payload
package](78f6ffcae7)
The comment in the code explains it. Basically, after the merge from
main, the payload package runs out of memory when linting, probably
because it grew in recent PRs. That package will sooner or later
collapse for our tooling, so we may have to split it. It's already too
big.

## Future Actions
- Resolve React compiler violations, as mentioned in commit 3.
- Decouple the `tsconfig` used for typechecking and build across the
entire monorepo (as explained in point 5) to ensure ts coverage even for
files that aren't transpiled (such as scripts).
- Remove the few remaining `eslint.config.js`. I had to leave the
`richtext-lexical` and `next` ones for now. They could be moved to the
root config and scoped to their packages, as we do for example with
`templates/vercel-postgres/**`. However, I couldn't get it to work, I
don't know why.
- Make eslint in the test folder usable. Not only are we not linting
`test` in CI, but now the `pnpm eslint .` command is so large that my
computer freezes. If each suite were its own package, this would be
solved, and dynamic codegen + git hooks to modify tsconfig.base.json
wouldn't be necessary
([related](https://github.com/payloadcms/payload/pull/11984)).
2025-05-19 12:36:40 -03:00
Jarrod Flesch
e14c670a51 query preset passing tests 2025-05-19 11:01:09 -04:00
Jarrod Flesch
21f5d3473c fixes versions count test selectors 2025-05-19 09:14:32 -04:00
Sasha
38029cdd6e chore(drizzle): fix lint errors in @payloadcms/drizzle (#12428) 2025-05-19 08:14:20 +00:00
Femi Oladipo
14252696ce fix: incorrect environment file loading (#12360)
### What?

Fixes issue with the Payload CLI where environment files were silently
always loaded as if in development mode, even when `NODE_ENV=production`
is explicitly set. Achieved by dynamically checking the enviroment based
on `process.env.NODE_ENV` (defaulting to "development") then passing
that to the underlying library `@next/env`.

### Why?

Previously, the Payload CLI always passed `true` to the `dev` flag of
`loadEnvConfig` from `@next/env`, causing it to load
development-specific `.env` files even when `NODE_ENV=production` was
explicitly set. Frustratingly for the user there was also no warning
message that this was happening.

For example, previously when running:
```sh
NODE_ENV=production pnpm payload run ./seed.ts
```
It would still load `.env.development*` and not `.env.production*`.

The inability to override the dev flag previously made it difficult, bar
impossible (depending on ones setup), to run the CLI in a
production-like environment. Which is useful for several reasons, a few
examples being:
- Seeding production data:
```
NODE_ENV=production payload run seed.ts
```
- Capturing current schema:
```
NODE_ENV=production payload migrate:create
```
- Running one-off jobs with live data:
```
NODE_ENV=production payload run jobs/consolidate-payments.ts
```

This fix allows users to correctly target production without surprises.

### How?

- Introduced a dev constant that checks `NODE_ENV !== 'production'`
- Passed dev to `loadEnvConfig` to allow `@next/env` to resolve the
correct `.env.*` files based on the environment:

**Before:**
```ts
const { loadedEnvFiles } = loadEnvConfig(process.cwd(), true) // assuming this won't run in production
```
**After:**
```ts
const dev = process.env.NODE_ENV !== 'production'
const { loadedEnvFiles } = loadEnvConfig(process.cwd(), dev)
```

The signature of `loadEnvConfig` from
[packages/next-env/index.ts](2086975c3c/packages/next-env/index.ts (L114))
is:
```ts
export function loadEnvConfig(
  dir: string,
  dev?: boolean,
  log: Log = console,
  forceReload = false,
  onReload?: (envFilePath: string) => void
): {
  combinedEnv: Env
  parsedEnv: Env | undefined
  loadedEnvFiles: LoadedEnvFiles
} 
```

Logic from `loadEnvConfig` in
[packages/next-env/index.ts](2086975c3c/packages/next-env/index.ts (L136))
that handles loading dependant on the `dev` variable:
```ts
const mode = isTest ? 'test' : dev ? 'development' : 'production'
const dotenvFiles = [
  `.env.${mode}.local`,
  mode !== 'test' && `.env.local`,
  `.env.${mode}`,
  '.env',
]
```

This change allows Payload CLI commands to honor the current `NODE_ENV`,
loading `.env.production*`, as intended when running with
`NODE_ENV=production`.

No behavioral changes for existing dev users, but adds expected support
for production workflows.
---
Note:
There are a few consideration I made here.
1. Default to development if not explicitly set (I think most would
agree).
2. I haven't implemented warning for non standard `NODE_ENV` values, as
the `Next.js` cli does. For example `NODE_ENV=alpha`.
3. Extension of point 2, I haven't implemented loading of non-standard
`NODE_ENV` values.

I do believe either point 2 or 3 should be implemented however. So users
are not left surprised by the actions of the CLI.

FYI, the `Next.js` cli does the warning in the main package and not
`@next/env`. This logic exists in
[packages/next/src/bin/next.ts](2086975c3c/packages/next/src/bin/next.ts (L64)):

```ts
const standardEnv = ['production', 'development', 'test']

if (process.env.NODE_ENV) {
  const isNotStandard = !standardEnv.includes(process.env.NODE_ENV)
  const shouldWarnCommands =
    process.env.NODE_ENV === 'development'
      ? ['start', 'build']
      : process.env.NODE_ENV === 'production'
        ? ['dev']
        : []

  if (isNotStandard || shouldWarnCommands.includes(commandName)) {
    warn(NON_STANDARD_NODE_ENV)
  }
}
```
This warns when using the wrong `NODE_ENV` for a command (I don't think
that applies here?). Also warning when a non-standard `NODE_ENV` is used
(e.g., `NODE_ENV=alpha`) and will not attempt to load `.env.alpha*`.
This makes unexpected behaviour non-silent.

If desired, I can add the warning to this PR or follow up with a
separate PR. However, loading non-standard `NODE_ENV` is a bigger
discussion and implementation. That I would prefer to leave out of this
PR so not to block it moving along. But I felt it was worth mentioning.
2025-05-18 23:23:41 +00:00
Jacob Fletcher
5855f3a475 fix: sanitize duplicate blocks (#12440) 2025-05-17 09:20:28 -04:00
Germán Jabloñski
529bfe149e fix: orderable with groups and tabs requires migration (#12422)
⚠️ `orderable` fields will no longer be `required` and `unique`, so your
database may prompt you to accept an automatic migration if you're using
[this
feature](https://payloadcms.com/docs/configuration/collections#config-options).
Note that the `orderable` feature is still experimental, so it may still
receive breaking changes without a major upgrade or contain bugs. Use it
with caution.
___

The `orderable` fields will not have `required` and `unique` constraints
at the database schema level, in order to automatically migrate
collections that incorporate this property.

Now, when a user adds the `orderable` property to a collection or join
field, existing documents will have the order field set to undefined.
The first time you try to reorder them, the documents will be
automatically assigned an initial order, and you will be prompted to
refresh the page.

We believe this provides a better development experience than having to
manually migrate data with a script.

Additionally, it fixes a bug that occurred when using `orderable` in
conjunction with groups and tabs fields.

Closes:
- #12129
- #12331
- #12212

---------

Co-authored-by: Dan Ribbens <dan.ribbens@gmail.com>
2025-05-16 22:21:46 +00:00
Jarrod Flesch
36597110e9 fix missing styles in admin-root 2025-05-16 15:59:25 -04:00
Jacob Fletcher
18f2f899c5 perf(ui): useAsTitle field lags on slow cpu (#12436)
When running the Payload admin panel on a machine with a slower CPU,
form state lags significantly and can become nearly unusable or even
crash when interacting with the document's `useAsTitle` field.

Here's an example:


https://github.com/user-attachments/assets/3535fa99-1b31-4cb6-b6a8-5eb9a36b31b7

#### Why this happens

The reason for this is that entire React component trees are
re-rendering on every keystroke of the `useAsTitle` field, twice over.

Here's a breakdown of the flow:

1. First, we dispatch form state events to the form context. Only the
components that are subscribed to form state re-render when this happens
(good).
2. Then, we sync the `useAsTitle` field to the document info provider,
which lives outside the form. Regardless of whether its children need to
be aware of the document title, all components subscribed to the
document info context will re-render (there are many, including the form
itself).

Given how far up the rendering tree the document info provider is, its
rendering footprint, and the rate of speed at which these events are
dispatched, this is resource intensive.

#### What is the fix

The fix is to isolate the document's title into it's own context. This
way only the components that are subscribed to specifically this context
will re-render as the title changes.

Here's the same test with the same CPU throttling, but no lag:


https://github.com/user-attachments/assets/c8ced9b1-b5f0-4789-8d00-a2523d833524
2025-05-16 15:51:57 -04:00
Jarrod Flesch
4f2b237858 fix: column selector bug 2025-05-16 14:26:04 -04:00
Jarrod Flesch
d90afba70d fixes more tests 2025-05-16 13:37:25 -04:00
Jarrod Flesch
decd512daa fix versions test selectors 2025-05-16 13:19:04 -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
Jarrod Flesch
b2bf95b17b allow using wait for ssr test 2025-05-16 11:46:40 -04:00
Jarrod Flesch
10e29dd5e2 fix outdated selectors in failing tests 2025-05-16 11:30:05 -04: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
Jarrod Flesch
4c4ae1295e fix css drawer header title selector in test 2025-05-16 10:27:31 -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
Jarrod Flesch
adb805dadb revert renderList type change 2025-05-15 17:00:19 -04:00
Jarrod Flesch
e5f0ca3d45 revert unused generics on ServerFunctionClient 2025-05-15 16:58:02 -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
fe2b7693cc successful build 2025-05-15 16:37:05 -04:00
Jarrod Flesch
30d4a098b1 adds missing translations 2025-05-15 16:34:39 -04:00
Jarrod Flesch
aa7918fe6e chore: extract tab style into button element 2025-05-15 16:30:13 -04:00
Jarrod Flesch
f23f87243c fixes issue with add folders in drawers 2025-05-15 16:29:51 -04:00
Jarrod Flesch
41b75882a1 Merge branch 'main' into HEAD 2025-05-15 16:28:06 -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
Jarrod Flesch
cfd3c34aba fix lint issues 2025-05-08 09:50:07 -07:00
Jarrod Flesch
270137e92e chore: add better preferences generic default 2025-05-07 15:07:49 -07:00
Jarrod Flesch
cab817d2aa build 2025-05-05 17:03:27 -04:00
Jarrod Flesch
db622e2b79 remove old folder int tests 2025-05-05 16:50:46 -04:00
Jarrod Flesch
c52204317a lint fixes 2025-05-05 16:35:08 -04:00
Jarrod Flesch
a85af1a6d3 import fix 2025-05-05 16:02:22 -04:00
Jarrod Flesch
bccadd5101 fix bad imports 2025-05-05 15:49:26 -04:00
Jarrod Flesch
2fa723743e chore: more test fixes 2025-05-05 14:53:25 -04:00
Jarrod Flesch
2904de778d fix unpublish and locked-docs suite 2025-05-05 12:53:48 -04:00
Jarrod Flesch
d37dfb1376 fix form-state tests 2025-05-05 12:02:42 -04:00
Jarrod Flesch
b203f617af fixes some failing test suites 2025-05-05 11:12:42 -04:00
Jarrod Flesch
ee46f27881 Merge branch 'main' into feat/folders 2025-05-05 09:01:32 -04:00
Jarrod Flesch
2426784726 passing versions suite 2025-05-05 08:31:48 -04:00
Jarrod Flesch
95b78a5951 fix admin locale picker test 2025-05-02 16:55:59 -04:00
Jarrod Flesch
d1c1ad2a1d fix build 2025-05-02 15:26:34 -04:00
Jarrod Flesch
2980bdb799 Merge branch 'main' into feat/folders 2025-05-02 14:37:46 -04:00
Jarrod Flesch
fe6923d0a7 bulk edit folder selection 2025-05-02 12:33:40 -04:00
Jarrod Flesch
7eec63ae69 fix toast missing title for media docs 2025-05-02 10:00:58 -04:00
Jarrod Flesch
77a7bc5e9c fix move to drawers layering and usage 2025-05-02 09:21:44 -04:00
Jarrod Flesch
780becc88a fix global views throwing errors on render 2025-05-02 08:23:37 -04:00
Jarrod Flesch
a2d394ec82 fix breadcrumb click issue 2025-05-02 08:20:49 -04:00
Jarrod Flesch
b1c8c96e97 chore: move doc to folder not updating label 2025-05-02 08:16:52 -04:00
Jarrod Flesch
6b6b596489 feat: allow searching all docs at root on collection-folder views 2025-05-01 16:52:36 -04:00
Jarrod Flesch
a89bc1479f better list selection organization 2025-05-01 14:13:48 -04:00
Jarrod Flesch
bd9f3b5bd2 chore: adds custom cell component for managing doc folder placement in default list view 2025-05-01 14:03:44 -04:00
Jarrod Flesch
2584ff42eb unifies move-to-drawer rather than needing many drawers 2025-05-01 00:34:37 -04:00
Jarrod Flesch
cc1f5fb70c add relationTo cell on browse-by-folder table 2025-04-30 16:46:33 -04:00
Jarrod Flesch
4f7f378b84 rename _parentFolder to _folder 2025-04-30 16:20:13 -04:00
Jarrod Flesch
b279fa7bde language edits for moving folders and documents 2025-04-30 16:13:10 -04:00
Jarrod Flesch
93230a5915 Merge branch 'main' into feat/folders 2025-04-23 06:46:18 -07:00
Jarrod Flesch
9f31daf8e7 prevent views from loading if folders are not enabled 2025-04-23 06:45:12 -07:00
Jarrod Flesch
ded7164dca pill sizing, fix e2e rendering issue 2025-04-21 16:24:09 -04:00
Jarrod Flesch
cc4526844a feat: allow add to folder from move drawer 2025-04-21 15:02:35 -04:00
Jarrod Flesch
362f25f593 commit updated types 2025-04-21 14:53:14 -04:00
Jarrod Flesch
7567d2358a fix: mismatch icon and overall size from small button and small pill 2025-04-21 14:52:44 -04:00
Jarrod Flesch
b418c3cade fix: folder not opening to current selection in doc view 2025-04-21 14:52:15 -04:00
Jarrod Flesch
db0d07d9a7 fix build 2025-04-21 12:41:02 -04:00
Jarrod Flesch
b56e2faad2 fixes folder specific build errors 2025-04-21 12:02:58 -04:00
Jarrod Flesch
56982f9811 Merge branch 'main' into feat/folders 2025-04-21 11:41:43 -04:00
Jarrod Flesch
9e1258811a finishes translations, adjusts create perms 2025-04-21 11:39:09 -04:00
Jarrod Flesch
1d2accfcbb chore: client side functional sorting 2025-04-18 15:50:56 -04:00
Jarrod Flesch
4a8bea2dde chore: adjust search placeholder and table column label 2025-04-18 11:22:36 -04:00
Jarrod Flesch
0149e42276 revert button styles with new chevron icon 2025-04-18 11:05:54 -04:00
Jarrod Flesch
36d9900774 Merge branch 'main' into feat/folders 2025-04-18 10:56:46 -04:00
Jarrod Flesch
f4d624a0c5 rm stale file 2025-04-18 09:46:51 -04:00
Jarrod Flesch
57bcfcc8be cleanup 2025-04-18 09:45:43 -04:00
Jarrod Flesch
d73ddbde0c browse by folder translation 2025-04-18 08:16:00 -04:00
Jarrod Flesch
189dd64799 debug false in test config 2025-04-17 15:21:01 -04:00
Jarrod Flesch
572ce2955a about done 2025-04-17 15:20:18 -04:00
Jarrod Flesch
87ca312a54 Merge branch 'main' into feat/folders 2025-04-14 14:24:12 -04:00
Jarrod Flesch
2b2fa67031 Merge remote-tracking branch 'origin/main' into feat/folders 2025-04-14 14:16:52 -04:00
Jarrod Flesch
2f45749634 rendering of root folder view 2025-04-14 14:16:01 -04:00
Jarrod Flesch
0534dd9506 add folder route to config 2025-04-14 08:05:25 -04:00
Jarrod Flesch
aee1ea1346 rename functions/files 2025-04-11 12:57:32 -04:00
Jarrod Flesch
82ba8cf8f4 feat: completes move-to folder drawer 2025-04-11 12:42:35 -04:00
Jarrod Flesch
1dfff2b0b0 adds no results and adjusts data functions 2025-04-08 10:43:20 -04:00
Jarrod Flesch
6b695355c3 wires up create new polymorphic list view button 2025-04-07 14:14:51 -04:00
Jarrod Flesch
29c32d3141 fix bad imports 2025-04-07 09:05:50 -04:00
Jarrod Flesch
f6afbed5d2 buildable 2025-04-07 08:56:56 -04:00
Jarrod Flesch
5dcf96ca10 chore: adjust translations function 2025-04-07 08:56:21 -04:00
Jarrod Flesch
91c22bd88c Merge branch 'main' into feat/folders 2025-04-04 16:22:57 -04:00
Jarrod Flesch
0d8b5677d9 feat: working collection-folder list view 2025-04-04 15:43:12 -04:00
Jarrod Flesch
e55c89c4b7 refactor: consolidates buildTableColumnState 2025-03-25 14:25:10 -04:00
Jarrod Flesch
f762d683c6 chore: refactoring, routing structure 2025-03-25 14:09:32 -04:00
Jarrod Flesch
953c538af0 Merge branch 'main' into feat/folders 2025-03-24 10:05:53 -04:00
Jarrod Flesch
65d0272950 refactor(folders): folder configuration 2025-03-13 10:05:30 -04:00
Jarrod Flesch
c570d6178c Merge branch 'main' into feat/folders 2025-02-27 15:30:14 -05:00
Jarrod Flesch
c935420937 lockfile 2025-02-26 16:00:10 -05:00
Jarrod Flesch
72dd527a15 Merge branch 'main' into feat/folders 2025-02-26 15:58:03 -05:00
Jarrod Flesch
1d6e0941e7 feat: search 2025-02-26 15:26:55 -05:00
Jarrod Flesch
91f7deb278 try/catch moveTo, fix button hover colors 2025-02-21 16:23:37 -05:00
Jarrod Flesch
d3986cfaf0 folder popup fix 2025-02-21 15:25:51 -05:00
Jarrod Flesch
4a9de40098 feat: wire up delete interations with confirm modal 2025-02-21 14:00:35 -05:00
Jarrod Flesch
f4679a4088 chore: adds folder delete confirmation translation 2025-02-21 13:59:55 -05:00
Jarrod Flesch
cb8ee7d2b0 fixes for moveTo drawer doc handling 2025-02-21 12:33:25 -05:00
Jarrod Flesch
06ef8da836 adds confirm translation for move drawer 2025-02-21 12:31:19 -05:00
Jarrod Flesch
91dc98978d chore: disabled instead of hide folders inside move drawer 2025-02-20 17:15:04 -05:00
Jarrod Flesch
07ff1ee7be misc fixes 2025-02-20 16:34:41 -05:00
Jarrod Flesch
72d393d24c more translations 2025-02-20 16:32:26 -05:00
Jarrod Flesch
95d4324af3 remove old files 2025-02-20 16:08:00 -05:00
Jarrod Flesch
a617d3166c use translations 2025-02-20 16:07:41 -05:00
Jarrod Flesch
6770e7a1b3 adds translation strings 2025-02-20 16:03:11 -05:00
Jarrod Flesch
f5e535dacf confirm modal for move 2025-02-20 15:21:43 -05:00
Jarrod Flesch
3d9c25e278 Merge branch 'main' into feat/folders 2025-02-20 13:37:13 -05:00
Jarrod Flesch
3bdb127ad4 start of modal tidy up 2025-02-20 13:31:46 -05:00
Jarrod Flesch
de4be1eb78 ui/ux fixes 2025-02-20 12:49:30 -05:00
Jarrod Flesch
d26529282f feat: move docs to folders within edit view 2025-02-19 16:52:33 -05:00
Jarrod Flesch
2694603353 feat: wire in pref for view type 2025-02-19 14:50:35 -05:00
Jarrod Flesch
9630de1bac feat: droppable crumbs, image cards, reusable item grid/table display 2025-02-19 13:59:38 -05:00
Jarrod Flesch
91e867ecb1 chore: regenerates lockfile 2025-02-14 14:54:54 -05:00
Jarrod Flesch
25e196bdd7 chore: successful build 2025-02-14 14:05:35 -05:00
Jarrod Flesch
b631eebdb5 Merge branch 'main' into feat/folders 2025-02-14 12:48:21 -05:00
Jarrod Flesch
a66f134cf6 feat: adds base folder functionality, list and grid views 2025-02-14 12:22:52 -05:00
Jarrod Flesch
b3aec9a23f chore: updates css imports 2025-01-16 15:10:49 -05:00
Jarrod Flesch
31911d87c1 chore: more merge resolutions 2025-01-14 16:05:19 -05:00
Jarrod Flesch
2d37ac41a2 merges main 2025-01-14 15:57:23 -05:00
Jarrod Flesch
b68af8ba31 Merge branch 'main' into feat/folders 2025-01-14 15:48:39 -05:00
Jarrod Flesch
4c775d1ced Merge branch 'main' into feat/folders 2025-01-02 12:25:43 -05:00
Jarrod Flesch
4706019a22 feat: brings back subfolder viewing, folder assigning etc 2024-12-18 14:40:26 -05:00
Jarrod Flesch
9c33a48192 Merge branch 'main' into feat/folders 2024-12-17 14:17:40 -05:00
Jarrod Flesch
8ec6784645 Merge branch 'main' into feat/beta/folders 2024-12-13 12:58:07 -05:00
Jarrod Flesch
8d9bf835a0 Merge branch 'main' into feat/beta/folders 2024-11-19 13:54:57 -05:00
Jarrod Flesch
2eef7ee388 moves base folder work into beta 2024-10-01 09:50:50 -04:00
671 changed files with 27960 additions and 8342 deletions

View File

@@ -62,12 +62,6 @@ jobs:
echo "templates: ${{ steps.filter.outputs.templates }}"
lint:
# Follows same github's ci skip: [skip lint], [lint skip], [no lint]
if: >
github.event_name == 'pull_request' &&
!contains(github.event.pull_request.title, '[skip lint]') &&
!contains(github.event.pull_request.title, '[lint skip]') &&
!contains(github.event.pull_request.title, '[no lint]')
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
@@ -81,10 +75,8 @@ jobs:
pnpm-version: ${{ env.PNPM_VERSION }}
pnpm-install-cache-key: pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
- name: Lint staged
run: |
git diff --name-only --diff-filter=d origin/${GITHUB_BASE_REF}...${GITHUB_SHA}
npx lint-staged --diff="origin/${GITHUB_BASE_REF}...${GITHUB_SHA}"
- name: Lint
run: pnpm lint -- --quiet
build:
needs: changes

1
.gitignore vendored
View File

@@ -3,6 +3,7 @@ package-lock.json
dist
/.idea/*
!/.idea/runConfigurations
/.idea/runConfigurations/_template*
!/.idea/payload.iml
# Custom actions

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>

7
.vscode/launch.json vendored
View File

@@ -118,6 +118,13 @@
"request": "launch",
"type": "node-terminal"
},
{
"command": "pnpm tsx --no-deprecation test/dev.ts folder-view",
"cwd": "${workspaceFolder}",
"name": "Run Dev Folder View",
"request": "launch",
"type": "node-terminal"
},
{
"command": "pnpm tsx --no-deprecation test/dev.ts localization",
"cwd": "${workspaceFolder}",

View File

@@ -7,9 +7,6 @@
},
"editor.formatOnSaveMode": "file",
"eslint.rules.customizations": [
// Defaultt all ESLint errors to 'warn' to differentate from TypeScript's 'error' level
{ "rule": "*", "severity": "warn" },
// Silence some warnings that will get auto-fixed
{ "rule": "perfectionist/*", "severity": "off", "fixable": true },
{ "rule": "curly", "severity": "off", "fixable": true },

View File

@@ -132,6 +132,7 @@ The following options are available:
| `hideAPIURL` | Hides the "API URL" meta field while editing documents within this Collection. |
| `enableRichTextLink` | The [Rich Text](../fields/rich-text) field features a `Link` element which allows for users to automatically reference related documents within their rich text. Set to `true` by default. |
| `enableRichTextRelationship` | The [Rich Text](../fields/rich-text) field features a `Relationship` element which allows for users to automatically reference related documents within their rich text. Set to `true` by default. |
| `folders` | A boolean to enable folders for a given collection. Defaults to `false`. [More details](../folders/overview). |
| `meta` | Page metadata overrides to apply to this Collection within the Admin Panel. [More details](../admin/metadata). |
| `preview` | Function to generate preview URLs within the Admin Panel that can point to your app. [More details](../admin/preview). |
| `livePreview` | Enable real-time editing for instant visual feedback of your front-end application. [More details](../live-preview/overview). |

View File

@@ -84,6 +84,7 @@ The following options are available:
| **`csrf`** | A whitelist array of URLs to allow Payload to accept cookies from. [More details](../authentication/cookies#csrf-attacks). |
| **`defaultDepth`** | If a user does not specify `depth` while requesting a resource, this depth will be used. [More details](../queries/depth). |
| **`defaultMaxTextLength`** | The maximum allowed string length to be permitted application-wide. Helps to prevent malicious public document creation. |
| `folders` | An optional object to configure global folder settings. [More details](../folders/overview). |
| `queryPresets` | An object that to configure Collection Query Presets. [More details](../query-presets/overview). |
| **`maxDepth`** | The maximum allowed depth to be permitted application-wide. This setting helps prevent against malicious queries. Defaults to `10`. [More details](../queries/depth). |
| **`indexSortableFields`** | Automatically index all sortable top-level fields in the database to improve sort performance and add database compatibility for Azure Cosmos and similar. |

View File

@@ -100,7 +100,7 @@ Here are the available Presentational Fields:
### Virtual Fields
Virtual fields are used to display data that is not stored in the database. They are useful for displaying computed values that populate within the APi response through hooks, etc.
Virtual fields are used to display data that is not stored in the database. They are useful for displaying computed values that populate within the API response through hooks, etc.
Here are the available Virtual Fields:

100
docs/folders/overview.mdx Normal file
View File

@@ -0,0 +1,100 @@
---
title: Folders
label: Folders
order: 10
desc: Folders allow you to group documents across collections, and are a great way to organize your content.
keywords: folders, folder, content organization
---
Folders allow you to group documents across collections, and are a great way to organize your content. Folders are built on top of relationship fields, when you enable folders on a collection, Payload adds a hidden relationship field `folders`, that relates to a folder — or no folder. Folders also have the `folder` field, allowing folders to be nested within other folders.
The configuration for folders is done in two places, the collection config and the Payload config. The collection config is where you enable folders, and the Payload config is where you configure the global folder settings.
## Folder Configuration
On the payload config, you can configure the following settings under the `folders` property:
```ts
// Type definition
type RootFoldersConfiguration = {
/**
* An array of functions to be ran when the folder collection is initialized
* This allows plugins to modify the collection configuration
*/
collectionOverrides?: (({
collection,
}: {
collection: CollectionConfig
}) => CollectionConfig | Promise<CollectionConfig>)[]
/**
* Ability to view hidden fields and collections related to folders
*
* @default false
*/
debug?: boolean
/**
* The Folder field name
*
* @default "folder"
*/
fieldName?: string
/**
* Slug for the folder collection
*
* @default "payload-folders"
*/
slug?: string
}
```
```ts
// Example usage
import { buildConfig } from 'payload'
const config = buildConfig({
// ...
folders: {
// highlight-start
debug: true, // optional
collectionOverrides: [
async ({ collection }) => {
return collection
},
], // optional
fieldName: 'folder', // optional
slug: 'payload-folders', // optional
// highlight-end
},
})
```
## Collection Configuration
To enable folders on a collection, you need to set the `admin.folders` property to `true` on the collection config. This will add a hidden relationship field to the collection that relates to a folder — or no folder.
```ts
// Type definition
type CollectionFoldersConfiguration = boolean
```
```ts
// Example usage
import { buildConfig } from 'payload'
const config = buildConfig({
collections: [
{
slug: 'pages',
// highlight-start
admin: {
folders: true, // defaults to false
},
// highlight-end
},
],
})
```

View File

@@ -81,7 +81,7 @@ To install a Database Adapter, you can run **one** of the following commands:
#### 2. Copy Payload files into your Next.js app folder
Payload installs directly in your Next.js `/app` folder, and you'll need to place some files into that folder for Payload to run. You can copy these files from the [Blank Template](https://github.com/payloadcms/payload/tree/main/templates/blank/src/app/(payload)) on GitHub. Once you have the required Payload files in place in your `/app` folder, you should have something like this:
Payload installs directly in your Next.js `/app` folder, and you'll need to place some files into that folder for Payload to run. You can copy these files from the [Blank Template](<https://github.com/payloadcms/payload/tree/main/templates/blank/src/app/(payload)>) on GitHub. Once you have the required Payload files in place in your `/app` folder, you should have something like this:
```plaintext
app/

View File

@@ -85,6 +85,7 @@ formBuilderPlugin({
checkbox: true,
number: true,
message: true,
date: false,
payment: false,
},
})
@@ -349,6 +350,18 @@ Maps to a `checkbox` input on your front-end. Used to collect a boolean value.
| `width` | string | The width of the field on the front-end. |
| `required` | checkbox | Whether or not the field is required when submitted. |
### Date
Maps to a `date` input on your front-end. Used to collect a date value.
| Property | Type | Description |
| -------------- | -------- | ---------------------------------------------------- |
| `name` | string | The name of the field. |
| `label` | string | The label of the field. |
| `defaultValue` | date | The default value of the field. |
| `width` | string | The width of the field on the front-end. |
| `required` | checkbox | Whether or not the field is required when submitted. |
### Number
Maps to a `number` input on your front-end. Used to collect a number.
@@ -421,6 +434,42 @@ formBuilderPlugin({
})
```
### Customizing the date field default value
You can custommise the default value of the date field and any other aspects of the date block in this way.
Note that the end submission source will be responsible for the timezone of the date. Payload only stores the date in UTC format.
```ts
import { fields as formFields } from '@payloadcms/plugin-form-builder'
// payload.config.ts
formBuilderPlugin({
fields: {
// date: true, // just enable it without any customizations
date: {
...formFields.date,
fields: [
...(formFields.date && 'fields' in formFields.date
? formFields.date.fields.map((field) => {
if ('name' in field && field.name === 'defaultValue') {
return {
...field,
timezone: true, // optionally enable timezone
admin: {
...field.admin,
description: 'This is a date field',
},
}
}
return field
})
: []),
],
},
},
})
```
## Email
This plugin relies on the [email configuration](../email/overview) defined in your Payload configuration. It will read from your config and attempt to send your emails using the credentials provided.

View File

@@ -6,14 +6,14 @@ desc: Converting between lexical richtext and HTML
keywords: lexical, richtext, html
---
## Converting Rich Text to HTML
## Rich Text to HTML
There are two main approaches to convert your Lexical-based rich text to HTML:
1. **Generate HTML on-demand (Recommended)**: Convert JSON to HTML wherever you need it, on-demand.
2. **Generate HTML within your Collection**: Create a new field that automatically converts your saved JSON content to HTML. This is not recommended because it adds overhead to the Payload API and may not work well with live preview.
### Generating HTML on-demand (Recommended)
### On-demand
To convert JSON to HTML on-demand, use the `convertLexicalToHTML` function from `@payloadcms/richtext-lexical/html`. Here's an example of how to use it in a React component in your frontend:
@@ -32,61 +32,81 @@ export const MyComponent = ({ data }: { data: SerializedEditorState }) => {
}
```
### Converting Lexical Blocks
#### Dynamic Population (Advanced)
If your rich text includes Lexical blocks, you need to provide a way to convert them to HTML. For example:
By default, `convertLexicalToHTML` expects fully populated data (e.g. uploads, links, etc.). If you need to dynamically fetch and populate those nodes, use the async variant, `convertLexicalToHTMLAsync`, from `@payloadcms/richtext-lexical/html-async`. You must provide a `populate` function:
```tsx
'use client'
import type { MyInlineBlock, MyTextBlock } from '@/payload-types'
import type {
DefaultNodeTypes,
SerializedBlockNode,
SerializedInlineBlockNode,
} from '@payloadcms/richtext-lexical'
import type { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
import {
convertLexicalToHTML,
type HTMLConvertersFunction,
} from '@payloadcms/richtext-lexical/html'
import React from 'react'
type NodeTypes =
| DefaultNodeTypes
| SerializedBlockNode<MyTextBlock>
| SerializedInlineBlockNode<MyInlineBlock>
const htmlConverters: HTMLConvertersFunction<NodeTypes> = ({
defaultConverters,
}) => ({
...defaultConverters,
blocks: {
// Each key should match your block's slug
myTextBlock: ({ node, providedCSSString }) =>
`<div style="background-color: red;${providedCSSString}">${node.fields.text}</div>`,
},
inlineBlocks: {
// Each key should match your inline block's slug
myInlineBlock: ({ node, providedStyleTag }) =>
`<span${providedStyleTag}>${node.fields.text}</span$>`,
},
})
import { getRestPopulateFn } from '@payloadcms/richtext-lexical/client'
import { convertLexicalToHTMLAsync } from '@payloadcms/richtext-lexical/html-async'
import React, { useEffect, useState } from 'react'
export const MyComponent = ({ data }: { data: SerializedEditorState }) => {
const html = convertLexicalToHTML({
converters: htmlConverters,
data,
})
const [html, setHTML] = useState<null | string>(null)
useEffect(() => {
async function convert() {
const html = await convertLexicalToHTMLAsync({
data,
populate: getRestPopulateFn({
apiURL: `http://localhost:3000/api`,
}),
})
setHTML(html)
}
return <div dangerouslySetInnerHTML={{ __html: html }} />
void convert()
}, [data])
return html && <div dangerouslySetInnerHTML={{ __html: html }} />
}
```
### Outputting HTML from the Collection
Using the REST populate function will send a separate request for each node. If you need to populate a large number of nodes, this may be slow. For improved performance on the server, you can use the `getPayloadPopulateFn` function:
To automatically generate HTML from the saved richText field in your Collection, use the `lexicalHTMLField()` helper. This approach converts the JSON to HTML using an `afterRead` hook. For instance:
```tsx
import type { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
import { getPayloadPopulateFn } from '@payloadcms/richtext-lexical'
import { convertLexicalToHTMLAsync } from '@payloadcms/richtext-lexical/html-async'
import { getPayload } from 'payload'
import React from 'react'
import config from '../../config.js'
export const MyRSCComponent = async ({
data,
}: {
data: SerializedEditorState
}) => {
const payload = await getPayload({
config,
})
const html = await convertLexicalToHTMLAsync({
data,
populate: await getPayloadPopulateFn({
currentDepth: 0,
depth: 1,
payload,
}),
})
return html && <div dangerouslySetInnerHTML={{ __html: html }} />
}
```
### HTML field
The `lexicalHTMLField()` helper converts JSON to HTML and saves it in a field that is updated every time you read it via an `afterRead` hook. It's generally not recommended for two reasons:
1. It creates a column with duplicate content in another format.
2. In [client-side live preview](/docs/live-preview/client), it makes it not "live".
Consider using the [on-demand HTML converter above](/docs/rich-text/converting-html#on-demand-recommended) or the [JSX converter](/docs/rich-text/converting-jsx) unless you have a good reason.
```ts
import type { HTMLConvertersFunction } from '@payloadcms/richtext-lexical/html'
@@ -154,74 +174,59 @@ const Pages: CollectionConfig = {
}
```
### Generating HTML in Your Frontend with Dynamic Population (Advanced)
## Blocks to HTML
By default, `convertLexicalToHTML` expects fully populated data (e.g. uploads, links, etc.). If you need to dynamically fetch and populate those nodes, use the async variant, `convertLexicalToHTMLAsync`, from `@payloadcms/richtext-lexical/html-async`. You must provide a `populate` function:
If your rich text includes Lexical blocks, you need to provide a way to convert them to HTML. For example:
```tsx
'use client'
import type { MyInlineBlock, MyTextBlock } from '@/payload-types'
import type {
DefaultNodeTypes,
SerializedBlockNode,
SerializedInlineBlockNode,
} from '@payloadcms/richtext-lexical'
import type { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
import { getRestPopulateFn } from '@payloadcms/richtext-lexical/client'
import { convertLexicalToHTMLAsync } from '@payloadcms/richtext-lexical/html-async'
import React, { useEffect, useState } from 'react'
export const MyComponent = ({ data }: { data: SerializedEditorState }) => {
const [html, setHTML] = useState<null | string>(null)
useEffect(() => {
async function convert() {
const html = await convertLexicalToHTMLAsync({
data,
populate: getRestPopulateFn({
apiURL: `http://localhost:3000/api`,
}),
})
setHTML(html)
}
void convert()
}, [data])
return html && <div dangerouslySetInnerHTML={{ __html: html }} />
}
```
Using the REST populate function will send a separate request for each node. If you need to populate a large number of nodes, this may be slow. For improved performance on the server, you can use the `getPayloadPopulateFn` function:
```tsx
import type { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
import { getPayloadPopulateFn } from '@payloadcms/richtext-lexical'
import { convertLexicalToHTMLAsync } from '@payloadcms/richtext-lexical/html-async'
import { getPayload } from 'payload'
import {
convertLexicalToHTML,
type HTMLConvertersFunction,
} from '@payloadcms/richtext-lexical/html'
import React from 'react'
import config from '../../config.js'
type NodeTypes =
| DefaultNodeTypes
| SerializedBlockNode<MyTextBlock>
| SerializedInlineBlockNode<MyInlineBlock>
export const MyRSCComponent = async ({
data,
}: {
data: SerializedEditorState
}) => {
const payload = await getPayload({
config,
})
const htmlConverters: HTMLConvertersFunction<NodeTypes> = ({
defaultConverters,
}) => ({
...defaultConverters,
blocks: {
// Each key should match your block's slug
myTextBlock: ({ node, providedCSSString }) =>
`<div style="background-color: red;${providedCSSString}">${node.fields.text}</div>`,
},
inlineBlocks: {
// Each key should match your inline block's slug
myInlineBlock: ({ node, providedStyleTag }) =>
`<span${providedStyleTag}>${node.fields.text}</span$>`,
},
})
const html = await convertLexicalToHTMLAsync({
export const MyComponent = ({ data }: { data: SerializedEditorState }) => {
const html = convertLexicalToHTML({
converters: htmlConverters,
data,
populate: await getPayloadPopulateFn({
currentDepth: 0,
depth: 1,
payload,
}),
})
return html && <div dangerouslySetInnerHTML={{ __html: html }} />
return <div dangerouslySetInnerHTML={{ __html: html }} />
}
```
## Converting HTML to Richtext
## HTML to Richtext
If you need to convert raw HTML into a Lexical editor state, use `convertHTMLToLexical` from `@payloadcms/richtext-lexical`, along with the [editorConfigFactory to retrieve the editor config](/docs/rich-text/converters#retrieving-the-editor-config):

View File

@@ -6,7 +6,7 @@ desc: Converting between lexical richtext and JSX
keywords: lexical, richtext, jsx
---
## Converting Richtext to JSX
## Richtext to JSX
To convert richtext to JSX, import the `RichText` component from `@payloadcms/richtext-lexical/react` and pass the richtext content to it:
@@ -28,7 +28,7 @@ The `RichText` component includes built-in converters for common Lexical nodes.
populated data to work correctly.
</Banner>
### Converting Internal Links
### Internal Links
By default, Payload doesn't know how to convert **internal** links to JSX, as it doesn't know what the corresponding URL of the internal link is. You'll notice that you get a "found internal link, but internalDocToHref is not provided" error in the console when you try to render content with internal links.
@@ -81,7 +81,7 @@ export const MyComponent: React.FC<{
}
```
### Converting Lexical Blocks
### Lexical Blocks
If your rich text includes custom Blocks or Inline Blocks, you must supply custom converters that match each block's slug. This converter is not included by default, as Payload doesn't know how to render your custom blocks.
@@ -133,7 +133,7 @@ export const MyComponent: React.FC<{
}
```
### Overriding Default JSX Converters
### Overriding Converters
You can override any of the default JSX converters by passing passing your custom converter, keyed to the node type, to the `converters` prop / the converters function.

View File

@@ -6,7 +6,7 @@ desc: Converting between lexical richtext and Markdown / MDX
keywords: lexical, richtext, markdown, md, mdx
---
## Converting Richtext to Markdown
## Richtext to Markdown
If you have access to the Payload Config and the [lexical editor config](/docs/rich-text/converters#retrieving-the-editor-config), you can convert the lexical editor state to Markdown with the following:
@@ -91,7 +91,7 @@ const Pages: CollectionConfig = {
}
```
## Converting Markdown to Richtext
## Markdown to Richtext
If you have access to the Payload Config and the [lexical editor config](/docs/rich-text/converters#retrieving-the-editor-config), you can convert Markdown to the lexical editor state with the following:

View File

@@ -6,7 +6,7 @@ desc: Converting between lexical richtext and plaintext
keywords: lexical, richtext, plaintext, text
---
## Converting Richtext to Plaintext
## Richtext to Plaintext
Here's how you can convert richtext data to plaintext using `@payloadcms/richtext-lexical/plaintext`.

View File

@@ -142,32 +142,33 @@ import { CallToAction } from '../blocks/CallToAction'
Here's an overview of all the included features:
| Feature Name | Included by default | Description |
| ------------------------------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`BoldFeature`** | Yes | Handles the bold text format |
| **`ItalicFeature`** | Yes | Handles the italic text format |
| **`UnderlineFeature`** | Yes | Handles the underline text format |
| **`StrikethroughFeature`** | Yes | Handles the strikethrough text format |
| **`SubscriptFeature`** | Yes | Handles the subscript text format |
| **`SuperscriptFeature`** | Yes | Handles the superscript text format |
| **`InlineCodeFeature`** | Yes | Handles the inline-code text format |
| **`ParagraphFeature`** | Yes | Handles paragraphs. Since they are already a key feature of lexical itself, this Feature mainly handles the Slash and Add-Block menu entries for paragraphs |
| **`HeadingFeature`** | Yes | Adds Heading Nodes (by default, H1 - H6, but that can be customized) |
| **`AlignFeature`** | Yes | Allows you to align text left, centered and right |
| **`IndentFeature`** | Yes | Allows you to indent text with the tab key |
| **`UnorderedListFeature`** | Yes | Adds unordered lists (ul) |
| **`OrderedListFeature`** | Yes | Adds ordered lists (ol) |
| **`ChecklistFeature`** | Yes | Adds checklists |
| **`LinkFeature`** | Yes | Allows you to create internal and external links |
| **`RelationshipFeature`** | Yes | Allows you to create block-level (not inline) relationships to other documents |
| **`BlockquoteFeature`** | Yes | Allows you to create block-level quotes |
| **`UploadFeature`** | Yes | Allows you to create block-level upload nodes - this supports all kinds of uploads, not just images |
| **`HorizontalRuleFeature`** | Yes | Horizontal rules / separators. Basically displays an `<hr>` element |
| **`InlineToolbarFeature`** | Yes | The inline toolbar is the floating toolbar which appears when you select text. This toolbar only contains actions relevant for selected text |
| **`FixedToolbarFeature`** | No | This classic toolbar is pinned to the top and always visible. Both inline and fixed toolbars can be enabled at the same time. |
| **`BlocksFeature`** | No | Allows you to use Payload's [Blocks Field](../fields/blocks) directly inside your editor. In the feature props, you can specify the allowed blocks - just like in the Blocks field. |
| **`TreeViewFeature`** | No | Adds a debug box under the editor, which allows you to see the current editor state live, the dom, as well as time travel. Very useful for debugging |
| **`EXPERIMENTAL_TableFeature`** | No | Adds support for tables. This feature may be removed or receive breaking changes in the future - even within a stable lexical release, without needing a major release. |
| Feature Name | Included by default | Description |
| ----------------------------------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`BoldFeature`** | Yes | Handles the bold text format |
| **`ItalicFeature`** | Yes | Handles the italic text format |
| **`UnderlineFeature`** | Yes | Handles the underline text format |
| **`StrikethroughFeature`** | Yes | Handles the strikethrough text format |
| **`SubscriptFeature`** | Yes | Handles the subscript text format |
| **`SuperscriptFeature`** | Yes | Handles the superscript text format |
| **`InlineCodeFeature`** | Yes | Handles the inline-code text format |
| **`ParagraphFeature`** | Yes | Handles paragraphs. Since they are already a key feature of lexical itself, this Feature mainly handles the Slash and Add-Block menu entries for paragraphs |
| **`HeadingFeature`** | Yes | Adds Heading Nodes (by default, H1 - H6, but that can be customized) |
| **`AlignFeature`** | Yes | Allows you to align text left, centered and right |
| **`IndentFeature`** | Yes | Allows you to indent text with the tab key |
| **`UnorderedListFeature`** | Yes | Adds unordered lists (ul) |
| **`OrderedListFeature`** | Yes | Adds ordered lists (ol) |
| **`ChecklistFeature`** | Yes | Adds checklists |
| **`LinkFeature`** | Yes | Allows you to create internal and external links |
| **`RelationshipFeature`** | Yes | Allows you to create block-level (not inline) relationships to other documents |
| **`BlockquoteFeature`** | Yes | Allows you to create block-level quotes |
| **`UploadFeature`** | Yes | Allows you to create block-level upload nodes - this supports all kinds of uploads, not just images |
| **`HorizontalRuleFeature`** | Yes | Horizontal rules / separators. Basically displays an `<hr>` element |
| **`InlineToolbarFeature`** | Yes | The inline toolbar is the floating toolbar which appears when you select text. This toolbar only contains actions relevant for selected text |
| **`FixedToolbarFeature`** | No | This classic toolbar is pinned to the top and always visible. Both inline and fixed toolbars can be enabled at the same time. |
| **`BlocksFeature`** | No | Allows you to use Payload's [Blocks Field](../fields/blocks) directly inside your editor. In the feature props, you can specify the allowed blocks - just like in the Blocks field. |
| **`TreeViewFeature`** | No | Adds a debug box under the editor, which allows you to see the current editor state live, the dom, as well as time travel. Very useful for debugging |
| **`EXPERIMENTAL_TableFeature`** | No | Adds support for tables. This feature may be removed or receive breaking changes in the future - even within a stable lexical release, without needing a major release. |
| **`EXPERIMENTAL_TextStateFeature`** | No | Allows you to store key-value attributes within TextNodes and assign them inline styles. |
Notice how even the toolbars are features? That's how extensible our lexical editor is - you could theoretically create your own toolbar if you wanted to!

View File

@@ -81,15 +81,6 @@ export const rootEslintConfig = [
export default [
...rootEslintConfig,
{
languageOptions: {
parserOptions: {
...rootParserOptions,
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
files: ['packages/eslint-config/**/*.ts'],
rules: {

View File

@@ -74,9 +74,9 @@
"docker:start": "docker compose -f test/docker-compose.yml up -d",
"docker:stop": "docker compose -f test/docker-compose.yml down",
"force:build": "pnpm run build:core:force",
"lint": "turbo run lint --concurrency 1 --continue",
"lint": "turbo run lint --log-order=grouped --continue",
"lint-staged": "lint-staged",
"lint:fix": "turbo run lint:fix --concurrency 1 --continue",
"lint:fix": "turbo run lint:fix --log-order=grouped --continue",
"obliterate-playwright-cache-macos": "rm -rf ~/Library/Caches/ms-playwright && find /System/Volumes/Data/private/var/folders -type d -name 'playwright*' -exec rm -rf {} +",
"prepare": "husky",
"prepare-run-test-against-prod": "pnpm bf && rm -rf test/packed && rm -rf test/node_modules && rm -rf app && rm -f test/pnpm-lock.yaml && pnpm run script:pack --all --no-build --dest test/packed && pnpm runts test/setupProd.ts && cd test && pnpm i --ignore-workspace && cd ..",

View File

@@ -1,18 +0,0 @@
import { rootEslintConfig, rootParserOptions } from '../../eslint.config.js'
/** @typedef {import('eslint').Linter.Config} Config */
/** @type {Config[]} */
export const index = [
...rootEslintConfig,
{
languageOptions: {
parserOptions: {
...rootParserOptions,
tsconfigRootDir: import.meta.dirname,
},
},
},
]
export default index

View File

@@ -1,19 +0,0 @@
import { rootEslintConfig, rootParserOptions } from '../../eslint.config.js'
/** @typedef {import('eslint').Linter.Config} Config */
/** @type {Config[]} */
export const index = [
...rootEslintConfig,
{
ignores: ['bin/cli.js'],
languageOptions: {
parserOptions: {
...rootParserOptions,
tsconfigRootDir: import.meta.dirname,
},
},
},
]
export default index

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,18 +0,0 @@
import { rootEslintConfig, rootParserOptions } from '../../eslint.config.js'
/** @typedef {import('eslint').Linter.Config} Config */
/** @type {Config[]} */
export const index = [
...rootEslintConfig,
{
languageOptions: {
parserOptions: {
...rootParserOptions,
tsconfigRootDir: import.meta.dirname,
},
},
},
]
export default index

View File

@@ -20,7 +20,6 @@ type SearchParam = {
const subQueryOptions = {
lean: true,
limit: 50,
}
/**
@@ -184,7 +183,7 @@ export async function buildSearchParam({
select[joinPath] = true
}
const result = await SubModel.find(subQuery).lean().limit(50).select(select)
const result = await SubModel.find(subQuery).lean().select(select)
const $in: unknown[] = []

View File

@@ -150,6 +150,18 @@ export const buildSortParam = ({
sort = [sort]
}
// In the case of Mongo, when sorting by a field that is not unique, the results are not guaranteed to be in the same order each time.
// So we add a fallback sort to ensure that the results are always in the same order.
let fallbackSort = '-id'
if (timestamps) {
fallbackSort = '-createdAt'
}
if (!(sort.includes(fallbackSort) || sort.includes(fallbackSort.replace('-', '')))) {
sort.push(fallbackSort)
}
const sorting = sort.reduce<Record<string, string>>((acc, item) => {
let sortProperty: string
let sortDirection: SortDirection

View File

@@ -1,18 +0,0 @@
import { rootEslintConfig, rootParserOptions } from '../../eslint.config.js'
/** @typedef {import('eslint').Linter.Config} Config */
/** @type {Config[]} */
export const index = [
...rootEslintConfig,
{
languageOptions: {
parserOptions: {
...rootParserOptions,
tsconfigRootDir: import.meta.dirname,
},
},
},
]
export default index

View File

@@ -1,18 +0,0 @@
import { rootEslintConfig, rootParserOptions } from '../../eslint.config.js'
/** @typedef {import('eslint').Linter.Config} Config */
/** @type {Config[]} */
export const index = [
...rootEslintConfig,
{
languageOptions: {
parserOptions: {
...rootParserOptions,
tsconfigRootDir: import.meta.dirname,
},
},
},
]
export default index

View File

@@ -1,18 +0,0 @@
import { rootEslintConfig, rootParserOptions } from '../../eslint.config.js'
/** @typedef {import('eslint').Linter.Config} Config */
/** @type {Config[]} */
export const index = [
...rootEslintConfig,
{
languageOptions: {
parserOptions: {
...rootParserOptions,
tsconfigRootDir: import.meta.dirname,
},
},
},
]
export default index

View File

@@ -4,7 +4,7 @@ import toSnakeCase from 'to-snake-case'
import type { DrizzleAdapter } from './types.js'
import buildQuery from './queries/buildQuery.js'
import { buildQuery } from './queries/buildQuery.js'
import { getTransaction } from './utilities/getTransaction.js'
export const count: Count = async function count(

View File

@@ -5,7 +5,7 @@ import toSnakeCase from 'to-snake-case'
import type { DrizzleAdapter } from './types.js'
import buildQuery from './queries/buildQuery.js'
import { buildQuery } from './queries/buildQuery.js'
import { getTransaction } from './utilities/getTransaction.js'
export const countGlobalVersions: CountGlobalVersions = async function countGlobalVersions(

View File

@@ -5,7 +5,7 @@ import toSnakeCase from 'to-snake-case'
import type { DrizzleAdapter } from './types.js'
import buildQuery from './queries/buildQuery.js'
import { buildQuery } from './queries/buildQuery.js'
import { getTransaction } from './utilities/getTransaction.js'
export const countVersions: CountVersions = async function countVersions(

View File

@@ -23,10 +23,10 @@ export async function createGlobal<T extends Record<string, unknown>>(
data,
db,
fields: globalConfig.flattenedFields,
ignoreResult: returning === false,
operation: 'create',
req,
tableName,
ignoreResult: returning === false,
})
if (returning === false) {

View File

@@ -17,11 +17,11 @@ export async function createGlobalVersion<T extends TypeWithID>(
globalSlug,
publishedLocale,
req,
returning,
select,
snapshot,
updatedAt,
versionData,
returning,
}: CreateGlobalVersionArgs,
) {
const db = await getTransaction(this, req)
@@ -42,11 +42,11 @@ export async function createGlobalVersion<T extends TypeWithID>(
},
db,
fields: buildVersionGlobalFields(this.payload.config, global, true),
ignoreResult: returning === false ? 'idOnly' : false,
operation: 'create',
req,
select,
tableName,
ignoreResult: returning === false ? 'idOnly' : false,
})
const table = this.tables[tableName]

View File

@@ -18,11 +18,11 @@ export async function createVersion<T extends TypeWithID>(
parent,
publishedLocale,
req,
returning,
select,
snapshot,
updatedAt,
versionData,
returning,
}: CreateVersionArgs<T>,
) {
const db = await getTransaction(this, req)

View File

@@ -6,7 +6,7 @@ import toSnakeCase from 'to-snake-case'
import type { DrizzleAdapter } from './types.js'
import { buildFindManyArgs } from './find/buildFindManyArgs.js'
import buildQuery from './queries/buildQuery.js'
import { buildQuery } from './queries/buildQuery.js'
import { selectDistinct } from './queries/selectDistinct.js'
import { transform } from './transform/read/index.js'
import { getTransaction } from './utilities/getTransaction.js'

View File

@@ -4,7 +4,7 @@ import { inArray } from 'drizzle-orm'
import type { DrizzleAdapter } from '../types.js'
import buildQuery from '../queries/buildQuery.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'

View File

@@ -19,13 +19,17 @@ import toSnakeCase from 'to-snake-case'
import type { BuildQueryJoinAliases, DrizzleAdapter } from '../types.js'
import type { Result } from './buildFindManyArgs.js'
import buildQuery from '../queries/buildQuery.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) {
@@ -250,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
@@ -290,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

@@ -23,7 +23,7 @@ export { migrateFresh } from './migrateFresh.js'
export { migrateRefresh } from './migrateRefresh.js'
export { migrateReset } from './migrateReset.js'
export { migrateStatus } from './migrateStatus.js'
export { default as buildQuery } from './queries/buildQuery.js'
export { buildQuery } from './queries/buildQuery.js'
export { operatorMap } from './queries/operatorMap.js'
export type { Operators } from './queries/operatorMap.js'
export { parseParams } from './queries/parseParams.js'

View File

@@ -1,4 +1,4 @@
import type { FlattenedBlock, FlattenedField } from 'payload'
import type { FlattenedField } from 'payload'
type Args = {
doc: Record<string, unknown>
@@ -54,7 +54,7 @@ export const traverseFields = ({ doc, fields, locale, path, rows }: Args) => {
// Can ignore string blocks, as those were added in v3 and don't need to be migrated
const matchedBlock = field.blocks.find(
(block) => typeof block !== 'string' && block.slug === row.blockType,
) as FlattenedBlock | undefined
)
if (matchedBlock) {
return traverseFields({
@@ -75,7 +75,7 @@ export const traverseFields = ({ doc, fields, locale, path, rows }: Args) => {
// Can ignore string blocks, as those were added in v3 and don't need to be migrated
const matchedBlock = field.blocks.find(
(block) => typeof block !== 'string' && block.slug === row.blockType,
) as FlattenedBlock | undefined
)
if (matchedBlock) {
return traverseFields({

View File

@@ -1,7 +1,7 @@
import type { SQL, Table } from 'drizzle-orm'
import type { FlattenedField, Sort } from 'payload'
import { asc, desc, or } from 'drizzle-orm'
import { asc, desc } from 'drizzle-orm'
import type { DrizzleAdapter, GenericColumn } from '../types.js'
import type { BuildQueryJoinAliases, BuildQueryResult } from './buildQuery.js'
@@ -39,8 +39,9 @@ export const buildOrderBy = ({
}: Args): BuildQueryResult['orderBy'] => {
const orderBy: BuildQueryResult['orderBy'] = []
const createdAt = adapter.tables[tableName]?.createdAt
if (!sort) {
const createdAt = adapter.tables[tableName]?.createdAt
if (createdAt) {
sort = '-createdAt'
} else {
@@ -52,6 +53,18 @@ export const buildOrderBy = ({
sort = [sort]
}
// In the case of Mongo, when sorting by a field that is not unique, the results are not guaranteed to be in the same order each time.
// So we add a fallback sort to ensure that the results are always in the same order.
let fallbackSort = '-id'
if (createdAt) {
fallbackSort = '-createdAt'
}
if (!(sort.includes(fallbackSort) || sort.includes(fallbackSort.replace('-', '')))) {
sort.push(fallbackSort)
}
for (const sortItem of sort) {
let sortProperty: string
let sortDirection: 'asc' | 'desc'
@@ -92,7 +105,7 @@ export const buildOrderBy = ({
selectFields[sortTableColumnName] = sortTable[sortTableColumnName]
}
} catch (err) {
} catch (_) {
// continue
}
}

View File

@@ -37,7 +37,8 @@ export type BuildQueryResult = {
selectFields: Record<string, GenericColumn>
where: SQL
}
const buildQuery = function buildQuery({
export const buildQuery = function buildQuery({
adapter,
aliasTable,
fields,
@@ -92,5 +93,3 @@ const buildQuery = function buildQuery({
where,
}
}
export default buildQuery

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
@@ -274,7 +280,7 @@ export const getTableColumnFromPath = ({
tableName: newTableName,
value,
})
} catch (error) {
} catch (_) {
// this is fine, not every block will have the field
}
if (!result) {

View File

@@ -15,7 +15,6 @@ import {
notInArray,
or,
type SQL,
type SQLWrapper,
} from 'drizzle-orm'
type OperatorKeys =
@@ -35,7 +34,7 @@ type OperatorKeys =
| 'not_like'
| 'or'
export type Operators = Record<OperatorKeys, (column: Column, value: SQLWrapper | unknown) => SQL>
export type Operators = Record<OperatorKeys, (column: Column, value: unknown) => SQL>
export const operatorMap: Operators = {
and,

View File

@@ -1,5 +1,4 @@
import type { QueryPromise, SQL } from 'drizzle-orm'
import type { PgSelect } from 'drizzle-orm/pg-core'
import type { SQLiteColumn, SQLiteSelect } from 'drizzle-orm/sqlite-core'
import type {

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,
@@ -25,13 +24,18 @@ 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
@@ -72,6 +76,7 @@ type Result = {
export const traverseFields = ({
adapter,
blocksTableNameMap,
columnPrefix,
columns,
disableNotNull,
@@ -250,6 +255,7 @@ export const traverseFields = ({
baseColumns,
baseForeignKeys,
baseIndexes,
blocksTableNameMap,
disableNotNull: disableNotNullFromHere,
disableRelsTableUnique: true,
disableUnique,
@@ -369,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,
@@ -377,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: {
@@ -456,6 +483,7 @@ export const traverseFields = ({
baseColumns,
baseForeignKeys,
baseIndexes,
blocksTableNameMap,
disableNotNull: disableNotNullFromHere,
disableRelsTableUnique: true,
disableUnique,
@@ -506,7 +534,7 @@ export const traverseFields = ({
},
],
references: ['id'],
relationName: `_blocks_${block.slug}`,
relationName,
to: rootTableName,
},
}
@@ -554,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,
@@ -629,6 +649,7 @@ export const traverseFields = ({
hasManyTextField: groupHasManyTextField,
} = traverseFields({
adapter,
blocksTableNameMap,
columnPrefix: `${columnName}_`,
columns,
disableNotNull: disableNotNullFromHere,
@@ -845,6 +866,7 @@ export const traverseFields = ({
baseColumns,
baseForeignKeys,
baseIndexes,
blocksTableNameMap,
disableNotNull,
disableUnique,
fields: [],

View File

@@ -12,7 +12,7 @@ export const commitTransaction: CommitTransaction = async function commitTransac
try {
await this.sessions[id].resolve()
} catch (err: unknown) {
} catch (_) {
await this.sessions[id].reject()
}

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

@@ -7,6 +7,7 @@ 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'
@@ -248,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) {
@@ -327,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(
@@ -665,10 +670,6 @@ export const traverseFields = <T extends Record<string, unknown>>({
withinArrayOrBlockLocale: locale || withinArrayOrBlockLocale,
})
if ('_order' in ref) {
delete ref._order
}
return
}

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

@@ -22,7 +22,7 @@ export const transformRelationship = ({ baseRow, data, field, relationships }: A
if (Array.isArray(field.relationTo) && valueIsValueWithRelation(relation)) {
relationRow[`${relation.relationTo}ID`] = relation.value
relationships.push(relationRow)
} else {
} else if (typeof field.relationTo === 'string') {
relationRow[`${field.relationTo}ID`] = relation
if (relation) {
relationships.push(relationRow)

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

@@ -9,7 +9,7 @@ import { getTransaction } from './utilities/getTransaction.js'
export async function updateGlobal<T extends Record<string, unknown>>(
this: DrizzleAdapter,
{ slug, data, req, select, returning }: UpdateGlobalArgs,
{ slug, data, req, returning, select }: UpdateGlobalArgs,
): Promise<T> {
const db = await getTransaction(this, req)
const globalConfig = this.payload.globals.config.find((config) => config.slug === slug)
@@ -23,10 +23,10 @@ export async function updateGlobal<T extends Record<string, unknown>>(
data,
db,
fields: globalConfig.flattenedFields,
ignoreResult: returning === false,
req,
select,
tableName,
ignoreResult: returning === false,
})
if (returning === false) {

View File

@@ -10,7 +10,7 @@ import toSnakeCase from 'to-snake-case'
import type { DrizzleAdapter } from './types.js'
import buildQuery from './queries/buildQuery.js'
import { buildQuery } from './queries/buildQuery.js'
import { upsertRow } from './upsertRow/index.js'
import { getTransaction } from './utilities/getTransaction.js'
@@ -21,10 +21,10 @@ export async function updateGlobalVersion<T extends TypeWithID>(
global,
locale,
req,
returning,
select,
versionData,
where: whereArg,
returning,
}: UpdateGlobalVersionArgs<T>,
) {
const db = await getTransaction(this, req)
@@ -53,12 +53,12 @@ export async function updateGlobalVersion<T extends TypeWithID>(
data: versionData,
db,
fields,
ignoreResult: returning === false,
operation: 'update',
req,
select,
tableName,
where,
ignoreResult: returning === false,
})
if (returning === false) {

View File

@@ -5,7 +5,7 @@ import toSnakeCase from 'to-snake-case'
import type { DrizzleAdapter } from './types.js'
import buildQuery from './queries/buildQuery.js'
import { buildQuery } from './queries/buildQuery.js'
import { selectDistinct } from './queries/selectDistinct.js'
import { upsertRow } from './upsertRow/index.js'
import { getTransaction } from './utilities/getTransaction.js'

View File

@@ -5,7 +5,7 @@ import toSnakeCase from 'to-snake-case'
import type { DrizzleAdapter } from './types.js'
import buildQuery from './queries/buildQuery.js'
import { buildQuery } from './queries/buildQuery.js'
import { selectDistinct } from './queries/selectDistinct.js'
import { upsertRow } from './upsertRow/index.js'
import { getTransaction } from './utilities/getTransaction.js'

View File

@@ -10,7 +10,7 @@ import toSnakeCase from 'to-snake-case'
import type { DrizzleAdapter } from './types.js'
import buildQuery from './queries/buildQuery.js'
import { buildQuery } from './queries/buildQuery.js'
import { upsertRow } from './upsertRow/index.js'
import { getTransaction } from './utilities/getTransaction.js'
@@ -21,10 +21,10 @@ export async function updateVersion<T extends TypeWithID>(
collection,
locale,
req,
returning,
select,
versionData,
where: whereArg,
returning,
}: UpdateVersionArgs<T>,
) {
const db = await getTransaction(this, req)
@@ -50,13 +50,13 @@ export async function updateVersion<T extends TypeWithID>(
data: versionData,
db,
fields,
ignoreResult: returning === false,
joinQuery: false,
operation: 'update',
req,
select,
tableName,
where,
ignoreResult: returning === false,
})
if (returning === false) {

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

@@ -46,7 +46,7 @@ export const insertArrays = async ({
// Add any sub arrays that need to be created
// We will call this recursively below
arrayRows.forEach((arrayRow, i) => {
arrayRows.forEach((arrayRow) => {
if (Object.keys(arrayRow.arrays).length > 0) {
rowsByTable[tableName].arrays.push(arrayRow.arrays)
}

View File

@@ -12,7 +12,7 @@ type BaseArgs = {
* When true, skips reading the data back from the database and returns the input data
* @default false
*/
ignoreResult?: boolean | 'idOnly'
ignoreResult?: 'idOnly' | boolean
joinQuery?: JoinQuery
path?: string
req?: Partial<PayloadRequest>

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

@@ -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,18 +0,0 @@
import { rootEslintConfig, rootParserOptions } from '../../eslint.config.js'
/** @typedef {import('eslint').Linter.Config} Config */
/** @type {Config[]} */
export const index = [
...rootEslintConfig,
{
languageOptions: {
parserOptions: {
...rootParserOptions,
tsconfigRootDir: import.meta.dirname,
},
},
},
]
export default index

View File

@@ -1,18 +0,0 @@
import { rootEslintConfig, rootParserOptions } from '../../eslint.config.js'
/** @typedef {import('eslint').Linter.Config} Config */
/** @type {Config[]} */
export const index = [
...rootEslintConfig,
{
languageOptions: {
parserOptions: {
...rootParserOptions,
tsconfigRootDir: import.meta.dirname,
},
},
},
]
export default index

View File

@@ -10,6 +10,7 @@ import globals from 'globals'
import importX from 'eslint-plugin-import-x'
import typescriptParser from '@typescript-eslint/parser'
import { deepMerge } from './deepMerge.js'
import reactCompiler from 'eslint-plugin-react-compiler'
const baseRules = {
// This rule makes no sense when overriding class methods. This is used a lot in richtext-lexical.
@@ -125,6 +126,52 @@ export const rootEslintConfig = [
ecmaFeatures: {
jsx: true,
},
projectService: {
// This is necessary because `tsconfig.base.json` defines `"rootDir": "${configDir}/src"`,
// And the following files aren't in src because they aren't transpiled.
// This is typescript-eslint's way of adding files that aren't included in tsconfig.
// See: https://typescript-eslint.io/troubleshooting/typed-linting/#i-get-errors-telling-me--was-not-found-by-the-project-service-consider-either-including-it-in-the-tsconfigjson-or-including-it-in-allowdefaultproject
// The best practice is to have a tsconfig.json that covers ALL files and is used for
// typechecking (with noEmit), and a `tsconfig.build.json` that is used for the build
// (or alternatively, swc, tsup or tsdown). That's what we should ideally do, in which case
// this hardcoded list wouldn't be necessary. Note that these files don't currently go
// through ts, only through eslint.
allowDefaultProject: [
'../payload/bin.js',
'../payload/bundle.js',
'../next/babel.config.cjs',
'../next/bundleScss.js',
'../ui/babel.config.cjs',
'../ui/bundle.js',
'../graphql/bin.js',
'../richtext-lexical/babel.config.cjs',
'../richtext-lexical/bundle.js',
'../richtext-lexical/scripts/translateNewKeys.ts',
'../db-postgres/bundle.js',
'../db-postgres/relationships-v2-v3.mjs',
'../db-postgres/scripts/renamePredefinedMigrations.ts',
'../db-sqlite/bundle.js',
'../db-vercel-postgres/relationships-v2-v3.mjs',
'../db-vercel-postgres/scripts/renamePredefinedMigrations.ts',
'../plugin-cloud-storage/azure.d.ts',
'../plugin-cloud-storage/azure.js',
'../plugin-cloud-storage/gcs.d.ts',
'../plugin-cloud-storage/gcs.js',
'../plugin-cloud-storage/s3.d.ts',
'../plugin-cloud-storage/s3.js',
'../plugin-redirects/types.d.ts',
'../plugin-redirects/types.js',
'../translations/scripts/translateNewKeys/applyEslintFixes.ts',
'../translations/scripts/translateNewKeys/findMissingKeys.ts',
'../translations/scripts/translateNewKeys/generateTsObjectLiteral.ts',
'../translations/scripts/translateNewKeys/index.ts',
'../translations/scripts/translateNewKeys/run.ts',
'../translations/scripts/translateNewKeys/sortKeys.ts',
'../translations/scripts/translateNewKeys/translateText.ts',
'../create-payload-app/bin/cli.js',
],
},
tsconfigRootDir: import.meta.dirname,
},
ecmaVersion: 'latest',
sourceType: 'module',
@@ -206,6 +253,10 @@ export const rootEslintConfig = [
},
files: ['*.config.ts', 'config.ts'],
},
{
name: 'React Compiler',
...reactCompiler.configs.recommended,
},
]
export default rootEslintConfig

View File

@@ -36,6 +36,7 @@
"eslint-plugin-jest-dom": "5.5.0",
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-perfectionist": "3.9.1",
"eslint-plugin-react-compiler": "19.0.0-beta-e993439-20250405",
"eslint-plugin-react-hooks": "0.0.0-experimental-d331ba04-20250307",
"eslint-plugin-regexp": "2.7.0",
"globals": "16.0.0",

View File

@@ -1,18 +0,0 @@
import { rootEslintConfig, rootParserOptions } from '../../eslint.config.js'
/** @typedef {import('eslint').Linter.Config} Config */
/** @type {Config[]} */
export const index = [
...rootEslintConfig,
{
languageOptions: {
parserOptions: {
...rootParserOptions,
tsconfigRootDir: import.meta.dirname,
},
},
},
]
export default index

View File

@@ -17,7 +17,7 @@ export const recursivelyBuildNestedPaths = ({ field, nestedFieldName2, parentNam
if (field.type === 'tabs') {
// if the tab has a name, treat it as a group
// otherwise, treat it as a row
return (field.tabs as Tab[]).reduce((tabSchema, tab: any) => {
return field.tabs.reduce((tabSchema, tab: any) => {
tabSchema.push(
...recursivelyBuildNestedPaths({
field: {

View File

@@ -1,18 +0,0 @@
import { rootEslintConfig, rootParserOptions } from '../../eslint.config.js'
/** @typedef {import('eslint').Linter.Config} Config */
/** @type {Config[]} */
export const index = [
...rootEslintConfig,
{
languageOptions: {
parserOptions: {
...rootParserOptions,
tsconfigRootDir: import.meta.dirname,
},
},
},
]
export default index

View File

@@ -1,18 +0,0 @@
import { rootEslintConfig, rootParserOptions } from '../../eslint.config.js'
/** @typedef {import('eslint').Linter.Config} Config */
/** @type {Config[]} */
export const index = [
...rootEslintConfig,
{
languageOptions: {
parserOptions: {
...rootParserOptions,
tsconfigRootDir: import.meta.dirname,
},
},
},
]
export default index

View File

@@ -1,18 +0,0 @@
import { rootEslintConfig, rootParserOptions } from '../../eslint.config.js'
/** @typedef {import('eslint').Linter.Config} Config */
/** @type {Config[]} */
export const index = [
...rootEslintConfig,
{
languageOptions: {
parserOptions: {
...rootParserOptions,
tsconfigRootDir: import.meta.dirname,
},
},
},
]
export default index

View File

@@ -1,6 +1,4 @@
import { rootEslintConfig, rootParserOptions } from '../../eslint.config.js'
import reactCompiler from 'eslint-plugin-react-compiler'
const { rules } = reactCompiler
/** @typedef {import('eslint').Linter.Config} Config */
@@ -8,28 +6,12 @@ const { rules } = reactCompiler
export const index = [
...rootEslintConfig,
{
languageOptions: {
parserOptions: {
...rootParserOptions,
tsconfigRootDir: import.meta.dirname,
},
},
settings: {
next: {
rootDir: '../../app/',
},
},
},
{
plugins: {
'react-compiler': {
rules,
},
},
rules: {
'react-compiler/react-compiler': 'error',
},
},
]
export default index

View File

@@ -118,7 +118,6 @@
"babel-plugin-react-compiler": "19.0.0-beta-e993439-20250405",
"esbuild": "0.24.2",
"esbuild-sass-plugin": "3.3.1",
"eslint-plugin-react-compiler": "19.0.0-beta-e993439-20250405",
"payload": "workspace:*",
"swc-plugin-transform-remove-imports": "3.1.0"
},

View File

@@ -1,7 +1,7 @@
'use client'
import type { SanitizedConfig } from 'payload'
import { Link } from '@payloadcms/ui'
import { Button } from '@payloadcms/ui'
import { useParams, usePathname, useSearchParams } from 'next/navigation.js'
import { formatAdminURL } from 'payload/shared'
import React from 'react'
@@ -13,7 +13,6 @@ export const DocumentTabLink: React.FC<{
children?: React.ReactNode
href: string
isActive?: boolean
isCollection?: boolean
newTab?: boolean
}> = ({
adminRoute,
@@ -54,19 +53,17 @@ export const DocumentTabLink: React.FC<{
isActiveFromProps
return (
<li
<Button
aria-label={ariaLabel}
buttonStyle="tab"
className={[baseClass, isActive && `${baseClass}--active`].filter(Boolean).join(' ')}
disabled={isActive}
el={!isActive || href !== pathname ? 'link' : 'div'}
newTab={newTab}
size="medium"
to={!isActive || href !== pathname ? hrefWithLocale : undefined}
>
<Link
className={`${baseClass}__link`}
href={!isActive || href !== pathname ? hrefWithLocale : ''}
prefetch={false}
{...(newTab && { rel: 'noopener noreferrer', target: '_blank' })}
tabIndex={isActive ? -1 : 0}
>
{children}
</Link>
</li>
{children}
</Button>
)
}

View File

@@ -1,74 +1,24 @@
@import '../../../../scss/styles.scss';
@layer payload-default {
.doc-tab {
@extend %h5;
position: relative;
&__link {
text-decoration: none;
display: flex;
justify-content: center;
align-items: center;
white-space: nowrap;
// Use a pseudo element for the accessability so that it doesn't take up DOM space
// Also because the parent element has `overflow: hidden` which would clip an outline
&:focus-visible::after {
content: '';
border: var(--accessibility-outline);
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
pointer-events: none;
}
}
&:focus:not(:focus-visible) {
opacity: 1;
}
&::before {
content: '';
display: block;
position: absolute;
width: 100%;
height: 100%;
border-radius: var(--style-radius-s);
background-color: var(--theme-elevation-50);
opacity: 0;
}
display: flex;
justify-content: center;
align-items: center;
white-space: nowrap;
&:hover {
&::before {
opacity: 1;
}
.doc-tab__count {
.pill-version-count {
background-color: var(--theme-elevation-150);
}
}
&--active {
font-weight: 600;
&::before {
opacity: 1;
background-color: var(--theme-elevation-100);
}
.doc-tab {
&__count {
background-color: var(--theme-elevation-250);
}
.pill-version-count {
background-color: var(--theme-elevation-250);
}
&:hover {
.doc-tab {
&__count {
background-color: var(--theme-elevation-250);
}
.pill-version-count {
background-color: var(--theme-elevation-250);
}
}
}
@@ -80,16 +30,7 @@
gap: 4px;
width: 100%;
height: 100%;
line-height: base(1.2);
padding: base(0.2) base(0.6);
}
&__count {
line-height: base(0.8);
min-width: base(0.8);
text-align: center;
background-color: var(--theme-elevation-100);
border-radius: var(--style-radius-s);
line-height: calc(var(--base) * 1.2);
}
}
}

View File

@@ -68,7 +68,6 @@ export const DocumentTab: React.FC<
baseClass={baseClass}
href={href}
isActive={isActive}
isCollection={!!collectionConfig && !globalConfig}
newTab={newTab}
>
<span className={`${baseClass}__label`}>

View File

@@ -0,0 +1,9 @@
@layer payload-default {
.pill-version-count {
line-height: calc(var(--base) * 0.8);
min-width: calc(var(--base) * 0.8);
text-align: center;
background-color: var(--theme-elevation-100);
border-radius: var(--style-radius-s);
}
}

View File

@@ -2,7 +2,9 @@
import { useDocumentInfo } from '@payloadcms/ui'
import React from 'react'
import { baseClass } from '../../Tab/index.js'
import './index.scss'
const baseClass = 'pill-version-count'
export const VersionsPill: React.FC = () => {
const { versionCount } = useDocumentInfo()
@@ -11,5 +13,5 @@ export const VersionsPill: React.FC = () => {
return null
}
return <span className={`${baseClass}__count`}>{versionCount}</span>
return <span className={baseClass}>{versionCount}</span>
}

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

@@ -4,7 +4,7 @@ import type { groupNavItems } from '@payloadcms/ui/shared'
import type { NavPreferences } from 'payload'
import { getTranslation } from '@payloadcms/translations'
import { Link, NavGroup, useConfig, useTranslation } from '@payloadcms/ui'
import { BrowseByFolderButton, Link, NavGroup, useConfig, useTranslation } from '@payloadcms/ui'
import { EntityType } from '@payloadcms/ui/shared'
import { usePathname } from 'next/navigation.js'
import { formatAdminURL } from 'payload/shared'
@@ -20,14 +20,35 @@ export const DefaultNavClient: React.FC<{
const {
config: {
admin: {
routes: { browseByFolder: foldersRoute },
},
collections,
routes: { admin: adminRoute },
},
} = useConfig()
const [folderCollectionSlugs] = React.useState<string[]>(() => {
return collections.reduce<string[]>((acc, collection) => {
if (collection.admin.folders) {
acc.push(collection.slug)
}
return acc
}, [])
})
const { i18n } = useTranslation()
const folderURL = formatAdminURL({
adminRoute,
path: foldersRoute,
})
const viewingRootFolderView = pathname.startsWith(folderURL)
return (
<Fragment>
{folderCollectionSlugs.length > 0 && <BrowseByFolderButton active={viewingRootFolderView} />}
{groups.map(({ entities, label }, key) => {
return (
<NavGroup isOpen={navPreferences?.groups?.[label]?.open} key={key} label={label}>

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

@@ -0,0 +1,161 @@
import type {
AdminViewServerProps,
BuildCollectionFolderViewResult,
FolderListViewServerPropsOnly,
ListQuery,
} from 'payload'
import { DefaultBrowseByFolderView, FolderProvider, HydrateAuthProvider } from '@payloadcms/ui'
import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
import { formatAdminURL } from '@payloadcms/ui/shared'
import { redirect } from 'next/navigation.js'
import { getFolderData } from 'payload'
import React from 'react'
import { getPreferences } from '../../utilities/getPreferences.js'
export type BuildFolderViewArgs = {
customCellProps?: Record<string, any>
disableBulkDelete?: boolean
disableBulkEdit?: boolean
enableRowSelections: boolean
folderID?: number | string
isInDrawer?: boolean
overrideEntityVisibility?: boolean
query: ListQuery
} & AdminViewServerProps
export const buildBrowseByFolderView = async (
args: BuildFolderViewArgs,
): Promise<BuildCollectionFolderViewResult> => {
const {
disableBulkDelete,
disableBulkEdit,
enableRowSelections,
folderCollectionSlugs,
folderID,
initPageResult,
isInDrawer,
params,
query: queryFromArgs,
searchParams,
} = args
const {
locale: fullLocale,
permissions,
req: {
i18n,
payload,
payload: { config },
query: queryFromReq,
user,
},
visibleEntities,
} = initPageResult
const collections = folderCollectionSlugs.filter(
(collectionSlug) =>
permissions?.collections?.[collectionSlug]?.read &&
visibleEntities.collections.includes(collectionSlug),
)
if (!collections.length) {
throw new Error('not-found')
}
const query = queryFromArgs || queryFromReq
const selectedCollectionSlugs: string[] =
Array.isArray(query?.relationTo) && query.relationTo.length
? query.relationTo
: [...folderCollectionSlugs, config.folders.slug]
const {
routes: { admin: adminRoute },
} = config
const { breadcrumbs, documents, subfolders } = await getFolderData({
folderID,
payload: initPageResult.req.payload,
search: query?.search as string,
user: initPageResult.req.user,
})
const resolvedFolderID = breadcrumbs[breadcrumbs.length - 1]?.id
if (
!isInDrawer &&
((resolvedFolderID && folderID && folderID !== resolvedFolderID) ||
(folderID && !resolvedFolderID))
) {
return redirect(
formatAdminURL({
adminRoute,
path: config.admin.routes.browseByFolder,
serverURL: config.serverURL,
}),
)
}
const browseByFolderPreferences = await getPreferences<{ viewPreference: string }>(
'browse-by-folder',
payload,
user.id,
user.collection,
)
const serverProps: Omit<FolderListViewServerPropsOnly, 'collectionConfig' | 'listPreferences'> = {
documents,
i18n,
locale: fullLocale,
params,
payload,
permissions,
searchParams,
subfolders,
user,
}
// const folderViewSlots = renderFolderViewSlots({
// clientProps: {
// },
// description: staticDescription,
// payload,
// serverProps,
// })
// documents cannot be created without a parent folder in this view
const hasCreatePermissionCollectionSlugs = folderID
? [config.folders.slug, ...folderCollectionSlugs]
: [config.folders.slug]
return {
View: (
<FolderProvider
breadcrumbs={breadcrumbs}
documents={documents}
filteredCollectionSlugs={selectedCollectionSlugs}
folderCollectionSlugs={folderCollectionSlugs}
folderID={folderID}
subfolders={subfolders}
>
<HydrateAuthProvider permissions={permissions} />
{RenderServerComponent({
clientProps: {
// ...folderViewSlots,
disableBulkDelete,
disableBulkEdit,
enableRowSelections,
hasCreatePermissionCollectionSlugs,
selectedCollectionSlugs,
viewPreference: browseByFolderPreferences?.value?.viewPreference,
},
// Component:config.folders?.components?.views?.list?.Component,
Fallback: DefaultBrowseByFolderView,
importMap: payload.importMap,
serverProps,
})}
</FolderProvider>
),
}
}

View File

@@ -0,0 +1,20 @@
import type React from 'react'
import { notFound } from 'next/navigation.js'
import type { BuildFolderViewArgs } from './buildView.js'
import { buildBrowseByFolderView } from './buildView.js'
export const BrowseByFolder: React.FC<BuildFolderViewArgs> = async (args) => {
try {
const { View } = await buildBrowseByFolderView(args)
return View
} catch (error) {
if (error.message === 'not-found') {
notFound()
} else {
console.error(error) // eslint-disable-line no-console
}
}
}

View File

@@ -0,0 +1,23 @@
import type { Metadata } from 'next'
import type { GenerateViewMetadata } from '../Root/index.js'
import { generateMetadata } from '../../utilities/meta.js'
export const generateBrowseByFolderMetadata = async (
args: Parameters<GenerateViewMetadata>[0],
): Promise<Metadata> => {
const { config, i18n } = args
const title: string = i18n.t('folder:browseByFolder')
const description: string = ''
const keywords: string = ''
return generateMetadata({
...(config.admin.meta || {}),
description,
keywords,
serverURL: config.serverURL,
title,
})
}

View File

@@ -0,0 +1,207 @@
import type {
AdminViewServerProps,
BuildCollectionFolderViewResult,
FolderListViewServerPropsOnly,
ListQuery,
Where,
} from 'payload'
import { DefaultCollectionFolderView, FolderProvider, HydrateAuthProvider } from '@payloadcms/ui'
import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
import { formatAdminURL, mergeListSearchAndWhere } from '@payloadcms/ui/shared'
import { redirect } from 'next/navigation.js'
import { getFolderData, parseDocumentID } from 'payload'
import React from 'react'
import { getPreferences } from '../../utilities/getPreferences.js'
// import { renderFolderViewSlots } from './renderFolderViewSlots.js'
export type BuildCollectionFolderViewStateArgs = {
disableBulkDelete?: boolean
disableBulkEdit?: boolean
enableRowSelections: boolean
folderID?: number | string
isInDrawer?: boolean
overrideEntityVisibility?: boolean
query: ListQuery
} & AdminViewServerProps
/**
* Builds the entire view for collection-folder views on the server
*/
export const buildCollectionFolderView = async (
args: BuildCollectionFolderViewStateArgs,
): Promise<BuildCollectionFolderViewResult> => {
const {
disableBulkDelete,
disableBulkEdit,
enableRowSelections,
folderCollectionSlugs,
folderID,
initPageResult,
isInDrawer,
overrideEntityVisibility,
params,
query: queryFromArgs,
searchParams,
} = args
const {
collectionConfig,
collectionConfig: { slug: collectionSlug },
locale: fullLocale,
permissions,
req: {
i18n,
payload,
payload: { config },
query: queryFromReq,
user,
},
visibleEntities,
} = initPageResult
if (!permissions?.collections?.[collectionSlug]?.read) {
throw new Error('not-found')
}
if (collectionConfig) {
const query = queryFromArgs || queryFromReq
const collectionFolderPreferences = await getPreferences<{ viewPreference: string }>(
`${collectionSlug}-collection-folder`,
payload,
user.id,
user.collection,
)
const {
routes: { admin: adminRoute },
} = config
if (
(!visibleEntities.collections.includes(collectionSlug) && !overrideEntityVisibility) ||
!folderCollectionSlugs.includes(collectionSlug)
) {
throw new Error('not-found')
}
const whereConstraints = [
mergeListSearchAndWhere({
collectionConfig,
search: typeof query?.search === 'string' ? query.search : undefined,
where: (query?.where as Where) || undefined,
}),
]
if (folderID) {
whereConstraints.push({
[config.folders.fieldName]: {
equals: parseDocumentID({ id: folderID, collectionSlug, payload }),
},
})
} else {
whereConstraints.push({
[config.folders.fieldName]: {
exists: false,
},
})
}
const { breadcrumbs, documents, subfolders } = await getFolderData({
collectionSlug,
folderID,
payload: initPageResult.req.payload,
search: query?.search as string,
user: initPageResult.req.user,
})
const resolvedFolderID = breadcrumbs[breadcrumbs.length - 1]?.id
if (
!isInDrawer &&
((resolvedFolderID && folderID && folderID !== resolvedFolderID) ||
(folderID && !resolvedFolderID))
) {
return redirect(
formatAdminURL({
adminRoute,
path: `/collections/${collectionSlug}/${config.folders.slug}`,
serverURL: config.serverURL,
}),
)
}
const newDocumentURL = formatAdminURL({
adminRoute,
path: `/collections/${collectionSlug}/create`,
})
const hasCreatePermission = permissions?.collections?.[collectionSlug]?.create
const serverProps: FolderListViewServerPropsOnly = {
collectionConfig,
documents,
i18n,
locale: fullLocale,
params,
payload,
permissions,
searchParams,
subfolders,
user,
}
// We could support slots in the folder view in the future
// const folderViewSlots = renderFolderViewSlots({
// clientProps: {
// collectionSlug,
// hasCreatePermission,
// newDocumentURL,
// },
// collectionConfig,
// description: typeof collectionConfig.admin.description === 'function'
// ? collectionConfig.admin.description({ t: i18n.t })
// : collectionConfig.admin.description,
// payload,
// serverProps,
// })
const search = query?.search as string
return {
View: (
<FolderProvider
breadcrumbs={breadcrumbs}
collectionSlug={collectionSlug}
documents={documents}
folderCollectionSlugs={folderCollectionSlugs}
folderID={folderID}
search={search}
subfolders={subfolders}
>
<HydrateAuthProvider permissions={permissions} />
{RenderServerComponent({
clientProps: {
// ...folderViewSlots,
collectionSlug,
disableBulkDelete,
disableBulkEdit,
enableRowSelections,
hasCreatePermission,
newDocumentURL,
viewPreference: collectionFolderPreferences?.value?.viewPreference,
},
Component: collectionConfig?.admin?.components?.views?.list?.Component,
Fallback: DefaultCollectionFolderView,
importMap: payload.importMap,
serverProps,
})}
</FolderProvider>
),
}
}
throw new Error('not-found')
}

View File

@@ -0,0 +1,20 @@
import type React from 'react'
import { notFound } from 'next/navigation.js'
import type { BuildCollectionFolderViewStateArgs } from './buildView.js'
import { buildCollectionFolderView } from './buildView.js'
export const CollectionFolderView: React.FC<BuildCollectionFolderViewStateArgs> = async (args) => {
try {
const { View } = await buildCollectionFolderView(args)
return View
} catch (error) {
if (error.message === 'not-found') {
notFound()
} else {
console.error(error) // eslint-disable-line no-console
}
}
}

View File

@@ -0,0 +1,35 @@
import type { Metadata } from 'next'
import type { SanitizedCollectionConfig } from 'payload'
import { getTranslation } from '@payloadcms/translations'
import type { GenerateViewMetadata } from '../Root/index.js'
import { generateMetadata } from '../../utilities/meta.js'
export const generateCollectionFolderMetadata = async (
args: {
collectionConfig: SanitizedCollectionConfig
} & Parameters<GenerateViewMetadata>[0],
): Promise<Metadata> => {
const { collectionConfig, config, i18n } = args
let title: string = ''
const description: string = ''
const keywords: string = ''
if (collectionConfig) {
title = getTranslation(collectionConfig.labels.singular, i18n)
}
title = `${title ? `${title} ` : title}${i18n.t('folder:folders')}`
return generateMetadata({
...(config.admin.meta || {}),
description,
keywords,
serverURL: config.serverURL,
title,
...(collectionConfig?.admin?.meta || {}),
})
}

View File

@@ -0,0 +1,99 @@
import type {
AfterFolderListClientProps,
AfterFolderListTableClientProps,
AfterFolderListTableServerPropsOnly,
BeforeFolderListClientProps,
BeforeFolderListServerPropsOnly,
BeforeFolderListTableClientProps,
BeforeFolderListTableServerPropsOnly,
FolderListViewServerPropsOnly,
FolderListViewSlots,
ListViewSlotSharedClientProps,
Payload,
SanitizedCollectionConfig,
StaticDescription,
ViewDescriptionClientProps,
ViewDescriptionServerPropsOnly,
} from 'payload'
import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
type Args = {
clientProps: ListViewSlotSharedClientProps
collectionConfig: SanitizedCollectionConfig
description?: StaticDescription
payload: Payload
serverProps: FolderListViewServerPropsOnly
}
export const renderFolderViewSlots = ({
clientProps,
collectionConfig,
description,
payload,
serverProps,
}: Args): FolderListViewSlots => {
const result: FolderListViewSlots = {} as FolderListViewSlots
if (collectionConfig.admin.components?.afterList) {
result.AfterFolderList = RenderServerComponent({
clientProps: clientProps satisfies AfterFolderListClientProps,
Component: collectionConfig.admin.components.afterList,
importMap: payload.importMap,
serverProps: serverProps satisfies AfterFolderListTableServerPropsOnly,
})
}
const listMenuItems = collectionConfig.admin.components?.listMenuItems
if (Array.isArray(listMenuItems)) {
result.listMenuItems = [
RenderServerComponent({
clientProps,
Component: listMenuItems,
importMap: payload.importMap,
serverProps,
}),
]
}
if (collectionConfig.admin.components?.afterListTable) {
result.AfterFolderListTable = RenderServerComponent({
clientProps: clientProps satisfies AfterFolderListTableClientProps,
Component: collectionConfig.admin.components.afterListTable,
importMap: payload.importMap,
serverProps: serverProps satisfies AfterFolderListTableServerPropsOnly,
})
}
if (collectionConfig.admin.components?.beforeList) {
result.BeforeFolderList = RenderServerComponent({
clientProps: clientProps satisfies BeforeFolderListClientProps,
Component: collectionConfig.admin.components.beforeList,
importMap: payload.importMap,
serverProps: serverProps satisfies BeforeFolderListServerPropsOnly,
})
}
if (collectionConfig.admin.components?.beforeListTable) {
result.BeforeFolderListTable = RenderServerComponent({
clientProps: clientProps satisfies BeforeFolderListTableClientProps,
Component: collectionConfig.admin.components.beforeListTable,
importMap: payload.importMap,
serverProps: serverProps satisfies BeforeFolderListTableServerPropsOnly,
})
}
if (collectionConfig.admin.components?.Description) {
result.Description = RenderServerComponent({
clientProps: {
collectionSlug: collectionConfig.slug,
description,
} satisfies ViewDescriptionClientProps,
Component: collectionConfig.admin.components.Description,
importMap: payload.importMap,
serverProps: serverProps satisfies ViewDescriptionServerPropsOnly,
})
}
return result
}

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

@@ -32,9 +32,12 @@ import { renderDocumentSlots } from './renderDocumentSlots.js'
export const generateMetadata: GenerateEditViewMetadata = async (args) => getMetaBySegment(args)
// This function will be responsible for rendering an Edit Document view
// it will be called on the server for Edit page views as well as
// called on-demand from document drawers
/**
* This function is responsible for rendering
* an Edit Document view on the server for both:
* - default document edit views
* - on-demand edit views within drawers
*/
export const renderDocument = async ({
disableActions,
documentSubViewType,

View File

@@ -40,6 +40,12 @@ type RenderListViewArgs = {
redirectAfterDuplicate?: boolean
} & AdminViewServerProps
/**
* This function is responsible for rendering
* the list view on the server for both:
* - default list view
* - list view within drawers
*/
export const renderListView = async (
args: RenderListViewArgs,
): Promise<{

View File

@@ -16,7 +16,9 @@ export const DeviceContainer: React.FC<{
// Keep an accurate measurement of the actual device size as it is truly rendered
// This is helpful when `sizes` are non-number units like percentages, etc.
// eslint-disable-next-line react-compiler/react-compiler -- TODO: fix
const { size: measuredDeviceSize } = useResize(deviceFrameRef.current)
// eslint-disable-next-line react-compiler/react-compiler -- TODO: fix
const { size: outerFrameSize } = useResize(outerFrameRef.current)
let deviceIsLargerThanFrame: boolean = false

View File

@@ -1,6 +1,6 @@
import type { AdminViewConfig, SanitizedConfig } from 'payload'
import type { ViewFromConfig } from './getViewFromConfig.js'
import type { ViewFromConfig } from './getRouteData.js'
import { isPathMatchingRoute } from './isPathMatchingRoute.js'

View File

@@ -1,5 +1,6 @@
import type {
AdminViewServerProps,
CollectionSlug,
DocumentSubViewTypes,
ImportMap,
PayloadComponent,
@@ -14,6 +15,8 @@ import { formatAdminURL } from 'payload/shared'
import type { initPage } from '../../utilities/initPage/index.js'
import { Account } from '../Account/index.js'
import { BrowseByFolder } from '../BrowseByFolder/index.js'
import { CollectionFolderView } from '../CollectionFolders/index.js'
import { CreateFirstUserView } from '../CreateFirstUser/index.js'
import { Dashboard } from '../Dashboard/index.js'
import { Document as DocumentView } from '../Document/index.js'
@@ -31,6 +34,7 @@ import { isPathMatchingRoute } from './isPathMatchingRoute.js'
const baseClasses = {
account: 'account',
folders: 'folders',
forgot: forgotPasswordBaseClass,
login: loginBaseClass,
reset: resetPasswordBaseClass,
@@ -48,6 +52,7 @@ export type ViewFromConfig = {
const oneSegmentViews: OneSegmentViews = {
account: Account,
browseByFolder: BrowseByFolder,
createFirstUser: CreateFirstUserView,
forgot: ForgotPasswordView,
inactivity: LogoutInactivity,
@@ -56,7 +61,7 @@ const oneSegmentViews: OneSegmentViews = {
unauthorized: UnauthorizedView,
}
type GetViewFromConfigArgs = {
type GetRouteDataArgs = {
adminRoute: string
config: SanitizedConfig
currentRoute: string
@@ -67,9 +72,11 @@ type GetViewFromConfigArgs = {
segments: string[]
}
type GetViewFromConfigResult = {
type GetRouteDataResult = {
DefaultView: ViewFromConfig
documentSubViewType?: DocumentSubViewTypes
folderCollectionSlugs: CollectionSlug[]
folderID?: string
initPageOptions: Parameters<typeof initPage>[0]
serverProps: ServerPropsFromView
templateClassName: string
@@ -77,19 +84,20 @@ type GetViewFromConfigResult = {
viewType?: ViewTypes
}
export const getViewFromConfig = ({
export const getRouteData = ({
adminRoute,
config,
currentRoute,
importMap,
searchParams,
segments,
}: GetViewFromConfigArgs): GetViewFromConfigResult => {
}: GetRouteDataArgs): GetRouteDataResult => {
let ViewToRender: ViewFromConfig = null
let templateClassName: string
let templateType: 'default' | 'minimal' | undefined
let documentSubViewType: DocumentSubViewTypes
let viewType: ViewTypes
let folderID: string
const initPageOptions: Parameters<typeof initPage>[0] = {
config,
@@ -105,6 +113,13 @@ export const getViewFromConfig = ({
let matchedCollection: SanitizedConfig['collections'][number] = undefined
let matchedGlobal: SanitizedConfig['globals'][number] = undefined
const folderCollectionSlugs = config.collections.reduce((acc, { slug, admin }) => {
if (admin?.folders) {
return [...acc, slug]
}
return acc
}, [])
const serverProps: ServerPropsFromView = {
viewActions: config?.admin?.components?.actions || [],
}
@@ -153,6 +168,7 @@ export const getViewFromConfig = ({
if (oneSegmentViews[viewKey]) {
// --> /account
// --> /create-first-user
// --> /browse-by-folder
// --> /forgot
// --> /login
// --> /logout
@@ -170,6 +186,11 @@ export const getViewFromConfig = ({
templateType = 'default'
viewType = 'account'
}
if (folderCollectionSlugs.length && viewKey === 'browseByFolder') {
templateType = 'default'
viewType = 'folders'
}
}
break
}
@@ -182,9 +203,19 @@ export const getViewFromConfig = ({
templateClassName = baseClasses[segmentTwo]
templateType = 'minimal'
viewType = 'reset'
}
if (isCollection && matchedCollection) {
} else if (
folderCollectionSlugs.length &&
`/${segmentOne}` === config.admin.routes.browseByFolder
) {
// --> /browse-by-folder/:folderID
ViewToRender = {
Component: oneSegmentViews.browseByFolder,
}
templateClassName = baseClasses.folders
templateType = 'default'
viewType = 'folders'
folderID = segmentTwo
} else if (isCollection && matchedCollection) {
// --> /collections/:collectionSlug
ViewToRender = {
@@ -229,31 +260,47 @@ export const getViewFromConfig = ({
templateType = 'minimal'
viewType = 'verify'
} else if (isCollection && matchedCollection) {
// Custom Views
// --> /collections/:collectionSlug/:id
// --> /collections/:collectionSlug/:id/api
// --> /collections/:collectionSlug/:id/preview
// --> /collections/:collectionSlug/:id/versions
// --> /collections/:collectionSlug/:id/versions/:versionID
if (
segmentThree === config.folders.slug &&
folderCollectionSlugs.includes(matchedCollection.slug)
) {
// Collection Folder Views
// --> /collections/:collectionSlug/:folderCollectionSlug
// --> /collections/:collectionSlug/:folderCollectionSlug/:folderID
ViewToRender = {
Component: CollectionFolderView,
}
ViewToRender = {
Component: DocumentView,
templateClassName = `collection-folders`
templateType = 'default'
viewType = 'collection-folders'
folderID = segmentFour
} else {
// Collection Edit Views
// --> /collections/:collectionSlug/:id
// --> /collections/:collectionSlug/:id/api
// --> /collections/:collectionSlug/:id/preview
// --> /collections/:collectionSlug/:id/versions
// --> /collections/:collectionSlug/:id/versions/:versionID
ViewToRender = {
Component: DocumentView,
}
templateClassName = `collection-default-edit`
templateType = 'default'
const viewInfo = getDocumentViewInfo([segmentFour, segmentFive])
viewType = viewInfo.viewType
documentSubViewType = viewInfo.documentSubViewType
attachViewActions({
collectionOrGlobal: matchedCollection,
serverProps,
viewKeyArg: documentSubViewType,
})
}
templateClassName = `collection-default-edit`
templateType = 'default'
const viewInfo = getDocumentViewInfo([segmentFour, segmentFive])
viewType = viewInfo.viewType
documentSubViewType = viewInfo.documentSubViewType
attachViewActions({
collectionOrGlobal: matchedCollection,
serverProps,
viewKeyArg: documentSubViewType,
})
} else if (isGlobal && matchedGlobal) {
// Custom Views
// Global Edit Views
// --> /globals/:globalSlug/versions
// --> /globals/:globalSlug/preview
// --> /globals/:globalSlug/versions/:versionID
@@ -288,6 +335,8 @@ export const getViewFromConfig = ({
return {
DefaultView: ViewToRender,
documentSubViewType,
folderCollectionSlugs,
folderID,
initPageOptions,
serverProps,
templateClassName,

View File

@@ -1,22 +1,23 @@
import type { I18nClient } from '@payloadcms/translations'
import type { Metadata } from 'next'
import type {
AdminViewClientProps,
AdminViewServerPropsOnly,
ImportMap,
SanitizedConfig,
} from 'payload'
import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
import { getClientConfig } from '@payloadcms/ui/utilities/getClientConfig'
import { notFound, redirect } from 'next/navigation.js'
import {
type AdminViewClientProps,
type AdminViewServerPropsOnly,
type ImportMap,
parseDocumentID,
type SanitizedConfig,
} from 'payload'
import { formatAdminURL } from 'payload/shared'
import React, { Fragment } from 'react'
import React from 'react'
import { DefaultTemplate } from '../../templates/Default/index.js'
import { MinimalTemplate } from '../../templates/Minimal/index.js'
import { initPage } from '../../utilities/initPage/index.js'
import { getViewFromConfig } from './getViewFromConfig.js'
import { getRouteData } from './getRouteData.js'
export type GenerateViewMetadata = (args: {
config: SanitizedConfig
@@ -64,12 +65,14 @@ export const RootPage = async ({
const {
DefaultView,
documentSubViewType,
folderCollectionSlugs,
folderID: folderIDParam,
initPageOptions,
serverProps,
templateClassName,
templateType,
viewType,
} = getViewFromConfig({
} = getRouteData({
adminRoute,
config,
currentRoute,
@@ -89,6 +92,10 @@ export const RootPage = async ({
})
?.then((doc) => !!doc))
/**
* This function is responsible for handling the case where the view is not found.
* The current route did not match any default views or custom route views.
*/
if (!DefaultView?.Component && !DefaultView?.payloadComponent) {
if (initPageResult?.req?.user) {
notFound()
@@ -132,8 +139,20 @@ export const RootPage = async ({
importMap,
})
const payload = initPageResult?.req.payload
const folderID = parseDocumentID({
id: folderIDParam,
collectionSlug: payload.config.folders.slug,
payload,
})
const RenderedView = RenderServerComponent({
clientProps: { clientConfig, documentSubViewType, viewType } satisfies AdminViewClientProps,
clientProps: {
clientConfig,
documentSubViewType,
folderCollectionSlugs,
viewType,
} satisfies AdminViewClientProps,
Component: DefaultView.payloadComponent,
Fallback: DefaultView.Component,
importMap,
@@ -141,6 +160,7 @@ export const RootPage = async ({
...serverProps,
clientConfig,
docID: initPageResult?.docID,
folderID,
i18n: initPageResult?.req.i18n,
importMap,
initPageResult,
@@ -151,8 +171,8 @@ export const RootPage = async ({
})
return (
<Fragment>
{!templateType && <Fragment>{RenderedView}</Fragment>}
<React.Fragment>
{!templateType && <React.Fragment>{RenderedView}</React.Fragment>}
{templateType === 'minimal' && (
<MinimalTemplate className={templateClassName}>{RenderedView}</MinimalTemplate>
)}
@@ -167,6 +187,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}
@@ -181,6 +202,6 @@ export const RootPage = async ({
{RenderedView}
</DefaultTemplate>
)}
</Fragment>
</React.Fragment>
)
}

View File

@@ -3,6 +3,8 @@ import type { SanitizedConfig } from 'payload'
import { getNextRequestI18n } from '../../utilities/getNextRequestI18n.js'
import { generateAccountViewMetadata } from '../Account/metadata.js'
import { generateBrowseByFolderMetadata } from '../BrowseByFolder/metadata.js'
import { generateCollectionFolderMetadata } from '../CollectionFolders/metadata.js'
import { generateCreateFirstUserViewMetadata } from '../CreateFirstUser/metadata.js'
import { generateDashboardViewMetadata } from '../Dashboard/metadata.js'
import { generateDocumentViewMetadata } from '../Document/metadata.js'
@@ -18,6 +20,7 @@ import { getCustomViewByRoute } from './getCustomViewByRoute.js'
const oneSegmentMeta = {
'create-first-user': generateCreateFirstUserViewMetadata,
folders: generateBrowseByFolderMetadata,
forgot: generateForgotPasswordViewMetadata,
login: generateLoginViewMetadata,
logout: generateUnauthorizedViewMetadata,
@@ -40,12 +43,18 @@ export const generatePageMetadata = async ({
params: paramsPromise,
}: Args) => {
const config = await configPromise
const params = await paramsPromise
const folderCollectionSlugs = config.collections.reduce((acc, { slug, admin }) => {
if (admin?.folders) {
return [...acc, slug]
}
return acc
}, [])
const segments = Array.isArray(params.segments) ? params.segments : []
const currentRoute = `/${segments.join('/')}`
const [segmentOne, segmentTwo] = segments
const [segmentOne, segmentTwo, segmentThree] = segments
const isGlobal = segmentOne === 'globals'
const isCollection = segmentOne === 'collections'
@@ -72,7 +81,14 @@ export const generatePageMetadata = async ({
break
}
case 1: {
if (oneSegmentMeta[segmentOne] && segmentOne !== 'account') {
if (folderCollectionSlugs.length && `/${segmentOne}` === config.admin.routes.browseByFolder) {
// --> /:folderCollectionSlug
meta = await oneSegmentMeta.folders({ config, i18n })
} else if (segmentOne === 'account') {
// --> /account
meta = await generateAccountViewMetadata({ config, i18n })
break
} else if (oneSegmentMeta[segmentOne]) {
// --> /create-first-user
// --> /forgot
// --> /login
@@ -81,10 +97,6 @@ export const generatePageMetadata = async ({
// --> /unauthorized
meta = await oneSegmentMeta[segmentOne]({ config, i18n })
break
} else if (segmentOne === 'account') {
// --> /account
meta = await generateAccountViewMetadata({ config, i18n })
break
}
break
}
@@ -92,8 +104,13 @@ export const generatePageMetadata = async ({
if (`/${segmentOne}` === config.admin.routes.reset) {
// --> /reset/:token
meta = await generateResetPasswordViewMetadata({ config, i18n })
}
if (isCollection) {
} else if (
folderCollectionSlugs.length &&
`/${segmentOne}` === config.admin.routes.browseByFolder
) {
// --> /browse-by-folder/:folderID
meta = await generateBrowseByFolderMetadata({ config, i18n })
} else if (isCollection) {
// --> /collections/:collectionSlug
meta = await generateListViewMetadata({ collectionConfig, config, i18n })
} else if (isGlobal) {
@@ -112,15 +129,29 @@ export const generatePageMetadata = async ({
// --> /:collectionSlug/verify/:token
meta = await generateVerifyViewMetadata({ config, i18n })
} else if (isCollection) {
// Custom Views
// --> /collections/:collectionSlug/:id
// --> /collections/:collectionSlug/:id/preview
// --> /collections/:collectionSlug/:id/versions
// --> /collections/:collectionSlug/:id/versions/:version
// --> /collections/:collectionSlug/:id/api
meta = await generateDocumentViewMetadata({ collectionConfig, config, i18n, params })
if (segmentThree === config.folders.slug) {
if (folderCollectionSlugs.includes(collectionConfig.slug)) {
// Collection Folder Views
// --> /collections/:collectionSlug/:folderCollectionSlug
// --> /collections/:collectionSlug/:folderCollectionSlug/:id
meta = await generateCollectionFolderMetadata({
collectionConfig,
config,
i18n,
params,
})
}
} else {
// Collection Document Views
// --> /collections/:collectionSlug/:id
// --> /collections/:collectionSlug/:id/preview
// --> /collections/:collectionSlug/:id/versions
// --> /collections/:collectionSlug/:id/versions/:version
// --> /collections/:collectionSlug/:id/api
meta = await generateDocumentViewMetadata({ collectionConfig, config, i18n, params })
}
} else if (isGlobal) {
// Custom Views
// Global Document Views
// --> /globals/:globalSlug/versions
// --> /globals/:globalSlug/versions/:version
// --> /globals/:globalSlug/preview

View File

@@ -1,6 +1,6 @@
import type { ArrayFieldClient, BlocksFieldClient, ClientConfig, ClientField } from 'payload'
import { fieldShouldBeLocalized } from 'payload/shared'
import { fieldShouldBeLocalized, groupHasName } from 'payload/shared'
import { fieldHasChanges } from './fieldHasChanges.js'
import { getFieldsForRowComparison } from './getFieldsForRowComparison.js'
@@ -114,25 +114,37 @@ export function countChangedFields({
// Fields that have nested fields and nest their fields' data.
case 'group': {
if (locales && fieldShouldBeLocalized({ field, parentIsLocalized })) {
locales.forEach((locale) => {
if (groupHasName(field)) {
if (locales && fieldShouldBeLocalized({ field, parentIsLocalized })) {
locales.forEach((locale) => {
count += countChangedFields({
comparison: comparison?.[field.name]?.[locale],
config,
fields: field.fields,
locales,
parentIsLocalized: parentIsLocalized || field.localized,
version: version?.[field.name]?.[locale],
})
})
} else {
count += countChangedFields({
comparison: comparison?.[field.name]?.[locale],
comparison: comparison?.[field.name],
config,
fields: field.fields,
locales,
parentIsLocalized: parentIsLocalized || field.localized,
version: version?.[field.name]?.[locale],
version: version?.[field.name],
})
})
}
} else {
// Unnamed group field: data is NOT nested under `field.name`
count += countChangedFields({
comparison: comparison?.[field.name],
comparison,
config,
fields: field.fields,
locales,
parentIsLocalized: parentIsLocalized || field.localized,
version: version?.[field.name],
version,
})
}
break

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