Compare commits

..

45 Commits

Author SHA1 Message Date
James
f7e036e1b8 chore: adds ops counter test to graphql 2025-03-12 11:42:33 -04:00
Alessio Gravili
c4fd27de01 templates: bump Payload and Next.js dependencies (#11641)
This bumps Payload to 3.28.0 and Next.js to 15.2.2 in all templates.
2025-03-12 08:48:07 -06:00
Said Akhrarov
b44603b253 fix(ui): prevent fieldErrorsToast from showing empty errors list (#11643)
<!--

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?
The error toast shown on field errors was _greatly_ improved recently
with much clearer, more easily consumable messages. This PR adjusts a
minor issue when the format of the error message is such that there are
no subsequent field errors present.

### Why?
To prevent showing an extra `li` when there are no more field errors.

### How?
Previously, the error msg array was being constructed like so:
```ts
const [intro, errorsString] = message.split(':')
const errors = (errorsString || '')
    .split(',')
    .map((error) => error.replaceAll(' > ', ' → ').trim())
    
if (errors.length === 0) {
    return {
      message: intro,
    }
}
...
```

This works fine. However, if the initial message split makes
`errorsString` undefined, as is the case where there are no subsequent
field errors, the `(errorsString || '').split(',')` will always return
an array with a single `""` element in it, making the check for
`errors.length === 0` unreachable. This PR checks if `errorsString` is
false-y first before doing further processing instead.

Before:

![Login-Payload-03-11-2025_10_36_PM-before](https://github.com/user-attachments/assets/b2695277-7e33-40c8-a369-de4f72654d5f)

After:

![Login-Payload-03-11-2025_10_35_PM-after](https://github.com/user-attachments/assets/efad92b2-d9c2-4efb-bb67-b1dd625855bf)
2025-03-12 10:27:24 -04:00
Patrik
9d6583d9de fix: incorrect height rounding when resizing images with sharp (#11634)
This PR fixes an issue where the Sharp `.resize()` function would round
down an auto-scaled dimension when `fastShrinkOnLoad` was enabled
(enabled by default).

This caused slight discrepancies in height calculations in certain edge
cases.

Be default (`fastShrinkOnLoad: true`), Sharp:
- Uses the built-in shrink-on-load feature for JPEG and WebP
- It is an optimization that prioritizes speed over precision when
resizing images

By setting `fastShrinkOnLoad: false`, we force Sharp to:
- Perform a more accurate resize operation instead of relying on quick
pre-shrink methods.

### Before / Context:

- Upload an image with original dimensions of 1500 × 735
- Define an `imageSize` of the following:
```
{
  name: 'thumbnail',
  width: 300,
},
```

#### Calculation:

`originalAspectRatio = 1500 / 735 ≈ 2.04081632653`

`resizeHeight = 300 / 2.04081632653`
`resizeHeight = 147`

However, Sharp's `.resize()` calculation would output:

`resizeHeight = 146`

This lead to an error of:

```
[17:05:13] ERROR: extract_area: bad extract area
    err: {
      "type": "Error",
      "message": "extract_area: bad extract area",
      "stack":
          Error: extract_area: bad extract area
    }
```

### After:

Sharp's `.resize()` calculation now correctly outputs:

`resizeHeight = 147`
2025-03-12 09:48:05 -04:00
Marcus Forsberg
7be02194d6 fix(translations): improve Swedish translations (#11654)
### What?
Improves Swedish translations throughout.

- There were several places where the automatic translations didn't make
sense, particularily around localization where "locale" was incorrectly
referred to as "Lokal" instead of "Språk". "Crop" being translated to
"Skörd" was another hilarious one ("Skörd" means crop as in harvest 😊).
- Most success messages were overly formal in Swedish with
"framgångsrikt" being used in an awkward fashion. I've shortened them to
be more to the point.
- Some shorter strings had incorrect capitalization, such as "Nytt
Lösenord". Swedish doesn't use that kind of capitalization, so "Nytt
lösenord" is correct.
- Replaced "Manöverpanel" as the word for "Dashboard" with "Översikt"
which is less awkward.
- Normalized loading toasts throughout so they all use dots at the end
to signify an ongoing action such as "Laddar..".
- Several other small improvements to make things more natural.
2025-03-12 11:57:55 +00:00
Jesper We
1da50f5684 chore(translations): polish Swedish (#11353) 2025-03-11 22:51:15 -04:00
Jacob Fletcher
f2abc80a00 test: deflakes blocks e2e (#11640)
The blocks e2e tests were flaky due to how we conditionally render
fields as they enter the viewport. This prevented Playwright from every
reaching the target element when running
`locator.scrollIntoViewIfNeeded()`. This is especially flaky on pages
with many fields because the page size would continually grow as it was
scrolled.

To fix this there are new `scrollEntirePage` and `waitForPageStability`
helpers. Together, these will ensure that all fields are rendered and
fully loaded before we start testing. An early attempt at this was made
via `page.mouse.wheel(0, 1750)`, but this is an arbitrary pixel value
that is error prone and is not future proof.

These tests were also flaky by an attempt to trigger a form state action
before it was ready to receive events. The fix here is to disable any
buttons while the form is initializing and let Playwright wait for an
interactive state.
2025-03-11 22:49:06 -04:00
Alessio Gravili
88eeeaa8dd fix: incorrect types for field Label, Description and Error server components (#11642)
Our previous types for Label, Description and Error server components were incorrectly typed. We were using the `ServerProps` type, which was wrong.

In our renderFields function, you can see that `ServerComponentProps` are passed as server props, not `ServerProps`: https://github.com/payloadcms/payload/blob/fix/incorrect-component-types/packages/ui/src/forms/fieldSchemasToFormState/renderField.tsx

Additionally, we no longer have to wrap that type in `Partial<>`, as all server props in that type are required.
2025-03-11 20:48:11 -06:00
Alessio Gravili
d14bc44c63 docs: fix invalid ```txt language (#11638)
Fixes error when importing docs to website. `text` is a valid language,
`txt` is not.
2025-03-11 15:25:31 -06:00
Alessio Gravili
9c53a62503 chore(deps): bump next.js from 15.2.1 to 15.2.2 in monorepo (#11636)
This bumps next.js to 15.2.2 in our monorepo, guaranteeing compatibility
2025-03-11 17:21:23 -04:00
Elliot DeNolf
bc79608db4 chore(release): eslint/3.28.0 2025-03-11 17:19:36 -04:00
Elliot DeNolf
d959d843a2 chore(release): v3.28.0 [skip ci] 2025-03-11 17:10:15 -04:00
Germán Jabloñski
eb09ce9a3e feat(richtext-lexical): allow disabling indentation for specific nodes (#11631)
allow disabling indentation for specific nodes via IndentFeature

Usage: 

```ts
editor: lexicalEditor({
  features: ({ defaultFeatures }) => [
    ...defaultFeatures,
    IndentFeature({
      // the array must contain the "type" property of registered indentable nodes
      disabledNodes: ['paragraph', 'listitem'],
    }),
  ],
}),
```

The nodes "paragraph", "heading", "listitem", "quote" remain indentable
by default, even without `IndentFeature` registered.

In a future PR we will probably add the option to disable TabNode.
2025-03-11 17:27:25 -03:00
Alessio Gravili
f2da72b4d0 chore(deps): bump all eslint packages (#11629)
This bumps all eslint packages, ensuring compatibility with TypeScript 5.7.3. Previously, the following would be thrown:

```bash
WARNING: You are currently running a version of TypeScript which is not officially supported by @typescript-eslint/typescript-estree.

You may find that it works just fine, or you may not.

SUPPORTED TYPESCRIPT VERSIONS: >=4.7.4 <5.7.0

YOUR TYPESCRIPT VERSION: 5.7.3

Please only submit bug reports when using the officially supported version
```

This [might have caused errors during linting](https://payloadcms.slack.com/archives/C04H7CQ615K/p1741707183505329?thread_ts=1741707036.030089&cid=C04H7CQ615K).

`payload` lint before: ✖ 380 problems (9 errors, 371 warnings)
`payload` lint after: ✖ 381 problems (9 errors, 372 warnings)

`ui` lint before: ✖ 154 problems (12 errors, 142 warnings)
`ui` lint after: ✖ 267 problems (12 errors, 255 warnings)

The additional warnings in `ui` come from the new  `@eslint-react/no-use-context` and  `@eslint-react/no-context-provider` rules which are good to have in React 19.
2025-03-11 18:34:50 +00:00
Jacob Fletcher
5285518562 feat: defaults to noindex nofollow (#11623)
We now have the ability to define all page metadata for the admin panel
via the Payload Config as a result of #11593. This means we can now set
sensible defaults for additional properties, e.g. `noindex` and
`nofollow` on the `robots` property. Setting this will prevent these
pages from being indexed and appearing in search results.

Note that setting this property prevents _indexing_ these pages, but
does not prevent them from being _crawled_. To prevent crawling as well,
you must add a standalone `robots.txt` file to your root directory.
2025-03-11 13:29:49 -04:00
Alessio Gravili
243cdb1901 refactor: more reliable import map generation, supporting turbopack and tsconfig basePath (#11618)
This simplifies and cleans up import map generation and adds support for turbopack, as well as the tsconfig `compilerOptions.basePath` property.

Previously, relative import paths looked like this:

```ts
import { TestComponent as ___ } from 'test/admin/components/TestComponent.js'
```

Paths like these will be resolved based on the `compilerOptions.baseUrl` path of your tsconfig.

This had 2 problems:

### baseUrl support

 If your tsconfig baseUrl was not `"."`, this did not work, as the import map generator does not respect it
 
 ### Turbopack support
 
If Turbopack was used, certain import paths were not able to be resolved.

For example, if your component is outside the `baseDir`, the generated path looked like this:

```ts
import { TestComponent as ___ } from '/../test/admin/components/TestComponent.js'
```

This works fine in webpack, but breaks in turbopack.

## Solution

This PR ensures all import paths are relative, making them more predictable and reliable.

The same component will now generate the following import path which works in Turbopack and if a different `compilerOptions.basePath` property is set:

```ts
import { TestComponent as ___ } from '../../../test/admin/components/TestComponent.js'
```

It also adds unit tests
2025-03-11 09:56:41 -06:00
Alessio Gravili
c7bb694249 perf: 50% faster compilation speed by skipping bundling of server-only packages during dev (#11594)
This PR skips bundling server-only payload packages during development, which results in 50% faster compilation speeds using turbo.

Test results using our blank template (both /api and /admin):

Webpack before: 11.5
Webpack now: 7.1s
=> 38% faster compilation speed

Turbopack before: 4.1s
Turbopack after: 2.1s
=> 50% faster compilation speed
2025-03-11 09:45:13 -06:00
Patrik
8f3d1bd871 fix: ensure only authenticated users can access the payload-locked-documents collection (#11624) 2025-03-11 10:57:12 -04:00
Rokas Puzonas
85f88a0194 fix(translations): update translation placeholders to not be translated for lithuanian (#11622)
### What?
The Lithuanian i18n translations have the placeholders (i.e.
`{{label}}`) also translated. For example `{{label}}` to `{{žymė}}`

My guess is that this was caused by the `pnpm translateNewKeys` script
which feeds all of the strings to OpenAI. In the system message in
[translateText.ts#L15](https://github.com/payloadcms/payload/blob/main/packages/translations/scripts/translateNewKeys/translateText.ts#L15)
there is nothing mentioning that it should not translate placeholders.
But I guess the AI was clever enough most of the time and not translated
them, leaving them as is. Because in the Lithuanian translation most
placeholders were correctly left as is, but a couple of them weren't.

I would have updated the system message, but I struggled to setup my
environment so that `pnpm translateNewKeys` would work (probably because
I'm on windows, idk). So I'm leaving the system message as is because I
can't test my changes, someone else should update it in another PR.

### Why?
Lithuanian messages weren't translated correctly.

### How?
Manually went through all of the used placeholders in in `lt.ts` and
updated the ones which were translated. Double checked using `en.ts`
file to see what was the original placeholder name.
2025-03-11 14:45:18 +00:00
Germán Jabloñski
38f61e91b8 docs: fix documentation about custom i18n types (#11386)
Fixes #9858

# The problems

There were several issues with custom i18n typing in the documentation
that were not detected because they did not occur in non-strict ts mode.

1. `Config['i18n']['translations']` didn't work, because i18n is an
optional property. As described in
[#9858](https://github.com/payloadcms/payload/issues/9858#issuecomment-2555814771),
some users were getting around this with
`NonNullable<Config['i18n']>['translations']`
2. [The trick being attempted in
`i18n`](36e152d69d/packages/payload/src/config/types.ts (L1034))
to customize and extend the `DefaultTranslationObject` does not work.
`i18n?: I18nOptions<{} | DefaultTranslationsObject> // loosen the type
here to allow for custom translations`.

If you want to verify this, you can use the following code example:
```ts
import type { Config } from 'payload'

const translation: NonNullable<Config['i18n']>['translations'] = {
  en: {
    authentication: {
      aaaaa: 'aaaaa', // I chose `authentication.aaaa` to appear first in intellisense
    }
  },
}

translation.en?.authentication // Property 'authentication' does not 
// exist on type '{} | { authentication: { account: string...
// so this option doesn't let you access the keys because of the join with `{}`, 
// and even if it did, it's not adding `aaaa` as a key.
```
3. In places where the `t` function is exposed in a callback, you cannot
do what the documentation says:
`{ t }: { t: TFunction<CustomTranslationsKeys | DefaultTranslationKeys>
}`
The reason for this is that the callback is exposed as a `LabelFunction`
type but without type arguments, and as a default it uses
`DefaultTranslationKeys`, which does not allow additional keys.

If you want to verify this, you can use the following code example:
```ts
// Make sure to test this with ts in strict mode
const _labelFn: LabelFunction = ({ t }: { t: TFunction<'extraKey' | DefaultTranslationKeys> }) => ""
// Type '"extraKey"' is not assignable to type
// '"authentication:account" | ... 441 more ... | "version:versionCount"'.
```

# The solution

Point 1 is a documentation issue. We could use `NonNullable`, or expose
the `I18nOptions` type, or simply not define the custom translation type
(which makes sense because if you put it in the config, ts will warn you
anyway).

Points 2 and 3 should ideally be corrected at the type level, but it
would imply a breaking change.

For now, I have corrected them at the documentation level, using an
alternative for point 2 and a type cast for point 3.

Maybe in payload v4 we should revisit this.
2025-03-11 09:14:44 -03:00
Jacob Fletcher
ac1e3cf69e feat(ui): form state queues (#11579)
Implements a form state task queue. This will prevent onChange handlers
within the form component from processing unnecessarily often, sometimes
long after the user has stopped making changes. This leads to a
potentially huge number of network requests if those changes were made
slower than the debounce rate. This is especially noticeable on slow
networks.

Does so through a new `useQueue` hook. This hook maintains a stack of
events that need processing but only processes the final event to
arrive. Every time a new event is pushed to the stack, the currently
running process is aborted (if any), and that event becomes the next in
the queue. This results in a shocking reduction in the time it takes
between final change to form state and the final network response, from
~1.5 minutes to ~3 seconds (depending on the scenario, see below).

This likely fixes a number of existing open issues. I will link those
issues here once they are identified and verifiably fixed.

Before:

I'm typing slowly here to ensure my changes aren't debounce by the form.
There are a total of 60 characters typed, triggering 58 network requests
and taking around 1.5 minutes to complete after the final change was
made.


https://github.com/user-attachments/assets/49ba0790-a8f8-4390-8421-87453ff8b650

After:

Here there are a total of 69 characters typed, triggering 11 network
requests and taking only about 3 seconds to complete after the final
change was made.


https://github.com/user-attachments/assets/447f8303-0957-41bd-bb2d-9e1151ed9ec3
2025-03-10 21:25:14 -04:00
Jacob Fletcher
397c1f1ae7 feat(next): fully expose Next.js metadata (#11593)
Payload now fully exposes Next.js' metadata options. You can now use the
`admin.meta` config to set any properties that Next.js supports and
Payload will inject them into its `generateMetadata` function call. The
`MetaConfig` provided by Payload now directly extends the `Metadata`
type from Next.js.

Although `admin.meta` has always been available, it only supported a
subset of options, such as `title`, `openGraph`, etc., but was lacking
properties like `robots`, etc.
2025-03-10 21:24:55 -04:00
Germán Jabloñski
c8f01e31a1 chore(db-postgres): enable TypeScript strict (#11560) 2025-03-10 18:12:20 -03:00
Jessica Chowdhury
9ac7a3ed49 fix(ui): adds fallback locale when defaultLocale is unavailable (#11614) 2025-03-10 15:20:58 -04:00
Jessica Chowdhury
051c1fe015 chore(ui): code/json field full height should include any padding added (#11607) 2025-03-10 15:17:58 -04:00
Dan Ribbens
6d0924ef37 fix: upload imageSizes forces original file uploads to be compressed (#11612)
### What?

When the upload config contains imageSizes, we are forcing the image to
be resized using sharp. This leads to lossy compression even when the
formatOptions and no cropping or focal point selection was made. This
change makes it possible to upload the original image, skipping
compression while still using the imageSizes feature.

### Why?

It should be possible to upload files without compression.

### How?

Changes the conditions to remove imageSizes to determine if sharp image
processing should be applied to the original image or not.
2025-03-10 14:32:30 -04:00
Jarrod Flesch
fc5876a602 fix(ui): stale list thumbnails when navigating (#11609)
### What? Stale list view images
Thumbnail images are stale on slow connections.

### Why?
The variable `fileExists` is not reset when the `fileSrc` prop changes.

#### Before

https://github.com/user-attachments/assets/57a2352a-8312-4070-ba16-8c4f4d4e58e2

#### After

https://github.com/user-attachments/assets/ea44b460-823d-412a-bed0-425378480bb5
2025-03-10 14:14:00 -04:00
Paul
72efc843cc templates: fix issue with populateAuthors hook breaking live-preview on website template (#11608)
Fixes https://github.com/payloadcms/payload/issues/11468

The populateAuthors hook could break live preview if it returned a
notFound error as we didn't catch these properly
2025-03-10 17:42:30 +00:00
Patrik
3ede7abe00 feat: threads path through field validate function (#11591)
This PR updates the field `validate` function property to include a new
`path` argument.

The `path` arg provides the schema path of the field, including array
indices where applicable.

#### Changes:

- Added `path: (number | string)[]` in the ValidateOptions type.
2025-03-10 11:41:23 -04:00
Sasha
5d65cb002b fix(plugin-import-export): plugin breaks i18n configuration (#11590)
Fixes https://github.com/payloadcms/payload/issues/11582
2025-03-10 11:31:06 -04:00
Md. Tajmirul Islam Akhand
814ced463b templates: allow displaying dynamic error message on forms created via Form Builder plugin (#11275)
Close #11274

### Why this PR?
I've created a custom phone number input block for my form builder,
including validation. However, the component on the frontend only
displays the generic message "This field is required," even when
formState.errors contains specific error messages. This is not the
expected behavior. I need the component to display the error messages
from formState.errors.

### Description
This pull request includes changes to improve error handling in various
form components by passing the `name` prop to the `Error` component and
updating the `Error` component to display specific error messages.

#### Error handling improvements:

*
[`templates/website/src/blocks/Form/Error/index.tsx`](diffhunk://#diff-a97a4b2b87ff1a02431d11ab00f4e0ead5d11819f45dac120b9502ace520196fR1-R14):
Updated the `Error` component to accept a `name` prop and use
`useFormContext` to display specific error messages.

#### Form component updates:

*
[`templates/website/src/blocks/Form/Checkbox/index.tsx`](diffhunk://#diff-4f0ad9596965f1e3b2f6356943d1d34009a742502bc8ab8d438ce98593fdef4aL42-R42):
Modified to pass the `name` prop to the `Error` component.
*
[`templates/website/src/blocks/Form/Country/index.tsx`](diffhunk://#diff-3abd97c2bfe7ce2a1809e6eaac68e6c02078514308f964b1792f7a1af2df92a7L62-R62):
Modified to pass the `name` prop to the `Error` component.
*
[`templates/website/src/blocks/Form/Email/index.tsx`](diffhunk://#diff-f1be3cf1e7c1fa9b543ed8f56a3655e601fdb399d31ede1d099a37004a1861bfL35-R35):
Modified to pass the `name` prop to the `Error` component.
*
[`templates/website/src/blocks/Form/Number/index.tsx`](diffhunk://#diff-72e5bd63eda769bce077e87bc614cb338211600580ad38ba86a7f066a35212a5L33-R33):
Modified to pass the `name` prop to the `Error` component.
*
[`templates/website/src/blocks/Form/Select/index.tsx`](diffhunk://#diff-69d52ba3bb01fc0ce4428f5b76ab48a86c448dceaf36390edbcf345f0b15c34eL60-R60):
Modified to pass the `name` prop to the `Error` component.
*
[`templates/website/src/blocks/Form/State/index.tsx`](diffhunk://#diff-c0eb5a8c64b6384a44e19556556921bff4c89ed3a8d5a1d2e46ce493178587caL61-R61):
Modified to pass the `name` prop to the `Error` component.
*
[`templates/website/src/blocks/Form/Text/index.tsx`](diffhunk://#diff-9d32d5b3132729534809280d97d8a0952e96270f434b5d57a32a2d4981a36384L29-R29):
Modified to pass the `name` prop to the `Error` component.
*
[`templates/website/src/blocks/Form/Textarea/index.tsx`](diffhunk://#diff-d25c7cb831ee04c195983c1a88718bdcec8f1dc34c3e5237875678eb8194994dL37-R37):
Modified to pass the `name` prop to the `Error` component.
2025-03-10 12:22:07 +00:00
Sasha
3de1636e92 docs: document payload migrate:create flags (#11592)
Related discussion
https://github.com/payloadcms/payload/discussions/10978
2025-03-07 19:25:39 +02:00
Sasha
e9afb367b5 fix(db-mongodb): properly sanitize updateVersion read result (#11589)
Previously, `db.updateVersion` had a mistake with using `transform({
operation: 'write' })` instead of `transform({ operation: 'read' })`
which led to improper DB data sanitization (like ObjectID -> string,
Date -> string) when calling `payload.update` with `autosave: true` when
some other autosave draft already exists. This fixes
https://github.com/payloadcms/payload/issues/11542 additionally for this
case.
2025-03-07 19:14:02 +02:00
Jarrod Flesch
029cac3cd3 fix(graphql): sanitize graphql field names for schema generation (#11556)
### What? Cannot generate GraphQL schema with hyphenated field names
Using field names that do not adhere to the GraphQL `_a-z & A-Z`
standard prevent you from generating a schema, even though it will work
just fine everywhere else.

Example: `my-field-name` will prevent schema generation.

### How? Field name sanitization on generation and querying
This PR adds sanitization to the schema generation that sanitizes field
names.
- It formats field names in a GraphQL safe format for schema generation.
**It does not change your config.**
- It adds resolvers for field names that do not adhere so they can be
mapped from the config name to the GraphQL safe name.

Example:
- `my-field` will turn into `my_field` in the schema generation
- `my_field` will resolve from `my-field` when data comes out

### Other notes
- Moves code from `packages/graphql/src/schema/buildObjectType.ts` to
`packages/graphql/src/schema/fieldToSchemaMap.ts`
- Resolvers are only added when necessary: `if (formatName(field.name)
!== field.name)`.

---------

Co-authored-by: Dan Ribbens <dan.ribbens@gmail.com>
2025-03-07 14:43:09 +00:00
Jessica Chowdhury
a53876d741 fix(ui): logic for showing copyToLocale button and adds test (#11584)
### What?
This [PR](https://github.com/payloadcms/payload/pull/11546) introduced a
bug where the `CopyToLocale` button can show up when localization is
false.

### Why?
`const disableCopyToLocale = localization &&
collectionConfig?.admin?.disableCopyToLocale` this line was faulty

### How?
Fixed the logic and added test to confirm button doesn't show when
localization is false.
2025-03-07 14:13:48 +00:00
Jessica Chowdhury
6f90d62fc2 fix(ui): upload.displayPreview should affect all previews in the admin panel (#11496)
### What?
We have the option to set `displayPreview: true || false` on upload
collections / upload fields - with the **field** option taking
precedence.

Currently, `displayPreview` is only affecting the list view for the
**_related_** collection.

i.e. if you go to a collection that has an upload field - the preview
will be hidden/shown correctly according to the `displayPreview` option.
<img width="620" alt="Screenshot 2025-03-03 at 12 38 18 PM"
src="https://github.com/user-attachments/assets/c11c2a84-0f64-4a08-940e-8c3f9096484b"
/>

However, when you go directly to the upload collection and look at the
list view - the preview is always shown, not affected by the
`displayPreview` option.
<img width="446" alt="Screenshot 2025-03-03 at 12 39 24 PM"
src="https://github.com/user-attachments/assets/f5e1267a-d98a-4c8c-8d54-93dea6cd2e31"
/>

Also, we have previews within the file field itself - also not being
affected by the `displayPreview` option.
<img width="528" alt="Screenshot 2025-03-03 at 12 40 06 PM"
src="https://github.com/user-attachments/assets/3dd04c9a-3d9f-4823-90f8-b538f3d420f9"
/>

All the upload related previews (excluding preview sizes and upload
editing options) should be affected by the `displayPreview` option.

### How?
Checks for `collection.displayPreview` and `field.displayPreview` in all
places where previews are displayed.

Closes #11404
2025-03-07 12:49:20 +00:00
Jessica Chowdhury
6699844d7b chore(ui): removes margin when row is empty and passes style from props (#11504)
Two small separate issues here (1) and (2):

### What?
1. Excess margin is displayed when a row is hidden due to
`admin.condition`
2. The `admin.style` props is never passed to the `row` field

### Why?
1. Unlike other fields, the `row` field still gets rendered when
`admin.condition` returns false - this is because the logic gets passed
down to the fields within the row
2. `style` was never being threaded to the `row` field wrapper

### How?
1. Hides the row using css to `display: none` when no children are
present
2. Passes `admin.styles` to the `row` wrapper

Fixes #11477
2025-03-07 12:48:58 +00:00
Jessica Chowdhury
657ad20278 feat(ui): adds disable copy to locale option to collection config (#11546)
### What?
Adds new option to disable the `copy to locale` button, adds description
to docs and adds e2e test.

### Why?
Client request.

### How?
The option can be used like this: 
```ts
// in collection config
  admin: {
    disableCopyToLocale: true,
  },
```
2025-03-07 12:48:08 +00:00
Elliot DeNolf
30af889e3b chore: set all licenses for internal tooling 2025-03-06 22:15:44 -05:00
Patrik
8378654fd0 fix(ui): apply consistent styling to custom & default block thumbnails (#11555)
Fixes #9744
2025-03-06 15:34:25 -05:00
Alessio Gravili
b0da85dfea chore(deps): bump next.js from 15.2.0 to 15.2.1 in monorepo (#11576)
This bumps next.js to 15.2.1 in our monorepo, guaranteeing compatibility
2025-03-06 19:09:33 +00:00
Jarrod Flesch
48115311e7 fix(ui): incorrect error states (#11574)
Fixes https://github.com/payloadcms/payload/issues/11568

### What? Out of sync errors states
- Collaspibles & Tabs were not reporting accurate child error counts
- Arrays could get into a state where they would not update their error
states
- Slight issue with toasts 

### Tabs & Collapsibles
The logic for determining matching field paths was not functioning as
intended. Fields were attempting to match with paths such as `_index-0`
which will not work.

### Arrays
The form state was not updating when the server sent back errorPaths.
This PR adds `errorPaths` to `serverPropsToAccept`.

### Toasts
Some toasts could report errors in the form of `my > > error`. This
ensures they will be `my > error`

### Misc
Removes 2 files that were not in use:
- `getFieldStateFromPaths.ts`
- `getNestedFieldState.ts`
2025-03-06 14:02:10 -05:00
Jacob Fletcher
7cef8900a7 chore(deps): bumps @payloadcms/admin-bar in templates and examples (#11566)
The Payload Admin Bar is now maintained in core and released under the
`@payloadcms` scope thanks to #3684. All templates and examples that
rely on this package now install from here and have been migrated
accordingly.
2025-03-06 12:09:32 -05:00
Alessio Gravili
557ac9931a feat(richtext-lexical): upgrade lexical from 0.21.0 to 0.27.1 (#11564)
Fixes https://github.com/payloadcms/payload/issues/10628

This upgrades lexical from 0.21.0 to 0.27.1. This will allow us to use the new node state API to implement custom text formats (e.g. text colors), [thanks to Germán](https://x.com/GermanJablo/status/1897345631821222292).

## Notable changes ported over from lexical playground:

### Table column freezing

https://github.com/user-attachments/assets/febdd7dd-6fa0-40d7-811c-9a38de04bfa7

### Block cursors

We now render a block cursor, which is a custom cursor that gets rendered when the browser doesn't render the native one. An example would be this this horizontal cursor above block nodes, if there is no space above:

![CleanShot 2025-03-05 at 18 48 08@2x](https://github.com/user-attachments/assets/f61ce280-599c-4123-bdf7-25507078fcd7)

Previously, those cursors were unstyled and not visible

### Table Alignment

Tables can now be aligned

![CleanShot 2025-03-05 at 19 48 32@2x](https://github.com/user-attachments/assets/3fe263db-a98e-4a5d-92fd-a0388e547e5b)
2025-03-06 17:06:39 +00:00
Elliot DeNolf
9f7e8f47d2 ci: adjust paths filter for workflows, only look at main.yml (#11572)
Refine the paths filter for workflows from `.github/workflows/**` to
`.github/workflows/main.yml`. This is the only workflow that affects the
build.
2025-03-06 15:16:57 +00:00
307 changed files with 14914 additions and 5610 deletions

View File

@@ -0,0 +1,27 @@
MIT License
Copyright (c) 2020-2025 Cameron Little <cameron@camlittle.com>
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
'Software'), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
---
Modifications made by Payload CMS, Inc. <info@payloadcms.com>, 2025
Details in README.md

View File

@@ -4,6 +4,7 @@
"private": true,
"description": "GitHub Action to automatically comment on PRs and Issues when a fix is released.",
"license": "MIT",
"author": "Payload <dev@payloadcms.com> (https://payloadcms.com)",
"main": "dist/index.js",
"scripts": {
"build": "pnpm build:typecheck && pnpm build:ncc",

View File

@@ -41,14 +41,14 @@ jobs:
with:
filters: |
needs_build:
- '.github/workflows/**'
- '.github/workflows/main.yml'
- 'packages/**'
- 'test/**'
- 'pnpm-lock.yaml'
- 'package.json'
- 'templates/**'
needs_tests:
- '.github/workflows/**'
- '.github/workflows/main.yml'
- 'packages/**'
- 'test/**'
- 'pnpm-lock.yaml'
@@ -308,6 +308,7 @@ jobs:
- fields__collections__Text
- fields__collections__UI
- fields__collections__Upload
- form-state
- live-preview
- localization
- locked-documents

View File

@@ -53,6 +53,7 @@ jobs:
plugin-cloud
plugin-cloud-storage
plugin-form-builder
plugin-import-export
plugin-multi-tenant
plugin-nested-docs
plugin-redirects

View File

@@ -6,7 +6,11 @@ desc: Customize the metadata of your pages within the Admin Panel
keywords: admin, components, custom, documentation, Content Management System, cms, headless, javascript, node, react, nextjs
---
Every page within the Admin Panel automatically receives dynamic, auto-generated metadata derived from live document data, the user's current locale, and more, without any additional configuration. This includes the page title, description, og:image and everything in between. Metadata is fully configurable at the root level and cascades down to individual collections, documents, and custom views, allowing for the ability to control metadata on any page with high precision.
Every page within the Admin Panel automatically receives dynamic, auto-generated metadata derived from live document data, the user's current locale, and more. This includes the page title, description, og:image, etc. and requires no additional configuration.
Metadata is fully configurable at the root level and cascades down to individual collections, documents, and custom views. This allows for the ability to control metadata on any page with high precision, while also providing sensible defaults.
All metadata is injected into Next.js' [`generateMetadata`](https://nextjs.org/docs/app/api-reference/functions/generate-metadata) function. This used to generate the `<head>` of pages within the Admin Panel. All metadata options that are available in Next.js are exposed by Payload.
Within the Admin Panel, metadata can be customized at the following levels:
@@ -48,13 +52,9 @@ The following options are available for Root Metadata:
| Key | Type | Description |
| --- | --- | --- |
| **`title`** | `string` | The title of the Admin Panel. |
| **`description`** | `string` | The description of the Admin Panel. |
| **`defaultOGImageType`** | `dynamic` (default), `static`, or `off` | The type of default OG image to use. If set to `dynamic`, Payload will use Next.js image generation to create an image with the title of the page. If set to `static`, Payload will use the `defaultOGImage` URL. If set to `off`, Payload will not generate an OG image. |
| **`icons`** | `IconConfig[]` | An array of icon objects. [More details](#icons) |
| **`keywords`** | `string` | A comma-separated list of keywords to include in the metadata of the Admin Panel. |
| **`openGraph`** | `OpenGraphConfig` | An object containing Open Graph metadata. [More details](#open-graph) |
| **`titleSuffix`** | `string` | A suffix to append to the end of the title of every page. Defaults to "- Payload". |
| `defaultOGImageType` | `dynamic` (default), `static`, or `off` | The type of default OG image to use. If set to `dynamic`, Payload will use Next.js image generation to create an image with the title of the page. If set to `static`, Payload will use the `defaultOGImage` URL. If set to `off`, Payload will not generate an OG image. |
| `titleSuffix` | `string` | A suffix to append to the end of the title of every page. Defaults to "- Payload". |
| `[keyof Metadata]` | `unknown` | Any other properties that Next.js supports within the `generateMetadata` function. [More details](https://nextjs.org/docs/app/api-reference/functions/generate-metadata). |
<Banner type="success">
**Reminder:**
@@ -67,7 +67,7 @@ The Icons Config corresponds to the `<link>` tags that are used to specify icons
The most common icon type is the favicon, which is displayed in the browser tab. This is specified by the `rel` attribute `icon`. Other common icon types include `apple-touch-icon`, which is used by Apple devices when the Admin Panel is saved to the home screen, and `mask-icon`, which is used by Safari to mask the Admin Panel icon.
To customize icons, use the `icons` key within the `admin.meta` object in your Payload Config:
To customize icons, use the `admin.meta.icons` property in your Payload Config:
```ts
{
@@ -93,23 +93,13 @@ To customize icons, use the `icons` key within the `admin.meta` object in your P
}
```
The following options are available for Icons:
| Key | Type | Description |
| --- | --- | --- |
| **`rel`** | `string` | The HTML `rel` attribute of the icon. |
| **`type`** | `string` | The MIME type of the icon. |
| **`color`** | `string` | The color of the icon. |
| **`fetchPriority`** | `string` | The [fetch priority](https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/fetchPriority) of the icon. |
| **`media`** | `string` | The [media query](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_media_queries/Using_media_queries) of the icon. |
| **`sizes`** | `string` | The [sizes](https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/sizes) of the icon. |
| **`url`** | `string` | The URL pointing the resource of the icon. |
For a full list of all available Icon options, see the [Next.js documentation](https://nextjs.org/docs/app/api-reference/functions/generate-metadata#icons).
### Open Graph
Open Graph metadata is a set of tags that are used to control how URLs are displayed when shared on social media platforms. Open Graph metadata is automatically generated by Payload, but can be customized at the Root level.
To customize Open Graph metadata, use the `openGraph` key within the `admin.meta` object in your Payload Config:
To customize Open Graph metadata, use the `admin.meta.openGraph` property in your Payload Config:
```ts
{
@@ -135,14 +125,46 @@ To customize Open Graph metadata, use the `openGraph` key within the `admin.meta
}
```
The following options are available for Open Graph Metadata:
For a full list of all available Open Graph options, see the [Next.js documentation](https://nextjs.org/docs/app/api-reference/functions/generate-metadata#opengraph).
| Key | Type | Description |
| --- | --- | --- |
| **`description`** | `string` | The description of the Admin Panel. |
| **`images`** | `OGImageConfig` or `OGImageConfig[]` | An array of image objects. |
| **`siteName`** | `string` | The name of the site. |
| **`title`** | `string` | The title of the Admin Panel. |
### Robots
Setting the `robots` property will allow you to control the `robots` meta tag that is rendered within the `<head>` of the Admin Panel. This can be used to control how search engines index pages and displays them in search results.
By default, the Admin Panel is set to prevent search engines from indexing pages within the Admin Panel.
To customize the Robots Config, use the `admin.meta.robots` property in your Payload Config:
```ts
{
// ...
admin: {
meta: {
// highlight-start
robots: 'noindex, nofollow',
// highlight-end
},
},
}
```
For a full list of all available Robots options, see the [Next.js documentation](https://nextjs.org/docs/app/api-reference/functions/generate-metadata#robots).
##### Prevent Crawling
While setting meta tags via `admin.meta.robots` can prevent search engines from _indexing_ web pages, it does not prevent them from being _crawled_.
To prevent your pages from being crawled altogether, add a `robots.txt` file to your root directory.
```text
User-agent: *
Disallow: /admin/
```
<Banner type="info">
**Note:**
If you've customized the path to your Admin Panel via `config.routes`, be sure to update the `Disallow` directive to match your custom path.
</Banner>
## Collection Metadata

View File

@@ -121,6 +121,7 @@ The following options are available:
| `useAsTitle` | Specify a top-level field to use for a document title throughout the Admin Panel. If no field is defined, the ID of the document is used as the title. A field with `virtual: true` cannot be used as the title. |
| `description` | Text to display below the Collection label in the List View to give editors more information. Alternatively, you can use the `admin.components.Description` to render a React component. [More details](#custom-components). |
| `defaultColumns` | Array of field names that correspond to which columns to show by default in this Collection's List View. |
| `disableCopyToLocale` | Disables the "Copy to Locale" button while editing documents within this Collection. Only applicable when localization is enabled. |
| `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. |

View File

@@ -188,13 +188,15 @@ In order to use [Custom Translations](#custom-translations) in your project, you
Here we create a shareable translations object. We will import this in both our custom components and in our Payload config.
In this example we show how to extend English, but you can do the same for any language you want.
```ts
// <rootDir>/custom-translations.ts
import type { Config } from 'payload'
import { enTranslations } from '@payloadcms/translations/languages/en'
import type { NestedKeysStripped } from '@payloadcms/translations'
export const customTranslations: Config['i18n']['translations'] = {
export const customTranslations = {
en: {
general: {
myCustomKey: 'My custom english translation',
@@ -205,7 +207,7 @@ export const customTranslations: Config['i18n']['translations'] = {
},
}
export type CustomTranslationsObject = typeof customTranslations.en
export type CustomTranslationsObject = typeof customTranslations.en & typeof enTranslations
export type CustomTranslationsKeys = NestedKeysStripped<CustomTranslationsObject>
```
@@ -259,7 +261,10 @@ const field: Field = {
name: 'myField',
type: 'text',
label: (
{ t }: { t: TFunction<CustomTranslationsKeys | DefaultTranslationKeys> }, // The generic passed to TFunction does not automatically merge the custom translations with the default translations. We need to merge them ourselves here
) => t('fields:addLabel'),
{ t: defaultT }
) => {
const t = defaultT as TFunction<CustomTranslationsKeys>
return t('fields:addLabel')
},
}
```

View File

@@ -118,6 +118,10 @@ default, migrations will be named using a timestamp.
npm run payload migrate:create optional-name-here
```
Flags:
* `--skip-empty`: with Postgres, it skips the "no schema changes detected. Would you like to create a blank migration file?" prompt which can be useful for generating migration in CI.
* `--force-accept-warning`: accepts any command prompts, creates a blank migration even if there weren't any changes to the schema.
### Status
The `migrate:status` command will check the status of migrations and output a table of which migrations have been run,

View File

@@ -286,14 +286,15 @@ export const MyField: Field = {
The following additional properties are provided in the `ctx` object:
| Property | Description |
| Property | Description |
| --- | --- |
| `data` | An object containing the full collection or global document currently being edited. |
| `siblingData` | An object containing document data that is scoped to only fields within the same parent of this field. |
| `operation` | Will be `create` or `update` depending on the UI action or API call. |
| `id` | The `id` of the current document being edited. `id` is `undefined` during the `create` operation. |
| `req` | The current HTTP request object. Contains `payload`, `user`, etc. |
| `event` | Either `onChange` or `submit` depending on the current action. Used as a performance opt-in. [More details](#async-field-validations). |
| `data` | An object containing the full collection or global document currently being edited. |
| `siblingData` | An object containing document data that is scoped to only fields within the same parent of this field. |
| `operation` | Will be `create` or `update` depending on the UI action or API call. |
| `path` | The full path to the field in the schema, represented as an array of string segments, including array indexes. I.e `['group', 'myArray', '1', 'textField']`. |
| `id` | The `id` of the current document being edited. `id` is `undefined` during the `create` operation. |
| `req` | The current HTTP request object. Contains `payload`, `user`, etc. |
| `event` | Either `onChange` or `submit` depending on the current action. Used as a performance opt-in. [More details](#async-field-validations). |
#### Reusing Default Field Validations
@@ -522,11 +523,11 @@ You can show and hide fields based on what other fields are doing by utilizing c
The `ctx` object:
| Property | Description |
| Property | Description |
| --- | --- |
| **`blockData`** | The nearest parent block's data. If the field is not inside a block, this will be `undefined`. |
| **`path`** | The full path to the field in the schema, including array indexes. Useful for dynamic lookups. |
| **`user`** | The currently authenticated user object. |
| **`blockData`** | The nearest parent block's data. If the field is not inside a block, this will be `undefined`. |
| **`path`** | The full path to the field in the schema, represented as an array of string segments, including array indexes. I.e `['group', 'myArray', '1', 'textField']`. |
| **`user`** | The currently authenticated user object. |
The `condition` function should return a boolean that will control if the field should be displayed or not.

View File

@@ -68,7 +68,7 @@ If your front-end is statically generated then you may also want to regenerate t
### Admin Bar
You might also want to render an admin bar on your front-end so that logged-in users can quickly navigate between the front-end and Payload as they're editing. For React apps, check out the official [Payload Admin Bar](https://github.com/payloadcms/payload-admin-bar). For other frameworks, simply hit the `/me` route with `credentials: 'include'` and render your own admin bar if the user is logged in.
You might also want to render an admin bar on your front-end so that logged-in users can quickly navigate between the front-end and Payload as they're editing. For React apps, check out the official [Payload Admin Bar](https://github.com/payloadcms/payload/tree/main/packages/admin-bar). For other frameworks, simply hit the `/me` route with `credentials: 'include'` and render your own admin bar if the user is logged in.
### CORS

View File

@@ -16,6 +16,7 @@
"start": "cross-env NODE_OPTIONS=--no-deprecation next start"
},
"dependencies": {
"@payloadcms/admin-bar": "latest",
"@payloadcms/db-mongodb": "latest",
"@payloadcms/next": "latest",
"@payloadcms/richtext-slate": "latest",
@@ -25,7 +26,6 @@
"graphql": "^16.9.0",
"next": "^15.0.0",
"payload": "latest",
"payload-admin-bar": "^1.0.7",
"react": "19.0.0",
"react-dom": "19.0.0"
},

5671
examples/draft-preview/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,9 @@
'use client'
import type { PayloadAdminBarProps } from 'payload-admin-bar'
import type { PayloadAdminBarProps } from '@payloadcms/admin-bar'
import { useRouter } from 'next/navigation'
import { PayloadAdminBar } from 'payload-admin-bar'
import { PayloadAdminBar } from '@payloadcms/admin-bar'
import React, { useState } from 'react'
import { Gutter } from '../Gutter'
@@ -42,7 +42,7 @@ export const AdminBar: React.FC<{
user: classes.user,
}}
cmsURL={process.env.NEXT_PUBLIC_SERVER_URL}
collection={collection}
collectionSlug={collection}
collectionLabels={{
plural: collectionLabels[collection]?.plural || 'Pages',
singular: collectionLabels[collection]?.singular || 'Page',

View File

@@ -52,7 +52,7 @@ export const home: Page = {
children: [{ text: 'Payload Admin Bar' }],
linkType: 'custom',
newTab: true,
url: 'https://github.com/payloadcms/payload-admin-bar',
url: 'https://github.com/payloadcms/payload/tree/main/packages/admin-bar',
},
{
text: ' appear at the top of this site. This will allow you to seamlessly navigate between the two apps. Then, navigate to the ',

View File

@@ -42,7 +42,6 @@
"next": "^15.1.0",
"next-intl": "^3.23.2",
"payload": "latest",
"payload-admin-bar": "^1.0.7",
"prism-react-renderer": "^2.3.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",

View File

@@ -83,9 +83,6 @@ importers:
payload:
specifier: latest
version: 3.25.0(graphql@16.10.0)(typescript@5.7.3)
payload-admin-bar:
specifier: ^1.0.7
version: 1.0.7(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
prism-react-renderer:
specifier: ^2.3.1
version: 2.4.1(react@19.0.0)
@@ -3354,12 +3351,6 @@ packages:
resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==}
engines: {node: '>=8'}
payload-admin-bar@1.0.7:
resolution: {integrity: sha512-eY/FjfCGkyXOxRupv4IPZ+HFh8CQnJBQS++VItgTXe/g9H0B4RqxfdpU3g3tART3e8MzmZYGOBxV5EGGO2+jbg==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
payload@3.25.0:
resolution: {integrity: sha512-azT1qtirV8QqIPpyWaxbF5TJPoWT5fpYoxin83wZxF5gmg0O06bL5YKCGFfCpzgCcw4FrFtLSzD68zGMc5m5Eg==}
engines: {node: ^18.20.2 || >=20.9.0}
@@ -8386,11 +8377,6 @@ snapshots:
path-type@4.0.0: {}
payload-admin-bar@1.0.7(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
dependencies:
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
payload@3.25.0(graphql@16.10.0)(typescript@5.7.3):
dependencies:
'@next/env': 15.2.0

View File

@@ -5,7 +5,6 @@ import { GeistMono } from 'geist/font/mono'
import { GeistSans } from 'geist/font/sans'
import React from 'react'
import { AdminBar } from '@/components/AdminBar'
import { Footer } from '@/globals/Footer/Component'
import { Header } from '@/globals/Header/Component'
import { LivePreviewListener } from '@/components/LivePreviewListener'
@@ -57,13 +56,7 @@ export default async function RootLayout({ children, params }: Args) {
<body>
<Providers>
<NextIntlClientProvider messages={messages}>
<AdminBar
adminBarProps={{
preview: isEnabled,
}}
/>
<LivePreviewListener />
<Header locale={locale} />
{children}
<Footer locale={locale} />

View File

@@ -1,7 +0,0 @@
@import '~@payloadcms/ui/scss';
.admin-bar {
@include small-break {
display: none;
}
}

View File

@@ -1,85 +0,0 @@
'use client'
import type { PayloadAdminBarProps } from 'payload-admin-bar'
import { cn } from '@/utilities/ui'
import { useSelectedLayoutSegments } from 'next/navigation'
import { PayloadAdminBar } from 'payload-admin-bar'
import React, { useState } from 'react'
import { useRouter } from 'next/navigation'
import './index.scss'
import { useTranslations } from 'next-intl'
const baseClass = 'admin-bar'
const collectionLabels = {
pages: {
plural: 'Pages',
singular: 'Page',
},
posts: {
plural: 'Posts',
singular: 'Post',
},
projects: {
plural: 'Projects',
singular: 'Project',
},
}
export const AdminBar: React.FC<{
adminBarProps?: PayloadAdminBarProps
}> = (props) => {
const { adminBarProps } = props || {}
const segments = useSelectedLayoutSegments()
const [show, setShow] = useState(false)
const collection = collectionLabels?.[segments?.[1]] ? segments?.[1] : 'pages'
const router = useRouter()
const t = useTranslations()
const onAuthChange = React.useCallback((user) => {
setShow(user?.id)
}, [])
return (
<div
className={cn(baseClass, 'py-2 bg-black text-white', {
block: show,
hidden: !show,
})}
>
<div className="container">
<PayloadAdminBar
{...adminBarProps}
className="py-2 text-white"
classNames={{
controls: 'font-medium text-white',
logo: 'text-white',
user: 'text-white',
}}
cmsURL={process.env.NEXT_PUBLIC_SERVER_URL}
collection={collection}
collectionLabels={{
plural: collectionLabels[collection]?.plural || 'Pages',
singular: collectionLabels[collection]?.singular || 'Page',
}}
logo={<span>{t('dashboard')}</span>}
onAuthChange={onAuthChange}
onPreviewExit={() => {
fetch('/next/exit-preview').then(() => {
router.push('/')
router.refresh()
})
}}
style={{
backgroundColor: 'transparent',
padding: 0,
position: 'relative',
zIndex: 'unset',
}}
/>
</div>
</div>
)
}

View File

@@ -12,54 +12,57 @@ const withBundleAnalyzer = bundleAnalyzer({
})
const config = withBundleAnalyzer(
withPayload({
eslint: {
ignoreDuringBuilds: true,
},
typescript: {
ignoreBuildErrors: true,
},
experimental: {
fullySpecified: true,
serverActions: {
bodySizeLimit: '5mb',
withPayload(
{
eslint: {
ignoreDuringBuilds: true,
},
typescript: {
ignoreBuildErrors: true,
},
experimental: {
fullySpecified: true,
serverActions: {
bodySizeLimit: '5mb',
},
},
env: {
PAYLOAD_CORE_DEV: 'true',
ROOT_DIR: path.resolve(dirname),
// @todo remove in 4.0 - will behave like this by default in 4.0
PAYLOAD_DO_NOT_SANITIZE_LOCALIZED_PROPERTY: 'true',
},
async redirects() {
return [
{
destination: '/admin',
permanent: false,
source: '/',
},
]
},
images: {
domains: ['localhost'],
},
webpack: (webpackConfig) => {
webpackConfig.resolve.extensionAlias = {
'.cjs': ['.cts', '.cjs'],
'.js': ['.ts', '.tsx', '.js', '.jsx'],
'.mjs': ['.mts', '.mjs'],
}
// Ignore sentry warnings when not wrapped with withSentryConfig
webpackConfig.ignoreWarnings = [
...(webpackConfig.ignoreWarnings ?? []),
{ file: /esm\/platform\/node\/instrumentation.js/ },
{ module: /esm\/platform\/node\/instrumentation.js/ },
]
return webpackConfig
},
},
env: {
PAYLOAD_CORE_DEV: 'true',
ROOT_DIR: path.resolve(dirname),
// @todo remove in 4.0 - will behave like this by default in 4.0
PAYLOAD_DO_NOT_SANITIZE_LOCALIZED_PROPERTY: 'true',
},
async redirects() {
return [
{
destination: '/admin',
permanent: false,
source: '/',
},
]
},
images: {
domains: ['localhost'],
},
webpack: (webpackConfig) => {
webpackConfig.resolve.extensionAlias = {
'.cjs': ['.cts', '.cjs'],
'.js': ['.ts', '.tsx', '.js', '.jsx'],
'.mjs': ['.mts', '.mjs'],
}
// Ignore sentry warnings when not wrapped with withSentryConfig
webpackConfig.ignoreWarnings = [
...(webpackConfig.ignoreWarnings ?? []),
{ file: /esm\/platform\/node\/instrumentation.js/ },
{ module: /esm\/platform\/node\/instrumentation.js/ },
]
return webpackConfig
},
}),
{ devBundleServerPackages: false },
),
)
export default process.env.NEXT_PUBLIC_SENTRY_DSN

View File

@@ -1,6 +1,6 @@
{
"name": "payload-monorepo",
"version": "3.27.0",
"version": "3.28.0",
"private": true,
"type": "module",
"scripts": {
@@ -118,7 +118,7 @@
"devDependencies": {
"@jest/globals": "29.7.0",
"@libsql/client": "0.14.0",
"@next/bundle-analyzer": "15.2.0",
"@next/bundle-analyzer": "15.2.2",
"@payloadcms/db-postgres": "workspace:*",
"@payloadcms/eslint-config": "workspace:*",
"@payloadcms/eslint-plugin": "workspace:*",
@@ -154,7 +154,7 @@
"lint-staged": "15.2.7",
"minimist": "1.2.8",
"mongodb-memory-server": "^10",
"next": "15.2.0",
"next": "15.2.2",
"open": "^10.1.0",
"p-limit": "^5.0.0",
"playwright": "1.50.0",

View File

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

View File

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

View File

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

View File

@@ -67,7 +67,7 @@ export const updateVersion: UpdateVersion = async function updateVersion(
return null
}
transform({ adapter: this, data: doc, fields, operation: 'write' })
transform({ adapter: this, data: doc, fields, operation: 'read' })
return doc
}

View File

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

View File

@@ -1,5 +1,5 @@
import type { DrizzleAdapter } from '@payloadcms/drizzle/types'
import type { Connect, Payload } from 'payload'
import type { Connect, Migration, Payload } from 'payload'
import { pushDevSchema } from '@payloadcms/drizzle'
import { drizzle } from 'drizzle-orm/node-postgres'
@@ -75,7 +75,8 @@ export const connect: Connect = async function connect(
this.payload.logger.info('---- DROPPED TABLES ----')
}
}
} catch (err) {
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error))
if (err.message?.match(/database .* does not exist/i) && !this.disableCreateDatabase) {
// capitalize first char of the err msg
this.payload.logger.info(
@@ -83,7 +84,7 @@ export const connect: Connect = async function connect(
)
const isCreated = await this.createDatabase()
if (isCreated) {
if (isCreated && this.connect) {
await this.connect(options)
return
}
@@ -116,6 +117,6 @@ export const connect: Connect = async function connect(
}
if (process.env.NODE_ENV === 'production' && this.prodMigrations) {
await this.migrate({ migrations: this.prodMigrations })
await this.migrate({ migrations: this.prodMigrations as unknown as Migration[] })
}
}

View File

@@ -79,13 +79,17 @@ export function postgresAdapter(args: Args): DatabaseAdapterObj<PostgresAdapter>
if (args.schemaName) {
adapterSchema = pgSchema(args.schemaName)
} else {
// @ts-expect-error - vestiges of when tsconfig was not strict. Feel free to improve
adapterSchema = { enum: pgEnum, table: pgTable }
}
const extensions = (args.extensions ?? []).reduce((acc, name) => {
acc[name] = true
return acc
}, {})
const extensions = (args.extensions ?? []).reduce(
(acc, name) => {
acc[name] = true
return acc
},
{} as Record<string, boolean>,
)
return createDatabaseAdapter<PostgresAdapter>({
name: 'postgres',
@@ -102,6 +106,7 @@ export function postgresAdapter(args: Args): DatabaseAdapterObj<PostgresAdapter>
}),
defaultDrizzleSnapshot,
disableCreateDatabase: args.disableCreateDatabase ?? false,
// @ts-expect-error - vestiges of when tsconfig was not strict. Feel free to improve
drizzle: undefined,
enums: {},
extensions,
@@ -123,9 +128,11 @@ export function postgresAdapter(args: Args): DatabaseAdapterObj<PostgresAdapter>
logger: args.logger,
operators: operatorMap,
pgSchema: adapterSchema,
// @ts-expect-error - vestiges of when tsconfig was not strict. Feel free to improve
pool: undefined,
poolOptions: args.pool,
prodMigrations: args.prodMigrations,
// @ts-expect-error - vestiges of when tsconfig was not strict. Feel free to improve
push: args.push,
relations: {},
relationshipsSuffix: args.relationshipsSuffix || '_rels',
@@ -163,6 +170,7 @@ export function postgresAdapter(args: Args): DatabaseAdapterObj<PostgresAdapter>
find,
findGlobal,
findGlobalVersions,
// @ts-expect-error - vestiges of when tsconfig was not strict. Feel free to improve
findOne,
findVersions,
indexes: new Set<string>(),
@@ -180,8 +188,10 @@ export function postgresAdapter(args: Args): DatabaseAdapterObj<PostgresAdapter>
queryDrafts,
rawRelations: {},
rawTables: {},
// @ts-expect-error - vestiges of when tsconfig was not strict. Feel free to improve
rejectInitializing,
requireDrizzleKit,
// @ts-expect-error - vestiges of when tsconfig was not strict. Feel free to improve
resolveInitializing,
rollbackTransaction,
updateGlobal,

View File

@@ -1,10 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
/* TODO: remove the following lines */
"strict": false,
"noUncheckedIndexedAccess": false,
},
"references": [
{
"path": "../payload"

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/drizzle",
"version": "3.27.0",
"version": "3.28.0",
"description": "A library of shared functions used by different payload database adapters",
"homepage": "https://payloadcms.com",
"repository": {

View File

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

View File

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

View File

@@ -13,6 +13,7 @@ export const index = deepMerge(
rules: {
...reactHooks.configs.recommended.rules,
'@eslint-react/hooks-extra/no-direct-set-state-in-use-effect': 'off',
'@eslint-react/naming-convention/use-state': 'off',
},
},
{

View File

@@ -2,7 +2,7 @@ import js from '@eslint/js'
import tseslint from 'typescript-eslint'
import perfectionist from 'eslint-plugin-perfectionist'
import { configs as regexpPluginConfigs } from 'eslint-plugin-regexp'
import eslintConfigPrettier from 'eslint-config-prettier'
import eslintConfigPrettier from 'eslint-config-prettier/flat'
import payloadPlugin from '@payloadcms/eslint-plugin'
import reactExtends from './configs/react/index.mjs'
import jestExtends from './configs/jest/index.mjs'

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/eslint-config",
"version": "3.9.0",
"version": "3.28.0",
"description": "Payload styles for ESLint and Prettier",
"keywords": [],
"homepage": "https://payloadcms.com",
@@ -24,23 +24,22 @@
"test": "echo \"Error: no test specified\" && exit 1"
},
"dependencies": {
"@eslint-react/eslint-plugin": "1.16.1",
"@eslint/js": "9.14.0",
"@eslint-react/eslint-plugin": "1.31.0",
"@eslint/js": "9.22.0",
"@payloadcms/eslint-plugin": "workspace:*",
"@types/eslint": "9.6.1",
"@types/eslint__js": "8.42.3",
"@typescript-eslint/parser": "8.14.0",
"eslint": "9.14.0",
"eslint-config-prettier": "9.1.0",
"eslint-plugin-import-x": "4.4.2",
"eslint-plugin-jest": "28.9.0",
"eslint-plugin-jest-dom": "5.4.0",
"@typescript-eslint/parser": "8.26.1",
"eslint": "9.22.0",
"eslint-config-prettier": "10.1.1",
"eslint-plugin-import-x": "4.6.1",
"eslint-plugin-jest": "28.11.0",
"eslint-plugin-jest-dom": "5.5.0",
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-perfectionist": "3.9.1",
"eslint-plugin-react-hooks": "0.0.0-experimental-a4b2d0d5-20250203",
"eslint-plugin-regexp": "2.6.0",
"globals": "15.12.0",
"eslint-plugin-react-hooks": "0.0.0-experimental-d331ba04-20250307",
"eslint-plugin-regexp": "2.7.0",
"globals": "16.0.0",
"typescript": "5.7.3",
"typescript-eslint": "8.14.0"
"typescript-eslint": "8.26.1"
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/eslint-plugin",
"version": "3.9.0",
"version": "3.28.0",
"description": "Payload plugin for ESLint",
"keywords": [],
"homepage": "https://payloadcms.com",
@@ -24,22 +24,21 @@
"test": "echo \"Error: no test specified\" && exit 1"
},
"dependencies": {
"@eslint-react/eslint-plugin": "1.16.1",
"@eslint/js": "9.14.0",
"@eslint-react/eslint-plugin": "1.31.0",
"@eslint/js": "9.22.0",
"@types/eslint": "9.6.1",
"@types/eslint__js": "8.42.3",
"@typescript-eslint/parser": "8.14.0",
"eslint": "9.14.0",
"eslint-config-prettier": "9.1.0",
"eslint-plugin-import-x": "4.4.2",
"eslint-plugin-jest": "28.9.0",
"eslint-plugin-jest-dom": "5.4.0",
"@typescript-eslint/parser": "8.26.1",
"eslint": "9.22.0",
"eslint-config-prettier": "10.1.1",
"eslint-plugin-import-x": "4.6.1",
"eslint-plugin-jest": "28.11.0",
"eslint-plugin-jest-dom": "5.5.0",
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-perfectionist": "3.9.1",
"eslint-plugin-react-hooks": "0.0.0-experimental-a4b2d0d5-20250203",
"eslint-plugin-regexp": "2.6.0",
"globals": "15.12.0",
"eslint-plugin-react-hooks": "0.0.0-experimental-d331ba04-20250307",
"eslint-plugin-regexp": "2.7.0",
"globals": "16.0.0",
"typescript": "5.7.3",
"typescript-eslint": "8.14.0"
"typescript-eslint": "8.26.1"
}
}

View File

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

View File

@@ -107,20 +107,20 @@ export function buildMutationInputType({
type = new GraphQLList(withNullableType({ type, field, forceNullable, parentIsLocalized }))
return {
...inputObjectTypeConfig,
[field.name]: { type },
[formatName(field.name)]: { type },
}
},
blocks: (inputObjectTypeConfig: InputObjectTypeConfig, field: BlocksField) => ({
...inputObjectTypeConfig,
[field.name]: { type: GraphQLJSON },
[formatName(field.name)]: { type: GraphQLJSON },
}),
checkbox: (inputObjectTypeConfig: InputObjectTypeConfig, field: CheckboxField) => ({
...inputObjectTypeConfig,
[field.name]: { type: GraphQLBoolean },
[formatName(field.name)]: { type: GraphQLBoolean },
}),
code: (inputObjectTypeConfig: InputObjectTypeConfig, field: CodeField) => ({
...inputObjectTypeConfig,
[field.name]: {
[formatName(field.name)]: {
type: withNullableType({ type: GraphQLString, field, forceNullable, parentIsLocalized }),
},
}),
@@ -134,13 +134,13 @@ export function buildMutationInputType({
}, inputObjectTypeConfig),
date: (inputObjectTypeConfig: InputObjectTypeConfig, field: DateField) => ({
...inputObjectTypeConfig,
[field.name]: {
[formatName(field.name)]: {
type: withNullableType({ type: GraphQLString, field, forceNullable, parentIsLocalized }),
},
}),
email: (inputObjectTypeConfig: InputObjectTypeConfig, field: EmailField) => ({
...inputObjectTypeConfig,
[field.name]: {
[formatName(field.name)]: {
type: withNullableType({ type: GraphQLString, field, forceNullable, parentIsLocalized }),
},
}),
@@ -165,12 +165,12 @@ export function buildMutationInputType({
}
return {
...inputObjectTypeConfig,
[field.name]: { type },
[formatName(field.name)]: { type },
}
},
json: (inputObjectTypeConfig: InputObjectTypeConfig, field: JSONField) => ({
...inputObjectTypeConfig,
[field.name]: {
[formatName(field.name)]: {
type: withNullableType({ type: GraphQLJSON, field, forceNullable, parentIsLocalized }),
},
}),
@@ -178,7 +178,7 @@ export function buildMutationInputType({
const type = field.name === 'id' ? GraphQLInt : GraphQLFloat
return {
...inputObjectTypeConfig,
[field.name]: {
[formatName(field.name)]: {
type: withNullableType({
type: field.hasMany === true ? new GraphQLList(type) : type,
field,
@@ -190,7 +190,7 @@ export function buildMutationInputType({
},
point: (inputObjectTypeConfig: InputObjectTypeConfig, field: PointField) => ({
...inputObjectTypeConfig,
[field.name]: {
[formatName(field.name)]: {
type: withNullableType({
type: new GraphQLList(GraphQLFloat),
field,
@@ -201,7 +201,7 @@ export function buildMutationInputType({
}),
radio: (inputObjectTypeConfig: InputObjectTypeConfig, field: RadioField) => ({
...inputObjectTypeConfig,
[field.name]: {
[formatName(field.name)]: {
type: withNullableType({ type: GraphQLString, field, forceNullable, parentIsLocalized }),
},
}),
@@ -247,12 +247,12 @@ export function buildMutationInputType({
return {
...inputObjectTypeConfig,
[field.name]: { type: field.hasMany ? new GraphQLList(type) : type },
[formatName(field.name)]: { type: field.hasMany ? new GraphQLList(type) : type },
}
},
richText: (inputObjectTypeConfig: InputObjectTypeConfig, field: RichTextField) => ({
...inputObjectTypeConfig,
[field.name]: {
[formatName(field.name)]: {
type: withNullableType({ type: GraphQLJSON, field, forceNullable, parentIsLocalized }),
},
}),
@@ -292,7 +292,7 @@ export function buildMutationInputType({
return {
...inputObjectTypeConfig,
[field.name]: { type },
[formatName(field.name)]: { type },
}
},
tabs: (inputObjectTypeConfig: InputObjectTypeConfig, field: TabsField) => {
@@ -336,7 +336,7 @@ export function buildMutationInputType({
},
text: (inputObjectTypeConfig: InputObjectTypeConfig, field: TextField) => ({
...inputObjectTypeConfig,
[field.name]: {
[formatName(field.name)]: {
type: withNullableType({
type: field.hasMany === true ? new GraphQLList(GraphQLString) : GraphQLString,
field,
@@ -347,7 +347,7 @@ export function buildMutationInputType({
}),
textarea: (inputObjectTypeConfig: InputObjectTypeConfig, field: TextareaField) => ({
...inputObjectTypeConfig,
[field.name]: {
[formatName(field.name)]: {
type: withNullableType({ type: GraphQLString, field, forceNullable, parentIsLocalized }),
},
}),
@@ -393,7 +393,7 @@ export function buildMutationInputType({
return {
...inputObjectTypeConfig,
[field.name]: { type: field.hasMany ? new GraphQLList(type) : type },
[formatName(field.name)]: { type: field.hasMany ? new GraphQLList(type) : type },
}
},
}

View File

@@ -1,58 +1,12 @@
import type { GraphQLFieldConfig, GraphQLType } from 'graphql'
import type {
ArrayField,
BlocksField,
CheckboxField,
CodeField,
CollapsibleField,
DateField,
EmailField,
Field,
GraphQLInfo,
GroupField,
JoinField,
JSONField,
NumberField,
PointField,
RadioField,
RelationshipField,
RichTextAdapter,
RichTextField,
RowField,
SanitizedConfig,
SelectField,
TabsField,
TextareaField,
TextField,
UploadField,
} from 'payload'
import type { GraphQLFieldConfig } from 'graphql'
import type { Field, GraphQLInfo, SanitizedConfig } from 'payload'
import {
GraphQLBoolean,
GraphQLEnumType,
GraphQLFloat,
GraphQLInt,
GraphQLList,
GraphQLNonNull,
GraphQLObjectType,
GraphQLString,
GraphQLUnionType,
} from 'graphql'
import { DateTimeResolver, EmailAddressResolver } from 'graphql-scalars'
import { combineQueries, createDataloaderCacheKey, MissingEditorProp, toWords } from 'payload'
import { tabHasName } from 'payload/shared'
import { GraphQLObjectType } from 'graphql'
import type { Context } from '../resolvers/types.js'
import { GraphQLJSON } from '../packages/graphql-type-json/index.js'
import { combineParentName } from '../utilities/combineParentName.js'
import { formatName } from '../utilities/formatName.js'
import { formatOptions } from '../utilities/formatOptions.js'
import { isFieldNullable } from './isFieldNullable.js'
import { withNullableType } from './withNullableType.js'
import { fieldToSchemaMap } from './fieldToSchemaMap.js'
export type ObjectTypeConfig = {
[path: string]: GraphQLFieldConfig<any, any>
[path: string]: GraphQLFieldConfig<any, any, any>
}
type Args = {
@@ -76,867 +30,6 @@ export function buildObjectType({
parentIsLocalized,
parentName,
}: Args): GraphQLObjectType {
const fieldToSchemaMap = {
array: (objectTypeConfig: ObjectTypeConfig, field: ArrayField) => {
const interfaceName =
field?.interfaceName || combineParentName(parentName, toWords(field.name, true))
if (!graphqlResult.types.arrayTypes[interfaceName]) {
const objectType = buildObjectType({
name: interfaceName,
config,
fields: field.fields,
forceNullable: isFieldNullable({ field, forceNullable, parentIsLocalized }),
graphqlResult,
parentIsLocalized: field.localized || parentIsLocalized,
parentName: interfaceName,
})
if (Object.keys(objectType.getFields()).length) {
graphqlResult.types.arrayTypes[interfaceName] = objectType
}
}
if (!graphqlResult.types.arrayTypes[interfaceName]) {
return objectTypeConfig
}
const arrayType = new GraphQLList(
new GraphQLNonNull(graphqlResult.types.arrayTypes[interfaceName]),
)
return {
...objectTypeConfig,
[field.name]: { type: withNullableType({ type: arrayType, field, parentIsLocalized }) },
}
},
blocks: (objectTypeConfig: ObjectTypeConfig, field: BlocksField) => {
const blockTypes: GraphQLObjectType<any, any>[] = (
field.blockReferences ?? field.blocks
).reduce((acc, _block) => {
const blockSlug = typeof _block === 'string' ? _block : _block.slug
if (!graphqlResult.types.blockTypes[blockSlug]) {
// TODO: iterate over blocks mapped to block slug in v4, or pass through payload.blocks
const block =
typeof _block === 'string' ? config.blocks.find((b) => b.slug === _block) : _block
const interfaceName =
block?.interfaceName || block?.graphQL?.singularName || toWords(block.slug, true)
const objectType = buildObjectType({
name: interfaceName,
config,
fields: [
...block.fields,
{
name: 'blockType',
type: 'text',
},
],
forceNullable,
graphqlResult,
parentIsLocalized,
parentName: interfaceName,
})
if (Object.keys(objectType.getFields()).length) {
graphqlResult.types.blockTypes[block.slug] = objectType
}
}
if (graphqlResult.types.blockTypes[blockSlug]) {
acc.push(graphqlResult.types.blockTypes[blockSlug])
}
return acc
}, [])
if (blockTypes.length === 0) {
return objectTypeConfig
}
const fullName = combineParentName(parentName, toWords(field.name, true))
const type = new GraphQLList(
new GraphQLNonNull(
new GraphQLUnionType({
name: fullName,
resolveType: (data) => graphqlResult.types.blockTypes[data.blockType].name,
types: blockTypes,
}),
),
)
return {
...objectTypeConfig,
[field.name]: { type: withNullableType({ type, field, parentIsLocalized }) },
}
},
checkbox: (objectTypeConfig: ObjectTypeConfig, field: CheckboxField) => ({
...objectTypeConfig,
[field.name]: {
type: withNullableType({ type: GraphQLBoolean, field, forceNullable, parentIsLocalized }),
},
}),
code: (objectTypeConfig: ObjectTypeConfig, field: CodeField) => ({
...objectTypeConfig,
[field.name]: {
type: withNullableType({ type: GraphQLString, field, forceNullable, parentIsLocalized }),
},
}),
collapsible: (objectTypeConfig: ObjectTypeConfig, field: CollapsibleField) =>
field.fields.reduce((objectTypeConfigWithCollapsibleFields, subField) => {
const addSubField = fieldToSchemaMap[subField.type]
if (addSubField) {
return addSubField(objectTypeConfigWithCollapsibleFields, subField)
}
return objectTypeConfigWithCollapsibleFields
}, objectTypeConfig),
date: (objectTypeConfig: ObjectTypeConfig, field: DateField) => ({
...objectTypeConfig,
[field.name]: {
type: withNullableType({ type: DateTimeResolver, field, forceNullable, parentIsLocalized }),
},
}),
email: (objectTypeConfig: ObjectTypeConfig, field: EmailField) => ({
...objectTypeConfig,
[field.name]: {
type: withNullableType({
type: EmailAddressResolver,
field,
forceNullable,
parentIsLocalized,
}),
},
}),
group: (objectTypeConfig: ObjectTypeConfig, field: GroupField) => {
const interfaceName =
field?.interfaceName || combineParentName(parentName, toWords(field.name, true))
if (!graphqlResult.types.groupTypes[interfaceName]) {
const objectType = buildObjectType({
name: interfaceName,
config,
fields: field.fields,
forceNullable: isFieldNullable({ field, forceNullable, parentIsLocalized }),
graphqlResult,
parentIsLocalized: field.localized || parentIsLocalized,
parentName: interfaceName,
})
if (Object.keys(objectType.getFields()).length) {
graphqlResult.types.groupTypes[interfaceName] = objectType
}
}
if (!graphqlResult.types.groupTypes[interfaceName]) {
return objectTypeConfig
}
return {
...objectTypeConfig,
[field.name]: {
type: graphqlResult.types.groupTypes[interfaceName],
resolve: (parent, args, context: Context) => {
return {
...parent[field.name],
_id: parent._id ?? parent.id,
}
},
},
}
},
join: (objectTypeConfig: ObjectTypeConfig, field: JoinField) => {
const joinName = combineParentName(parentName, toWords(field.name, true))
const joinType = {
type: new GraphQLObjectType({
name: joinName,
fields: {
docs: {
type: Array.isArray(field.collection)
? GraphQLJSON
: new GraphQLList(graphqlResult.collections[field.collection].graphQL.type),
},
hasNextPage: { type: GraphQLBoolean },
},
}),
args: {
limit: {
type: GraphQLInt,
},
page: {
type: GraphQLInt,
},
sort: {
type: GraphQLString,
},
where: {
type: Array.isArray(field.collection)
? GraphQLJSON
: graphqlResult.collections[field.collection].graphQL.whereInputType,
},
},
extensions: {
complexity:
typeof field?.graphQL?.complexity === 'number' ? field.graphQL.complexity : 10,
},
async resolve(parent, args, context: Context) {
const { collection } = field
const { limit, page, sort, where } = args
const { req } = context
const fullWhere = combineQueries(where, {
[field.on]: { equals: parent._id ?? parent.id },
})
if (Array.isArray(collection)) {
throw new Error('GraphQL with array of join.field.collection is not implemented')
}
return await req.payload.find({
collection,
depth: 0,
fallbackLocale: req.fallbackLocale,
limit,
locale: req.locale,
overrideAccess: false,
page,
req,
sort,
where: fullWhere,
})
},
}
return {
...objectTypeConfig,
[field.name]: joinType,
}
},
json: (objectTypeConfig: ObjectTypeConfig, field: JSONField) => ({
...objectTypeConfig,
[field.name]: {
type: withNullableType({ type: GraphQLJSON, field, forceNullable, parentIsLocalized }),
},
}),
number: (objectTypeConfig: ObjectTypeConfig, field: NumberField) => {
const type = field?.name === 'id' ? GraphQLInt : GraphQLFloat
return {
...objectTypeConfig,
[field.name]: {
type: withNullableType({
type: field?.hasMany === true ? new GraphQLList(type) : type,
field,
forceNullable,
parentIsLocalized,
}),
},
}
},
point: (objectTypeConfig: ObjectTypeConfig, field: PointField) => ({
...objectTypeConfig,
[field.name]: {
type: withNullableType({
type: new GraphQLList(new GraphQLNonNull(GraphQLFloat)),
field,
forceNullable,
parentIsLocalized,
}),
},
}),
radio: (objectTypeConfig: ObjectTypeConfig, field: RadioField) => ({
...objectTypeConfig,
[field.name]: {
type: withNullableType({
type: new GraphQLEnumType({
name: combineParentName(parentName, field.name),
values: formatOptions(field),
}),
field,
forceNullable,
parentIsLocalized,
}),
},
}),
relationship: (objectTypeConfig: ObjectTypeConfig, field: RelationshipField) => {
const { relationTo } = field
const isRelatedToManyCollections = Array.isArray(relationTo)
const hasManyValues = field.hasMany
const relationshipName = combineParentName(parentName, toWords(field.name, true))
let type
let relationToType = null
const graphQLCollections = config.collections.filter(
(collectionConfig) => collectionConfig.graphQL !== false,
)
if (Array.isArray(relationTo)) {
relationToType = new GraphQLEnumType({
name: `${relationshipName}_RelationTo`,
values: relationTo
.filter((relation) =>
graphQLCollections.some((collection) => collection.slug === relation),
)
.reduce(
(relations, relation) => ({
...relations,
[formatName(relation)]: {
value: relation,
},
}),
{},
),
})
// Only pass collections that are GraphQL enabled
const types = relationTo
.filter((relation) =>
graphQLCollections.some((collection) => collection.slug === relation),
)
.map((relation) => graphqlResult.collections[relation]?.graphQL.type)
type = new GraphQLObjectType({
name: `${relationshipName}_Relationship`,
fields: {
relationTo: {
type: relationToType,
},
value: {
type: new GraphQLUnionType({
name: relationshipName,
resolveType(data) {
return graphqlResult.collections[data.collection].graphQL.type.name
},
types,
}),
},
},
})
} else {
;({ type } = graphqlResult.collections[relationTo].graphQL)
}
// If the relationshipType is undefined at this point,
// it can be assumed that this blockType can have a relationship
// to itself. Therefore, we set the relationshipType equal to the blockType
// that is currently being created.
type = type || newlyCreatedBlockType
const relationshipArgs: {
draft?: unknown
fallbackLocale?: unknown
limit?: unknown
locale?: unknown
page?: unknown
where?: unknown
} = {}
const relationsUseDrafts = (Array.isArray(relationTo) ? relationTo : [relationTo])
.filter((relation) => graphQLCollections.some((collection) => collection.slug === relation))
.some((relation) => graphqlResult.collections[relation].config.versions?.drafts)
if (relationsUseDrafts) {
relationshipArgs.draft = {
type: GraphQLBoolean,
}
}
if (config.localization) {
relationshipArgs.locale = {
type: graphqlResult.types.localeInputType,
}
relationshipArgs.fallbackLocale = {
type: graphqlResult.types.fallbackLocaleInputType,
}
}
const relationship = {
type: withNullableType({
type: hasManyValues ? new GraphQLList(new GraphQLNonNull(type)) : type,
field,
forceNullable,
parentIsLocalized,
}),
args: relationshipArgs,
extensions: {
complexity:
typeof field?.graphQL?.complexity === 'number' ? field.graphQL.complexity : 10,
},
async resolve(parent, args, context: Context) {
const value = parent[field.name]
const locale = args.locale || context.req.locale
const fallbackLocale = args.fallbackLocale || context.req.fallbackLocale
let relatedCollectionSlug = field.relationTo
const draft = Boolean(args.draft ?? context.req.query?.draft)
if (hasManyValues) {
const results = []
const resultPromises = []
const createPopulationPromise = async (relatedDoc, i) => {
let id = relatedDoc
let collectionSlug = field.relationTo
const isValidGraphQLCollection = isRelatedToManyCollections
? graphQLCollections.some((collection) => collectionSlug.includes(collection.slug))
: graphQLCollections.some((collection) => collectionSlug === collection.slug)
if (isValidGraphQLCollection) {
if (isRelatedToManyCollections) {
collectionSlug = relatedDoc.relationTo
id = relatedDoc.value
}
const result = await context.req.payloadDataLoader.load(
createDataloaderCacheKey({
collectionSlug: collectionSlug as string,
currentDepth: 0,
depth: 0,
docID: id,
draft,
fallbackLocale,
locale,
overrideAccess: false,
showHiddenFields: false,
transactionID: context.req.transactionID,
}),
)
if (result) {
if (isRelatedToManyCollections) {
results[i] = {
relationTo: collectionSlug,
value: {
...result,
collection: collectionSlug,
},
}
} else {
results[i] = result
}
}
}
}
if (value) {
value.forEach((relatedDoc, i) => {
resultPromises.push(createPopulationPromise(relatedDoc, i))
})
}
await Promise.all(resultPromises)
return results
}
let id = value
if (isRelatedToManyCollections && value) {
id = value.value
relatedCollectionSlug = value.relationTo
}
if (id) {
if (
graphQLCollections.some((collection) => collection.slug === relatedCollectionSlug)
) {
const relatedDocument = await context.req.payloadDataLoader.load(
createDataloaderCacheKey({
collectionSlug: relatedCollectionSlug as string,
currentDepth: 0,
depth: 0,
docID: id,
draft,
fallbackLocale,
locale,
overrideAccess: false,
showHiddenFields: false,
transactionID: context.req.transactionID,
}),
)
if (relatedDocument) {
if (isRelatedToManyCollections) {
return {
relationTo: relatedCollectionSlug,
value: {
...relatedDocument,
collection: relatedCollectionSlug,
},
}
}
return relatedDocument
}
}
return null
}
return null
},
}
return {
...objectTypeConfig,
[field.name]: relationship,
}
},
richText: (objectTypeConfig: ObjectTypeConfig, field: RichTextField) => ({
...objectTypeConfig,
[field.name]: {
type: withNullableType({ type: GraphQLJSON, field, forceNullable, parentIsLocalized }),
args: {
depth: {
type: GraphQLInt,
},
},
async resolve(parent, args, context: Context) {
let depth = config.defaultDepth
if (typeof args.depth !== 'undefined') {
depth = args.depth
}
if (!field?.editor) {
throw new MissingEditorProp(field) // while we allow disabling editor functionality, you should not have any richText fields defined if you do not have an editor
}
if (typeof field?.editor === 'function') {
throw new Error('Attempted to access unsanitized rich text editor.')
}
const editor: RichTextAdapter = field?.editor
// RichText fields have their own depth argument in GraphQL.
// This is why the populationPromise (which populates richtext fields like uploads and relationships)
// is run here again, with the provided depth.
// In the graphql find.ts resolver, the depth is then hard-coded to 0.
// Effectively, this means that the populationPromise for GraphQL is only run here, and not in the find.ts resolver / normal population promise.
if (editor?.graphQLPopulationPromises) {
const fieldPromises = []
const populationPromises = []
const populateDepth =
field?.maxDepth !== undefined && field?.maxDepth < depth ? field?.maxDepth : depth
editor?.graphQLPopulationPromises({
context,
depth: populateDepth,
draft: args.draft,
field,
fieldPromises,
findMany: false,
flattenLocales: false,
overrideAccess: false,
parentIsLocalized,
populationPromises,
req: context.req,
showHiddenFields: false,
siblingDoc: parent,
})
await Promise.all(fieldPromises)
await Promise.all(populationPromises)
}
return parent[field.name]
},
},
}),
row: (objectTypeConfig: ObjectTypeConfig, field: RowField) =>
field.fields.reduce((objectTypeConfigWithRowFields, subField) => {
const addSubField = fieldToSchemaMap[subField.type]
if (addSubField) {
return addSubField(objectTypeConfigWithRowFields, subField)
}
return objectTypeConfigWithRowFields
}, objectTypeConfig),
select: (objectTypeConfig: ObjectTypeConfig, field: SelectField) => {
const fullName = combineParentName(parentName, field.name)
let type: GraphQLType = new GraphQLEnumType({
name: fullName,
values: formatOptions(field),
})
type = field.hasMany ? new GraphQLList(new GraphQLNonNull(type)) : type
type = withNullableType({ type, field, forceNullable, parentIsLocalized })
return {
...objectTypeConfig,
[field.name]: { type },
}
},
tabs: (objectTypeConfig: ObjectTypeConfig, field: TabsField) =>
field.tabs.reduce((tabSchema, tab) => {
if (tabHasName(tab)) {
const interfaceName =
tab?.interfaceName || combineParentName(parentName, toWords(tab.name, true))
if (!graphqlResult.types.groupTypes[interfaceName]) {
const objectType = buildObjectType({
name: interfaceName,
config,
fields: tab.fields,
forceNullable,
graphqlResult,
parentIsLocalized: tab.localized || parentIsLocalized,
parentName: interfaceName,
})
if (Object.keys(objectType.getFields()).length) {
graphqlResult.types.groupTypes[interfaceName] = objectType
}
}
if (!graphqlResult.types.groupTypes[interfaceName]) {
return tabSchema
}
return {
...tabSchema,
[tab.name]: {
type: graphqlResult.types.groupTypes[interfaceName],
resolve(parent, args, context: Context) {
return {
...parent[tab.name],
_id: parent._id ?? parent.id,
}
},
},
}
}
return {
...tabSchema,
...tab.fields.reduce((subFieldSchema, subField) => {
const addSubField = fieldToSchemaMap[subField.type]
if (addSubField) {
return addSubField(subFieldSchema, subField)
}
return subFieldSchema
}, tabSchema),
}
}, objectTypeConfig),
text: (objectTypeConfig: ObjectTypeConfig, field: TextField) => ({
...objectTypeConfig,
[field.name]: {
type: withNullableType({
type: field.hasMany === true ? new GraphQLList(GraphQLString) : GraphQLString,
field,
forceNullable,
parentIsLocalized,
}),
},
}),
textarea: (objectTypeConfig: ObjectTypeConfig, field: TextareaField) => ({
...objectTypeConfig,
[field.name]: {
type: withNullableType({ type: GraphQLString, field, forceNullable, parentIsLocalized }),
},
}),
upload: (objectTypeConfig: ObjectTypeConfig, field: UploadField) => {
const { relationTo } = field
const isRelatedToManyCollections = Array.isArray(relationTo)
const hasManyValues = field.hasMany
const relationshipName = combineParentName(parentName, toWords(field.name, true))
let type
let relationToType = null
if (Array.isArray(relationTo)) {
relationToType = new GraphQLEnumType({
name: `${relationshipName}_RelationTo`,
values: relationTo.reduce(
(relations, relation) => ({
...relations,
[formatName(relation)]: {
value: relation,
},
}),
{},
),
})
const types = relationTo.map((relation) => graphqlResult.collections[relation].graphQL.type)
type = new GraphQLObjectType({
name: `${relationshipName}_Relationship`,
fields: {
relationTo: {
type: relationToType,
},
value: {
type: new GraphQLUnionType({
name: relationshipName,
resolveType(data) {
return graphqlResult.collections[data.collection].graphQL.type.name
},
types,
}),
},
},
})
} else {
;({ type } = graphqlResult.collections[relationTo].graphQL)
}
// If the relationshipType is undefined at this point,
// it can be assumed that this blockType can have a relationship
// to itself. Therefore, we set the relationshipType equal to the blockType
// that is currently being created.
type = type || newlyCreatedBlockType
const relationshipArgs: {
draft?: unknown
fallbackLocale?: unknown
limit?: unknown
locale?: unknown
page?: unknown
where?: unknown
} = {}
const relationsUseDrafts = (Array.isArray(relationTo) ? relationTo : [relationTo]).some(
(relation) => graphqlResult.collections[relation].config.versions?.drafts,
)
if (relationsUseDrafts) {
relationshipArgs.draft = {
type: GraphQLBoolean,
}
}
if (config.localization) {
relationshipArgs.locale = {
type: graphqlResult.types.localeInputType,
}
relationshipArgs.fallbackLocale = {
type: graphqlResult.types.fallbackLocaleInputType,
}
}
const relationship = {
type: withNullableType({
type: hasManyValues ? new GraphQLList(new GraphQLNonNull(type)) : type,
field,
forceNullable,
parentIsLocalized,
}),
args: relationshipArgs,
extensions: {
complexity:
typeof field?.graphQL?.complexity === 'number' ? field.graphQL.complexity : 10,
},
async resolve(parent, args, context: Context) {
const value = parent[field.name]
const locale = args.locale || context.req.locale
const fallbackLocale = args.fallbackLocale || context.req.fallbackLocale
let relatedCollectionSlug = field.relationTo
const draft = Boolean(args.draft ?? context.req.query?.draft)
if (hasManyValues) {
const results = []
const resultPromises = []
const createPopulationPromise = async (relatedDoc, i) => {
let id = relatedDoc
let collectionSlug = field.relationTo
if (isRelatedToManyCollections) {
collectionSlug = relatedDoc.relationTo
id = relatedDoc.value
}
const result = await context.req.payloadDataLoader.load(
createDataloaderCacheKey({
collectionSlug,
currentDepth: 0,
depth: 0,
docID: id,
draft,
fallbackLocale,
locale,
overrideAccess: false,
showHiddenFields: false,
transactionID: context.req.transactionID,
}),
)
if (result) {
if (isRelatedToManyCollections) {
results[i] = {
relationTo: collectionSlug,
value: {
...result,
collection: collectionSlug,
},
}
} else {
results[i] = result
}
}
}
if (value) {
value.forEach((relatedDoc, i) => {
resultPromises.push(createPopulationPromise(relatedDoc, i))
})
}
await Promise.all(resultPromises)
return results
}
let id = value
if (isRelatedToManyCollections && value) {
id = value.value
relatedCollectionSlug = value.relationTo
}
if (id) {
const relatedDocument = await context.req.payloadDataLoader.load(
createDataloaderCacheKey({
collectionSlug: relatedCollectionSlug,
currentDepth: 0,
depth: 0,
docID: id,
draft,
fallbackLocale,
locale,
overrideAccess: false,
showHiddenFields: false,
transactionID: context.req.transactionID,
}),
)
if (relatedDocument) {
if (isRelatedToManyCollections) {
return {
relationTo: relatedCollectionSlug,
value: {
...relatedDocument,
collection: relatedCollectionSlug,
},
}
}
return relatedDocument
}
return null
}
return null
},
}
return {
...objectTypeConfig,
[field.name]: relationship,
}
},
}
const objectSchema = {
name,
fields: () =>
@@ -949,7 +42,16 @@ export function buildObjectType({
return {
...objectTypeConfig,
...fieldSchema(objectTypeConfig, field),
...fieldSchema({
config,
field,
forceNullable,
graphqlResult,
newlyCreatedBlockType,
objectTypeConfig,
parentIsLocalized,
parentName,
}),
}
}, baseFields),
}

View File

@@ -60,7 +60,7 @@ const buildFields = (label, fieldsToBuild) =>
return {
...builtFields,
[field.name]: {
[formatName(field.name)]: {
type: new GraphQLObjectType({
name: `${label}_${fieldName}`,
fields: objectTypeFields,

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,2 +1,3 @@
export { NotFoundPage } from '../views/NotFound/index.js'
export { generatePageMetadata, type GenerateViewMetadata, RootPage } from '../views/Root/index.js'
export { type GenerateViewMetadata, RootPage } from '../views/Root/index.js'
export { generatePageMetadata } from '../views/Root/metadata.js'

View File

@@ -1,53 +1,53 @@
import type { Metadata } from 'next'
import type { IconConfig, MetaConfig } from 'payload'
import type { Icon } from 'next/dist/lib/metadata/types/metadata-types.js'
import type { MetaConfig } from 'payload'
import { payloadFaviconDark, payloadFaviconLight, staticOGImage } from '@payloadcms/ui/assets'
import * as qs from 'qs-esm'
const defaultOpenGraph = {
const defaultOpenGraph: Metadata['openGraph'] = {
description:
'Payload is a headless CMS and application framework built with TypeScript, Node.js, and React.',
siteName: 'Payload App',
title: 'Payload App',
}
export const meta = async (args: { serverURL: string } & MetaConfig): Promise<any> => {
const {
defaultOGImageType,
description,
icons: customIcons,
keywords,
openGraph: openGraphFromProps,
serverURL,
title,
titleSuffix,
} = args
export const generateMetadata = async (
args: { serverURL: string } & MetaConfig,
): Promise<Metadata> => {
const { defaultOGImageType, serverURL, titleSuffix, ...rest } = args
const payloadIcons: IconConfig[] = [
{
type: 'image/png',
rel: 'icon',
sizes: '32x32',
url: typeof payloadFaviconDark === 'object' ? payloadFaviconDark?.src : payloadFaviconDark,
},
{
type: 'image/png',
media: '(prefers-color-scheme: dark)',
rel: 'icon',
sizes: '32x32',
url: typeof payloadFaviconLight === 'object' ? payloadFaviconLight?.src : payloadFaviconLight,
},
]
/**
* @todo find a way to remove the type assertion here.
* It is a result of needing to `DeepCopy` the `MetaConfig` type from Payload.
* This is required for the `DeepRequired` from `Config` to `SanitizedConfig`.
*/
const incomingMetadata = rest as Metadata
let icons = payloadIcons
const icons: Metadata['icons'] =
incomingMetadata.icons ||
([
{
type: 'image/png',
rel: 'icon',
sizes: '32x32',
url: typeof payloadFaviconDark === 'object' ? payloadFaviconDark?.src : payloadFaviconDark,
},
{
type: 'image/png',
media: '(prefers-color-scheme: dark)',
rel: 'icon',
sizes: '32x32',
url:
typeof payloadFaviconLight === 'object' ? payloadFaviconLight?.src : payloadFaviconLight,
},
] satisfies Array<Icon>)
if (customIcons && typeof customIcons === 'object' && Array.isArray(customIcons)) {
icons = customIcons
}
const metaTitle: Metadata['title'] = [incomingMetadata.title, titleSuffix]
.filter(Boolean)
.join(' ')
const metaTitle = [title, titleSuffix].filter(Boolean).join(' ')
const ogTitle = `${typeof openGraphFromProps?.title === 'string' ? openGraphFromProps.title : title} ${titleSuffix}`
const ogTitle = `${typeof incomingMetadata.openGraph?.title === 'string' ? incomingMetadata.openGraph.title : incomingMetadata.title} ${titleSuffix}`
const mergedOpenGraph: Metadata['openGraph'] = {
...(defaultOpenGraph || {}),
@@ -59,7 +59,8 @@ export const meta = async (args: { serverURL: string } & MetaConfig): Promise<an
height: 630,
url: `/api/og${qs.stringify(
{
description: openGraphFromProps?.description || defaultOpenGraph.description,
description:
incomingMetadata.openGraph?.description || defaultOpenGraph.description,
title: ogTitle,
},
{
@@ -84,13 +85,12 @@ export const meta = async (args: { serverURL: string } & MetaConfig): Promise<an
}
: {}),
title: ogTitle,
...(openGraphFromProps || {}),
...(incomingMetadata.openGraph || {}),
}
return Promise.resolve({
description,
...incomingMetadata,
icons,
keywords,
metadataBase: new URL(
serverURL ||
process.env.PAYLOAD_PUBLIC_SERVER_URL ||

View File

@@ -1,10 +1,15 @@
import type { MetaConfig } from 'payload'
import { getTranslation } from '@payloadcms/translations'
import type { GenerateEditViewMetadata } from '../Document/getMetaBySegment.js'
import { meta } from '../../utilities/meta.js'
import { generateMetadata } from '../../utilities/meta.js'
export const generateMetadata: GenerateEditViewMetadata = async ({
/**
* @todo Remove the `MetaConfig` type assertions. They are currently required because of how the `Metadata` type from `next` consumes the `URL` type.
*/
export const generateAPIViewMetadata: GenerateEditViewMetadata = async ({
collectionConfig,
config,
globalConfig,
@@ -17,24 +22,24 @@ export const generateMetadata: GenerateEditViewMetadata = async ({
: ''
return Promise.resolve(
meta({
generateMetadata({
...(config.admin.meta || {}),
description: `API - ${entityLabel}`,
keywords: 'API',
serverURL: config.serverURL,
title: `API - ${entityLabel}`,
...(collectionConfig
...((collectionConfig
? {
...(collectionConfig?.admin.meta || {}),
...(collectionConfig?.admin?.components?.views?.edit?.api?.meta || {}),
}
: {}),
...(globalConfig
: {}) as MetaConfig),
...((globalConfig
? {
...(globalConfig?.admin.meta || {}),
...(globalConfig?.admin?.components?.views?.edit?.api?.meta || {}),
}
: {}),
: {}) as MetaConfig),
}),
)
}

View File

@@ -16,8 +16,6 @@ import { EditView } from '../Edit/index.js'
import { AccountClient } from './index.client.js'
import { Settings } from './Settings/index.js'
export { generateAccountMetadata } from './meta.js'
export async function Account({ initPageResult, params, searchParams }: AdminViewServerProps) {
const {
languageOptions,

View File

@@ -1,9 +1,9 @@
import type { GenerateViewMetadata } from '../Root/index.js'
import { meta } from '../../utilities/meta.js'
import { generateMetadata } from '../../utilities/meta.js'
export const generateAccountMetadata: GenerateViewMetadata = async ({ config, i18n: { t } }) =>
meta({
export const generateAccountViewMetadata: GenerateViewMetadata = async ({ config, i18n: { t } }) =>
generateMetadata({
description: `${t('authentication:accountOfCurrentUser')}`,
keywords: `${t('authentication:account')}`,
serverURL: config.serverURL,

View File

@@ -9,8 +9,6 @@ import { getDocumentPermissions } from '../Document/getDocumentPermissions.js'
import { CreateFirstUserClient } from './index.client.js'
import './index.scss'
export { generateCreateFirstUserMetadata } from './meta.js'
export async function CreateFirstUserView({ initPageResult }: AdminViewServerProps) {
const {
locale,

View File

@@ -1,12 +1,12 @@
import type { GenerateViewMetadata } from '../Root/index.js'
import { meta } from '../../utilities/meta.js'
import { generateMetadata } from '../../utilities/meta.js'
export const generateCreateFirstUserMetadata: GenerateViewMetadata = async ({
export const generateCreateFirstUserViewMetadata: GenerateViewMetadata = async ({
config,
i18n: { t },
}) =>
meta({
generateMetadata({
description: t('authentication:createFirstUser'),
keywords: t('general:create'),
serverURL: config.serverURL,

View File

@@ -10,8 +10,6 @@ import type { DashboardViewClientProps, DashboardViewServerPropsOnly } from './D
import { DefaultDashboard } from './Default/index.js'
export { generateDashboardMetadata } from './meta.js'
export async function Dashboard({ initPageResult, params, searchParams }: AdminViewServerProps) {
const {
locale,

View File

@@ -1,16 +0,0 @@
import type { GenerateViewMetadata } from '../Root/index.js'
import { meta } from '../../utilities/meta.js'
export const generateDashboardMetadata: GenerateViewMetadata = async ({ config, i18n: { t } }) =>
meta({
description: `${t('general:dashboard')} Payload`,
keywords: `${t('general:dashboard')}, Payload`,
serverURL: config.serverURL,
title: t('general:dashboard'),
...(config.admin.meta || {}),
openGraph: {
title: t('general:dashboard'),
...(config.admin.meta?.openGraph || {}),
},
})

View File

@@ -0,0 +1,17 @@
import type { GenerateViewMetadata } from '../Root/index.js'
import { generateMetadata } from '../../utilities/meta.js'
export const generateDashboardViewMetadata: GenerateViewMetadata = async ({
config,
i18n: { t },
}) =>
generateMetadata({
serverURL: config.serverURL,
title: t('general:dashboard'),
...config.admin.meta,
openGraph: {
title: t('general:dashboard'),
...(config.admin.meta?.openGraph || {}),
},
})

View File

@@ -4,12 +4,12 @@ import type { EditConfig, SanitizedCollectionConfig, SanitizedGlobalConfig } fro
import type { GenerateViewMetadata } from '../Root/index.js'
import { getNextRequestI18n } from '../../utilities/getNextRequestI18n.js'
import { generateMetadata as apiMeta } from '../API/meta.js'
import { generateMetadata as editMeta } from '../Edit/meta.js'
import { generateMetadata as livePreviewMeta } from '../LivePreview/meta.js'
import { generateNotFoundMeta } from '../NotFound/meta.js'
import { generateMetadata as versionMeta } from '../Version/meta.js'
import { generateMetadata as versionsMeta } from '../Versions/meta.js'
import { generateAPIViewMetadata } from '../API/metadata.js'
import { generateEditViewMetadata } from '../Edit/metadata.js'
import { generateLivePreviewViewMetadata } from '../LivePreview/metadata.js'
import { generateNotFoundViewMetadata } from '../NotFound/metadata.js'
import { generateVersionViewMetadata } from '../Version/metadata.js'
import { generateVersionsViewMetadata } from '../Versions/metadata.js'
import { getViewsFromConfig } from './getViewsFromConfig.js'
export type GenerateEditViewMetadata = (
@@ -40,7 +40,7 @@ export const getMetaBySegment: GenerateEditViewMetadata = async ({
if (isCollection) {
// `/:collection/:id`
if (params.segments.length === 3) {
fn = editMeta
fn = generateEditViewMetadata
}
// `/:collection/:id/:view`
@@ -48,15 +48,15 @@ export const getMetaBySegment: GenerateEditViewMetadata = async ({
switch (params.segments[3]) {
case 'api':
// `/:collection/:id/api`
fn = apiMeta
fn = generateAPIViewMetadata
break
case 'preview':
// `/:collection/:id/preview`
fn = livePreviewMeta
fn = generateLivePreviewViewMetadata
break
case 'versions':
// `/:collection/:id/versions`
fn = versionsMeta
fn = generateVersionsViewMetadata
break
default:
break
@@ -68,7 +68,7 @@ export const getMetaBySegment: GenerateEditViewMetadata = async ({
switch (params.segments[3]) {
case 'versions':
// `/:collection/:id/versions/:version`
fn = versionMeta
fn = generateVersionViewMetadata
break
default:
break
@@ -79,7 +79,7 @@ export const getMetaBySegment: GenerateEditViewMetadata = async ({
if (isGlobal) {
// `/:global`
if (params.segments?.length === 2) {
fn = editMeta
fn = generateEditViewMetadata
}
// `/:global/:view`
@@ -87,15 +87,15 @@ export const getMetaBySegment: GenerateEditViewMetadata = async ({
switch (params.segments[2]) {
case 'api':
// `/:global/api`
fn = apiMeta
fn = generateAPIViewMetadata
break
case 'preview':
// `/:global/preview`
fn = livePreviewMeta
fn = generateLivePreviewViewMetadata
break
case 'versions':
// `/:global/versions`
fn = versionsMeta
fn = generateVersionsViewMetadata
break
default:
break
@@ -104,7 +104,7 @@ export const getMetaBySegment: GenerateEditViewMetadata = async ({
// `/:global/versions/:version`
if (params.segments?.length === 4 && params.segments[2] === 'versions') {
fn = versionMeta
fn = generateVersionViewMetadata
}
}
@@ -135,7 +135,7 @@ export const getMetaBySegment: GenerateEditViewMetadata = async ({
globalConfig?.admin?.components?.views?.edit?.[viewKey]
if (customViewConfig) {
return editMeta({
return generateEditViewMetadata({
collectionConfig,
config,
globalConfig,
@@ -147,5 +147,5 @@ export const getMetaBySegment: GenerateEditViewMetadata = async ({
}
}
return generateNotFoundMeta({ config, i18n })
return generateNotFoundViewMetadata({ config, i18n })
}

View File

@@ -2,5 +2,5 @@ import type { GenerateEditViewMetadata } from './getMetaBySegment.js'
import { getMetaBySegment } from './getMetaBySegment.js'
export const generateDocumentMetadata: GenerateEditViewMetadata = async (args) =>
export const generateDocumentViewMetadata: GenerateEditViewMetadata = async (args) =>
getMetaBySegment(args)

View File

@@ -5,9 +5,12 @@ import { getTranslation } from '@payloadcms/translations'
import type { GenerateEditViewMetadata } from '../Document/getMetaBySegment.js'
import { meta } from '../../utilities/meta.js'
import { generateMetadata } from '../../utilities/meta.js'
export const generateMetadata: GenerateEditViewMetadata = async ({
/**
* @todo Remove the type assertion. This is currently required because of how the `Metadata` type from `next` consumes the `URL` type.
*/
export const generateEditViewMetadata: GenerateEditViewMetadata = async ({
collectionConfig,
config,
globalConfig,
@@ -33,35 +36,35 @@ export const generateMetadata: GenerateEditViewMetadata = async ({
const ogToUse: MetaConfig['openGraph'] = {
title: `${isEditing ? t('general:edit') : t('general:edit')} - ${entityLabel}`,
...(config.admin.meta.openGraph || {}),
...(collectionConfig
...((collectionConfig
? {
...(collectionConfig?.admin.meta?.openGraph || {}),
...(collectionConfig?.admin?.components?.views?.edit?.[view]?.meta?.openGraph || {}),
}
: {}),
...(globalConfig
: {}) as MetaConfig['openGraph']),
...((globalConfig
? {
...(globalConfig?.admin.meta?.openGraph || {}),
...(globalConfig?.admin?.components?.views?.edit?.[view]?.meta?.openGraph || {}),
}
: {}),
: {}) as MetaConfig['openGraph']),
}
return meta({
return generateMetadata({
...metaToUse,
openGraph: ogToUse,
...(collectionConfig
...((collectionConfig
? {
...(collectionConfig?.admin.meta || {}),
...(collectionConfig?.admin?.components?.views?.edit?.[view]?.meta || {}),
}
: {}),
...(globalConfig
: {}) as MetaConfig),
...((globalConfig
? {
...(globalConfig?.admin.meta || {}),
...(globalConfig?.admin?.components?.views?.edit?.[view]?.meta || {}),
}
: {}),
: {}) as MetaConfig),
serverURL: config.serverURL,
})
}

View File

@@ -94,6 +94,7 @@ export const ForgotPasswordForm: React.FC = () => {
blockData: {},
data: {},
event: 'onChange',
path: ['username'],
preferences: { fields: {} },
req: {
payload: {
@@ -124,6 +125,7 @@ export const ForgotPasswordForm: React.FC = () => {
blockData: {},
data: {},
event: 'onChange',
path: ['email'],
preferences: { fields: {} },
req: { payload: { config }, t } as unknown as PayloadRequest,
required: true,

View File

@@ -8,8 +8,6 @@ import React, { Fragment } from 'react'
import { FormHeader } from '../../elements/FormHeader/index.js'
import { ForgotPasswordForm } from './ForgotPasswordForm/index.js'
export { generateForgotPasswordMetadata } from './meta.js'
export const forgotPasswordBaseClass = 'forgot-password'
export function ForgotPasswordView({ initPageResult }: AdminViewServerProps) {

View File

@@ -1,12 +1,12 @@
import type { GenerateViewMetadata } from '../Root/index.js'
import { meta } from '../../utilities/meta.js'
import { generateMetadata } from '../../utilities/meta.js'
export const generateForgotPasswordMetadata: GenerateViewMetadata = async ({
export const generateForgotPasswordViewMetadata: GenerateViewMetadata = async ({
config,
i18n: { t },
}) =>
meta({
generateMetadata({
description: t('authentication:forgotPassword'),
keywords: t('authentication:forgotPassword'),
title: t('authentication:forgotPassword'),

View File

@@ -18,8 +18,6 @@ import React, { Fragment } from 'react'
import { renderListViewSlots } from './renderListViewSlots.js'
import { resolveAllFilterOptions } from './resolveAllFilterOptions.js'
export { generateListMetadata } from './meta.js'
type RenderListViewArgs = {
customCellProps?: Record<string, any>
disableBulkDelete?: boolean

View File

@@ -5,9 +5,9 @@ import { getTranslation } from '@payloadcms/translations'
import type { GenerateViewMetadata } from '../Root/index.js'
import { meta } from '../../utilities/meta.js'
import { generateMetadata } from '../../utilities/meta.js'
export const generateListMetadata = async (
export const generateListViewMetadata = async (
args: {
collectionConfig: SanitizedCollectionConfig
} & Parameters<GenerateViewMetadata>[0],
@@ -22,7 +22,7 @@ export const generateListMetadata = async (
title = getTranslation(collectionConfig.labels.plural, i18n)
}
return meta({
return generateMetadata({
...(config.admin.meta || {}),
description,
keywords,

View File

@@ -2,16 +2,16 @@ import type { Metadata } from 'next'
import type { GenerateEditViewMetadata } from '../Document/getMetaBySegment.js'
import { generateMetadata as generateDocumentMetadata } from '../Edit/meta.js'
import { generateEditViewMetadata } from '../Edit/metadata.js'
export const generateMetadata: GenerateEditViewMetadata = async ({
export const generateLivePreviewViewMetadata: GenerateEditViewMetadata = async ({
collectionConfig,
config,
globalConfig,
i18n,
isEditing,
}): Promise<Metadata> =>
generateDocumentMetadata({
generateEditViewMetadata({
collectionConfig,
config,
globalConfig,

View File

@@ -8,8 +8,6 @@ import { Logo } from '../../elements/Logo/index.js'
import { LoginForm } from './LoginForm/index.js'
import './index.scss'
export { generateLoginMetadata } from './meta.js'
export const loginBaseClass = 'login'
export function LoginView({ initPageResult, params, searchParams }: AdminViewServerProps) {

View File

@@ -1,9 +1,9 @@
import type { GenerateViewMetadata } from '../Root/index.js'
import { meta } from '../../utilities/meta.js'
import { generateMetadata } from '../../utilities/meta.js'
export const generateLoginMetadata: GenerateViewMetadata = async ({ config, i18n: { t } }) =>
meta({
export const generateLoginViewMetadata: GenerateViewMetadata = async ({ config, i18n: { t } }) =>
generateMetadata({
description: `${t('authentication:login')}`,
keywords: `${t('authentication:login')}`,
serverURL: config.serverURL,

View File

@@ -7,8 +7,6 @@ import './index.scss'
const baseClass = 'logout'
export { generateLogoutMetadata } from './meta.js'
export const LogoutView: React.FC<
{
inactivity?: boolean

View File

@@ -1,9 +1,9 @@
import type { GenerateViewMetadata } from '../Root/index.js'
import { meta } from '../../utilities/meta.js'
import { generateMetadata } from '../../utilities/meta.js'
export const generateLogoutMetadata: GenerateViewMetadata = async ({ config, i18n: { t } }) =>
meta({
export const generateLogoutViewMetadata: GenerateViewMetadata = async ({ config, i18n: { t } }) =>
generateMetadata({
description: `${t('authentication:logoutUser')}`,
keywords: `${t('authentication:logout')}`,
serverURL: config.serverURL,

View File

@@ -10,7 +10,7 @@ import { getNextRequestI18n } from '../../utilities/getNextRequestI18n.js'
import { initPage } from '../../utilities/initPage/index.js'
import { NotFoundClient } from './index.client.js'
export const generatePageMetadata = async ({
export const generateNotFoundViewMetadata = async ({
config: configPromise,
}: {
config: Promise<SanitizedConfig> | SanitizedConfig

View File

@@ -2,16 +2,16 @@ import type { I18nClient } from '@payloadcms/translations'
import type { Metadata } from 'next'
import type { SanitizedConfig } from 'payload'
import { meta } from '../../utilities/meta.js'
import { generateMetadata } from '../../utilities/meta.js'
export const generateNotFoundMeta = async ({
export const generateNotFoundViewMetadata = async ({
config,
i18n,
}: {
config: SanitizedConfig
i18n: I18nClient
}): Promise<Metadata> =>
meta({
generateMetadata({
description: i18n.t('general:pageNotFound'),
keywords: `404 ${i18n.t('general:notFound')}`,
serverURL: config.serverURL,

View File

@@ -11,8 +11,6 @@ import './index.scss'
export const resetPasswordBaseClass = 'reset-password'
export { generateResetPasswordMetadata } from './meta.js'
export function ResetPassword({ initPageResult, params }: AdminViewServerProps) {
const { req } = initPageResult

View File

@@ -2,13 +2,13 @@ import type { Metadata } from 'next'
import type { GenerateViewMetadata } from '../Root/index.js'
import { meta } from '../../utilities/meta.js'
import { generateMetadata } from '../../utilities/meta.js'
export const generateResetPasswordMetadata: GenerateViewMetadata = async ({
export const generateResetPasswordViewMetadata: GenerateViewMetadata = async ({
config,
i18n: { t },
}): Promise<Metadata> =>
meta({
generateMetadata({
description: t('authentication:resetPassword'),
keywords: t('authentication:resetPassword'),
serverURL: config.serverURL,

View File

@@ -7,7 +7,7 @@ import type {
SanitizedGlobalConfig,
} from 'payload'
import { meta } from '../../utilities/meta.js'
import { generateMetadata } from '../../utilities/meta.js'
export const generateCustomViewMetadata = async (args: {
collectionConfig?: SanitizedCollectionConfig
@@ -26,7 +26,7 @@ export const generateCustomViewMetadata = async (args: {
return null
}
return meta({
return generateMetadata({
description: `Payload`,
keywords: `Payload`,
serverURL: config.serverURL,

View File

@@ -18,8 +18,6 @@ import { MinimalTemplate } from '../../templates/Minimal/index.js'
import { initPage } from '../../utilities/initPage/index.js'
import { getViewFromConfig } from './getViewFromConfig.js'
export { generatePageMetadata } from './meta.js'
export type GenerateViewMetadata = (args: {
config: SanitizedConfig
i18n: I18nClient

View File

@@ -2,27 +2,27 @@ import type { Metadata } from 'next'
import type { SanitizedConfig } from 'payload'
import { getNextRequestI18n } from '../../utilities/getNextRequestI18n.js'
import { generateAccountMetadata } from '../Account/index.js'
import { generateCreateFirstUserMetadata } from '../CreateFirstUser/index.js'
import { generateDashboardMetadata } from '../Dashboard/index.js'
import { generateDocumentMetadata } from '../Document/meta.js'
import { generateForgotPasswordMetadata } from '../ForgotPassword/index.js'
import { generateListMetadata } from '../List/index.js'
import { generateLoginMetadata } from '../Login/index.js'
import { generateNotFoundMeta } from '../NotFound/meta.js'
import { generateResetPasswordMetadata } from '../ResetPassword/index.js'
import { generateUnauthorizedMetadata } from '../Unauthorized/index.js'
import { generateVerifyMetadata } from '../Verify/index.js'
import { generateAccountViewMetadata } from '../Account/metadata.js'
import { generateCreateFirstUserViewMetadata } from '../CreateFirstUser/metadata.js'
import { generateDashboardViewMetadata } from '../Dashboard/metadata.js'
import { generateDocumentViewMetadata } from '../Document/metadata.js'
import { generateForgotPasswordViewMetadata } from '../ForgotPassword/metadata.js'
import { generateListViewMetadata } from '../List/metadata.js'
import { generateLoginViewMetadata } from '../Login/metadata.js'
import { generateNotFoundViewMetadata } from '../NotFound/metadata.js'
import { generateResetPasswordViewMetadata } from '../ResetPassword/metadata.js'
import { generateUnauthorizedViewMetadata } from '../Unauthorized/metadata.js'
import { generateVerifyViewMetadata } from '../Verify/metadata.js'
import { generateCustomViewMetadata } from './generateCustomViewMetadata.js'
import { getCustomViewByRoute } from './getCustomViewByRoute.js'
const oneSegmentMeta = {
'create-first-user': generateCreateFirstUserMetadata,
forgot: generateForgotPasswordMetadata,
login: generateLoginMetadata,
logout: generateUnauthorizedMetadata,
'logout-inactivity': generateUnauthorizedMetadata,
unauthorized: generateUnauthorizedMetadata,
'create-first-user': generateCreateFirstUserViewMetadata,
forgot: generateForgotPasswordViewMetadata,
login: generateLoginViewMetadata,
logout: generateUnauthorizedViewMetadata,
'logout-inactivity': generateUnauthorizedViewMetadata,
unauthorized: generateUnauthorizedViewMetadata,
}
type Args = {
@@ -68,7 +68,7 @@ export const generatePageMetadata = async ({
switch (segments.length) {
case 0: {
meta = await generateDashboardMetadata({ config, i18n })
meta = await generateDashboardViewMetadata({ config, i18n })
break
}
case 1: {
@@ -83,7 +83,7 @@ export const generatePageMetadata = async ({
break
} else if (segmentOne === 'account') {
// --> /account
meta = await generateAccountMetadata({ config, i18n })
meta = await generateAccountViewMetadata({ config, i18n })
break
}
break
@@ -91,14 +91,14 @@ export const generatePageMetadata = async ({
case 2: {
if (`/${segmentOne}` === config.admin.routes.reset) {
// --> /reset/:token
meta = await generateResetPasswordMetadata({ config, i18n })
meta = await generateResetPasswordViewMetadata({ config, i18n })
}
if (isCollection) {
// --> /collections/:collectionSlug
meta = await generateListMetadata({ collectionConfig, config, i18n })
meta = await generateListViewMetadata({ collectionConfig, config, i18n })
} else if (isGlobal) {
// --> /globals/:globalSlug
meta = await generateDocumentMetadata({
meta = await generateDocumentViewMetadata({
config,
globalConfig,
i18n,
@@ -110,7 +110,7 @@ export const generatePageMetadata = async ({
default: {
if (segmentTwo === 'verify') {
// --> /:collectionSlug/verify/:token
meta = await generateVerifyMetadata({ config, i18n })
meta = await generateVerifyViewMetadata({ config, i18n })
} else if (isCollection) {
// Custom Views
// --> /collections/:collectionSlug/:id
@@ -118,14 +118,14 @@ export const generatePageMetadata = async ({
// --> /collections/:collectionSlug/:id/versions
// --> /collections/:collectionSlug/:id/versions/:version
// --> /collections/:collectionSlug/:id/api
meta = await generateDocumentMetadata({ collectionConfig, config, i18n, params })
meta = await generateDocumentViewMetadata({ collectionConfig, config, i18n, params })
} else if (isGlobal) {
// Custom Views
// --> /globals/:globalSlug/versions
// --> /globals/:globalSlug/versions/:version
// --> /globals/:globalSlug/preview
// --> /globals/:globalSlug/api
meta = await generateDocumentMetadata({
meta = await generateDocumentViewMetadata({
config,
globalConfig,
i18n,
@@ -151,7 +151,7 @@ export const generatePageMetadata = async ({
viewConfig,
})
} else {
meta = await generateNotFoundMeta({ config, i18n })
meta = await generateNotFoundViewMetadata({ config, i18n })
}
}

View File

@@ -7,8 +7,6 @@ import React from 'react'
import { FormHeader } from '../../elements/FormHeader/index.js'
import './index.scss'
export { generateUnauthorizedMetadata } from './meta.js'
const baseClass = 'unauthorized'
export function UnauthorizedView({ initPageResult }: AdminViewServerProps) {

View File

@@ -1,9 +1,12 @@
import type { GenerateViewMetadata } from '../Root/index.js'
import { meta } from '../../utilities/meta.js'
import { generateMetadata } from '../../utilities/meta.js'
export const generateUnauthorizedMetadata: GenerateViewMetadata = async ({ config, i18n: { t } }) =>
meta({
export const generateUnauthorizedViewMetadata: GenerateViewMetadata = async ({
config,
i18n: { t },
}) =>
generateMetadata({
description: t('error:unauthorized'),
keywords: t('error:unauthorized'),
serverURL: config.serverURL,

View File

@@ -9,8 +9,6 @@ import './index.scss'
export const verifyBaseClass = 'verify'
export { generateVerifyMetadata } from './meta.js'
export async function Verify({ initPageResult, params, searchParams }: AdminViewServerProps) {
// /:collectionSlug/verify/:token

View File

@@ -1,9 +1,9 @@
import type { GenerateViewMetadata } from '../Root/index.js'
import { meta } from '../../utilities/meta.js'
import { generateMetadata } from '../../utilities/meta.js'
export const generateVerifyMetadata: GenerateViewMetadata = async ({ config, i18n: { t } }) =>
meta({
export const generateVerifyViewMetadata: GenerateViewMetadata = async ({ config, i18n: { t } }) =>
generateMetadata({
description: t('authentication:verifyUser'),
keywords: t('authentication:verify'),
serverURL: config.serverURL,

View File

@@ -6,9 +6,12 @@ import { formatDate } from '@payloadcms/ui/shared'
import type { GenerateEditViewMetadata } from '../Document/getMetaBySegment.js'
import { meta } from '../../utilities/meta.js'
import { generateMetadata } from '../../utilities/meta.js'
export const generateMetadata: GenerateEditViewMetadata = async ({
/**
* @todo Remove the `MetaConfig` type assertions. They are currently required because of how the `Metadata` type from `next` consumes the `URL` type.
*/
export const generateVersionViewMetadata: GenerateEditViewMetadata = async ({
collectionConfig,
config,
globalConfig,
@@ -47,12 +50,12 @@ export const generateMetadata: GenerateEditViewMetadata = async ({
...(config.admin.meta || {}),
description: t('version:viewingVersionGlobal', { entityLabel }),
title: `${t('version:version')}${formattedCreatedAt ? ` - ${formattedCreatedAt}` : ''}${entityLabel}`,
...(globalConfig?.admin?.meta || {}),
...(globalConfig?.admin?.components?.views?.edit?.version?.meta || {}),
...((globalConfig?.admin?.meta || {}) as MetaConfig),
...((globalConfig?.admin?.components?.views?.edit?.version?.meta || {}) as MetaConfig),
}
}
return meta({
return generateMetadata({
...metaToUse,
serverURL: config.serverURL,
})

View File

@@ -5,9 +5,12 @@ import { getTranslation } from '@payloadcms/translations'
import type { GenerateEditViewMetadata } from '../Document/getMetaBySegment.js'
import { meta } from '../../utilities/meta.js'
import { generateMetadata } from '../../utilities/meta.js'
export const generateMetadata: GenerateEditViewMetadata = async ({
/**
* @todo Remove the `MetaConfig` type assertions. They are currently required because of how the `Metadata` type from `next` consumes the `URL` type.
*/
export const generateVersionsViewMetadata: GenerateEditViewMetadata = async ({
collectionConfig,
config,
globalConfig,
@@ -48,12 +51,12 @@ export const generateMetadata: GenerateEditViewMetadata = async ({
...(config.admin.meta || {}),
description: t('version:viewingVersionsGlobal', { entitySlug: globalConfig.slug }),
title: `${t('version:versions')} - ${entityLabel}`,
...(globalConfig?.admin.meta || {}),
...(globalConfig?.admin?.components?.views?.edit?.versions?.meta || {}),
...((globalConfig?.admin.meta || {}) as MetaConfig),
...((globalConfig?.admin?.components?.views?.edit?.versions?.meta || {}) as MetaConfig),
}
}
return meta({
return generateMetadata({
...metaToUse,
serverURL: config.serverURL,
})

View File

@@ -1,9 +1,11 @@
/**
* @param {import('next').NextConfig} nextConfig
* @param {Object} [options] - Optional configuration options
* @param {boolean} [options.devBundleServerPackages] - Whether to bundle server packages in development mode. @default true
*
* @returns {import('next').NextConfig}
* */
export const withPayload = (nextConfig = {}) => {
export const withPayload = (nextConfig = {}, options = {}) => {
const env = nextConfig?.env || {}
if (nextConfig.experimental?.staleTimes?.dynamic) {
@@ -99,6 +101,32 @@ export const withPayload = (nextConfig = {}) => {
'libsql',
'pino-pretty',
'graphql',
// Do not bundle server-only packages during dev to improve compile speed
...(process.env.npm_lifecycle_event === 'dev' && options.devBundleServerPackages === false
? [
'payload',
'@payloadcms/db-mongodb',
'@payloadcms/db-postgres',
'@payloadcms/db-sqlite',
'@payloadcms/db-vercel-postgres',
'@payloadcms/drizzle',
'@payloadcms/email-nodemailer',
'@payloadcms/email-resend',
'@payloadcms/graphql',
'@payloadcms/payload-cloud',
'@payloadcms/plugin-cloud-storage',
'@payloadcms/plugin-redirects',
'@payloadcms/plugin-sentry',
'@payloadcms/plugin-stripe',
// TODO: Add the following packages, excluding their /client subpath exports, once Next.js supports it
// @payloadcms/richtext-lexical
//'@payloadcms/storage-azure',
//'@payloadcms/storage-gcs',
//'@payloadcms/storage-s3',
//'@payloadcms/storage-uploadthing',
//'@payloadcms/storage-vercel-blob',
]
: []),
],
webpack: (webpackConfig, webpackOptions) => {
const incomingWebpackConfig =

View File

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

View File

@@ -1,12 +1,12 @@
import { CognitoIdentityClient } from '@aws-sdk/client-cognito-identity'
import * as AWS from '@aws-sdk/client-s3'
import { S3 } from '@aws-sdk/client-s3'
import { fromCognitoIdentityPool } from '@aws-sdk/credential-providers'
import { authAsCognitoUser } from './authAsCognitoUser.js'
export type GetStorageClient = () => Promise<{
identityID: string
storageClient: AWS.S3
storageClient: S3
}>
export const refreshSession = async () => {
@@ -33,7 +33,7 @@ export const refreshSession = async () => {
// @ts-expect-error - Incorrect AWS types
const identityID = credentials.identityId
const storageClient = new AWS.S3({
const storageClient = new S3({
credentials,
region: process.env.PAYLOAD_CLOUD_BUCKET_REGION,
})

View File

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

View File

@@ -1,8 +1,7 @@
import type { TFunction } from '@payloadcms/translations'
import type { ServerProps } from '../../config/types.js'
import type { Field } from '../../fields/config/types.js'
import type { ClientFieldWithOptionalType } from './Field.js'
import type { ClientFieldWithOptionalType, ServerComponentProps } from './Field.js'
export type DescriptionFunction = ({ t }: { t: TFunction }) => string
@@ -33,7 +32,7 @@ export type FieldDescriptionServerProps<
clientField: TFieldClient
readonly field: TFieldServer
} & GenericDescriptionProps &
Partial<ServerProps>
ServerComponentProps
export type FieldDescriptionClientProps<
TFieldClient extends ClientFieldWithOptionalType = ClientFieldWithOptionalType,

View File

@@ -1,6 +1,5 @@
import type { ServerProps } from '../../config/types.js'
import type { Field } from '../../fields/config/types.js'
import type { ClientFieldWithOptionalType } from './Field.js'
import type { ClientFieldWithOptionalType, ServerComponentProps } from './Field.js'
export type GenericErrorProps = {
readonly alignCaret?: 'center' | 'left' | 'right'
@@ -22,7 +21,7 @@ export type FieldErrorServerProps<
clientField: TFieldClient
readonly field: TFieldServer
} & GenericErrorProps &
Partial<ServerProps>
ServerComponentProps
export type FieldErrorClientComponent<
TFieldClient extends ClientFieldWithOptionalType = ClientFieldWithOptionalType,

View File

@@ -1,6 +1,6 @@
import type { ServerProps, StaticLabel } from '../../config/types.js'
import type { StaticLabel } from '../../config/types.js'
import type { Field } from '../../fields/config/types.js'
import type { ClientFieldWithOptionalType } from './Field.js'
import type { ClientFieldWithOptionalType, ServerComponentProps } from './Field.js'
export type GenericLabelProps = {
readonly as?: 'label' | 'span'
@@ -26,7 +26,7 @@ export type FieldLabelServerProps<
clientField: TFieldClient
readonly field: TFieldServer
} & GenericLabelProps &
Partial<ServerProps>
ServerComponentProps
export type SanitizedLabelProps<TFieldClient extends ClientFieldWithOptionalType> = Omit<
FieldLabelClientProps<TFieldClient>,

View File

@@ -37,6 +37,7 @@ export const generatePasswordSaltHash = async ({
blockData: {},
data: {},
event: 'submit',
path: ['password'],
preferences: { fields: {} },
req,
required: true,

View File

@@ -0,0 +1,214 @@
import type { PayloadComponent } from '../../index.js'
import { addPayloadComponentToImportMap } from './utilities/addPayloadComponentToImportMap.js'
import { getImportMapToBaseDirPath } from './utilities/getImportMapToBaseDirPath.js'
describe('addPayloadComponentToImportMap', () => {
let importMap: Record<string, string>
let imports: Record<
string,
{
path: string
specifier: string
}
>
beforeEach(() => {
importMap = {}
imports = {}
jest.restoreAllMocks()
})
function componentPathTest({
baseDir,
importMapFilePath,
payloadComponent,
expectedPath,
expectedSpecifier,
expectedImportMapToBaseDirPath,
}: {
baseDir: string
importMapFilePath: string
payloadComponent: PayloadComponent
expectedPath: string
expectedImportMapToBaseDirPath: string
expectedSpecifier: string
}) {
const importMapToBaseDirPath = getImportMapToBaseDirPath({
baseDir,
importMapPath: importMapFilePath,
})
expect(importMapToBaseDirPath).toBe(expectedImportMapToBaseDirPath)
const { path, specifier } =
addPayloadComponentToImportMap({
importMapToBaseDirPath,
importMap,
imports,
payloadComponent,
}) ?? {}
expect(path).toBe(expectedPath)
expect(specifier).toBe(expectedSpecifier)
}
it('relative path with import map partially in base dir', () => {
componentPathTest({
baseDir: '/myPackage/test/myTest',
importMapFilePath: '/myPackage/app/(payload)/importMap.js',
payloadComponent: './MyComponent.js#MyExport',
expectedImportMapToBaseDirPath: '../../test/myTest/',
expectedPath: '../../test/myTest/MyComponent.js',
expectedSpecifier: 'MyExport',
})
})
it('relative path with import map partially in base dir 2', () => {
componentPathTest({
baseDir: '/myPackage/test/myTest',
importMapFilePath: '/myPackage/test/prod/app/(payload)/importMap.js',
payloadComponent: {
path: './MyComponent.js#MyExport',
},
expectedImportMapToBaseDirPath: '../../../myTest/',
expectedPath: '../../../myTest/MyComponent.js',
expectedSpecifier: 'MyExport',
})
})
it('relative path with import map partially in base dir 3', () => {
componentPathTest({
baseDir: '/myPackage/test/myTest',
importMapFilePath: '/myPackage/test/prod/app/(payload)/importMap.js',
payloadComponent: {
path: '../otherTest/MyComponent.js',
exportName: 'MyExport',
},
expectedImportMapToBaseDirPath: '../../../myTest/',
expectedPath: '../../../otherTest/MyComponent.js',
expectedSpecifier: 'MyExport',
})
})
it('relative path with import map within base dir', () => {
componentPathTest({
baseDir: '/myPackage/test/myTest',
importMapFilePath: '/myPackage/test/myTest/prod/app/(payload)/importMap.js',
payloadComponent: './MyComponent.js#MyExport',
expectedImportMapToBaseDirPath: '../../../',
expectedPath: '../../../MyComponent.js',
expectedSpecifier: 'MyExport',
})
})
it('relative path with import map not in base dir', () => {
componentPathTest({
baseDir: '/test/myTest',
importMapFilePath: '/app/(payload)/importMap.js',
payloadComponent: './MyComponent.js#MyExport',
expectedImportMapToBaseDirPath: '../../test/myTest/',
expectedPath: '../../test/myTest/MyComponent.js',
expectedSpecifier: 'MyExport',
})
})
it('relative path with import map not in base dir 2', () => {
componentPathTest({
baseDir: '/test/myTest',
importMapFilePath: '/app/(payload)/importMap.js',
payloadComponent: '../myOtherTest/MyComponent.js#MyExport',
expectedImportMapToBaseDirPath: '../../test/myTest/',
expectedPath: '../../test/myOtherTest/MyComponent.js',
expectedSpecifier: 'MyExport',
})
})
it('relative path with import map not in base dir, baseDir ending with slash', () => {
componentPathTest({
baseDir: '/test/myTest/',
importMapFilePath: '/app/(payload)/importMap.js',
payloadComponent: './MyComponent.js#MyExport',
expectedImportMapToBaseDirPath: '../../test/myTest/',
expectedPath: '../../test/myTest/MyComponent.js',
expectedSpecifier: 'MyExport',
})
})
it('relative path with import map not in base dir, component starting with slash', () => {
componentPathTest({
baseDir: '/test/myTest',
importMapFilePath: '/app/(payload)/importMap.js',
payloadComponent: '/MyComponent.js#MyExport',
expectedImportMapToBaseDirPath: '../../test/myTest/',
expectedPath: '../../test/myTest/MyComponent.js',
expectedSpecifier: 'MyExport',
})
})
it('aliased path', () => {
componentPathTest({
baseDir: '/test/myTest',
importMapFilePath: '/app/(payload)/importMap.js',
payloadComponent: '@components/MyComponent.js#MyExport',
expectedImportMapToBaseDirPath: '../../test/myTest/',
expectedPath: '@components/MyComponent.js',
expectedSpecifier: 'MyExport',
})
})
it('aliased path in PayloadComponent object', () => {
componentPathTest({
baseDir: '/test/',
importMapFilePath: '/app/(payload)/importMap.js',
payloadComponent: {
path: '@components/MyComponent.js',
},
expectedImportMapToBaseDirPath: '../../test/',
expectedPath: '@components/MyComponent.js',
expectedSpecifier: 'default',
})
})
it('relative path import starting with slash, going up', () => {
componentPathTest({
baseDir: '/test/myTest',
importMapFilePath: '/test/myTest/app/importMap.js',
payloadComponent: '/../MyComponent.js#MyExport',
expectedImportMapToBaseDirPath: '../',
expectedPath: '../../MyComponent.js',
expectedSpecifier: 'MyExport',
})
})
it('relative path import starting with dot-slash, going up', () => {
componentPathTest({
baseDir: '/test/myTest',
importMapFilePath: '/test/myTest/app/importMap.js',
payloadComponent: './../MyComponent.js#MyExport',
expectedImportMapToBaseDirPath: '../',
expectedPath: '../../MyComponent.js',
expectedSpecifier: 'MyExport',
})
})
it('importMap and baseDir in same directory', () => {
componentPathTest({
baseDir: '/test/myTest',
importMapFilePath: '/test/myTest/importMap.js',
payloadComponent: './MyComponent.js#MyExport',
expectedImportMapToBaseDirPath: './',
expectedPath: './MyComponent.js',
expectedSpecifier: 'MyExport',
})
})
it('baseDir within importMap dir', () => {
componentPathTest({
baseDir: '/test/myTest/components',
importMapFilePath: '/test/myTest/importMap.js',
payloadComponent: './MyComponent.js#MyExport',
expectedImportMapToBaseDirPath: './components/',
expectedPath: './components/MyComponent.js',
expectedSpecifier: 'MyExport',
})
})
})

View File

@@ -1,12 +1,13 @@
import crypto from 'crypto'
/* eslint-disable no-console */
import fs from 'fs'
import process from 'node:process'
import path from 'path'
import type { PayloadComponent, SanitizedConfig } from '../../config/types.js'
import { iterateConfig } from './iterateConfig.js'
import { parsePayloadComponent } from './parsePayloadComponent.js'
import { addPayloadComponentToImportMap } from './utilities/addPayloadComponentToImportMap.js'
import { getImportMapToBaseDirPath } from './utilities/getImportMapToBaseDirPath.js'
import { resolveImportMapFilePath } from './utilities/resolveImportMapFilePath.js'
type ImportIdentifier = string
type ImportSpecifier = string
@@ -37,54 +38,6 @@ export type ImportMap = {
[path: UserImportPath]: any
}
export function addPayloadComponentToImportMap({
baseDir,
importMap,
imports,
payloadComponent,
}: {
baseDir: string
importMap: InternalImportMap
imports: Imports
payloadComponent: PayloadComponent
}) {
if (!payloadComponent) {
return
}
const { exportName, path: componentPath } = parsePayloadComponent(payloadComponent)
if (importMap[componentPath + '#' + exportName]) {
return
}
const importIdentifier =
exportName + '_' + crypto.createHash('md5').update(componentPath).digest('hex')
// e.g. if baseDir is /test/fields and componentPath is /components/Field.tsx
// then path needs to be /test/fields/components/Field.tsx NOT /users/username/project/test/fields/components/Field.tsx
// so we need to append baseDir to componentPath
if (componentPath.startsWith('.') || componentPath.startsWith('/')) {
const normalizedBaseDir = baseDir.replace(/\\/g, '/')
const finalPath = normalizedBaseDir.startsWith('/../')
? `${normalizedBaseDir}${componentPath.slice(1)}`
: path.posix.join(normalizedBaseDir, componentPath.slice(1))
imports[importIdentifier] = {
path:
componentPath.startsWith('.') || componentPath.startsWith('/') ? finalPath : componentPath,
specifier: exportName,
}
} else {
imports[importIdentifier] = {
path: componentPath,
specifier: exportName,
}
}
importMap[componentPath + '#' + exportName] = importIdentifier
}
export type AddToImportMap = (payloadComponent: PayloadComponent | PayloadComponent[]) => void
export async function generateImportMap(
@@ -100,49 +53,21 @@ export async function generateImportMap(
const importMap: InternalImportMap = {}
const imports: Imports = {}
// Determine the root directory of the project - usually the directory where the src or app folder is located
const rootDir = process.env.ROOT_DIR ?? process.cwd()
// get componentsBaseDir.
// E.g.:
// config.admin.importMap.baseDir = /test/fields/
// rootDir: /
// componentsBaseDir = /test/fields/
const baseDir = config.admin.importMap.baseDir ?? process.cwd()
// or
const importMapFilePath = resolveImportMapFilePath({
adminRoute: config.routes.admin,
importMapFile: config?.admin?.importMap?.importMapFile,
rootDir,
})
// E.g.:
// config.admin.importMap.baseDir = /test/fields/
// rootDir: /test
// componentsBaseDir = /fields/
// or
// config.admin.importMap.baseDir = /
// rootDir: /
// componentsBaseDir = /
// E.g.:
// config.admin.importMap.baseDir = /test/fields/
// rootDir: /test/fields/prod
// componentsBaseDir = ../
// Check if rootDir is a subdirectory of baseDir
const baseDir = config.admin.importMap.baseDir
const isSubdirectory = path.relative(baseDir, rootDir).startsWith('..')
let componentsBaseDir
if (isSubdirectory) {
// Get the relative path from rootDir to baseDir
componentsBaseDir = path.relative(rootDir, baseDir)
} else {
// If rootDir is not a subdirectory, just return baseDir relative to rootDir
componentsBaseDir = `/${path.relative(rootDir, baseDir)}`
}
// Ensure result has a trailing slash
if (!componentsBaseDir.endsWith('/')) {
componentsBaseDir += '/'
}
const importMapToBaseDirPath = getImportMapToBaseDirPath({
baseDir,
importMapPath: importMapFilePath,
})
const addToImportMap: AddToImportMap = (payloadComponent) => {
if (!payloadComponent) {
@@ -157,16 +82,16 @@ export async function generateImportMap(
if (Array.isArray(payloadComponent)) {
for (const component of payloadComponent) {
addPayloadComponentToImportMap({
baseDir: componentsBaseDir,
importMap,
importMapToBaseDirPath,
imports,
payloadComponent: component,
})
}
} else {
addPayloadComponentToImportMap({
baseDir: componentsBaseDir,
importMap,
importMapToBaseDirPath,
imports,
payloadComponent,
})
@@ -183,56 +108,26 @@ export async function generateImportMap(
await writeImportMap({
componentMap: importMap,
config,
fileName: 'importMap.js',
force: options?.force,
importMap: imports,
importMapFilePath,
log: shouldLog,
rootDir,
})
}
export async function writeImportMap({
componentMap,
config,
fileName,
force,
importMap,
importMapFilePath,
log,
rootDir,
}: {
componentMap: InternalImportMap
config: SanitizedConfig
fileName: string
force?: boolean
importMap: Imports
importMapFilePath: string
log?: boolean
rootDir: string
}) {
let importMapFilePath: string | undefined = undefined
if (config?.admin?.importMap?.importMapFile?.length) {
if (!fs.existsSync(config.admin.importMap.importMapFile)) {
throw new Error(
`Could not find the import map file at ${config.admin.importMap.importMapFile}`,
)
}
importMapFilePath = config.admin.importMap.importMapFile
} else {
const appLocation = path.resolve(rootDir, `app/(payload)${config.routes.admin}/`)
const srcAppLocation = path.resolve(rootDir, `src/app/(payload)${config.routes.admin}/`)
if (fs.existsSync(appLocation)) {
importMapFilePath = path.resolve(appLocation, fileName)
} else if (fs.existsSync(srcAppLocation)) {
importMapFilePath = path.resolve(srcAppLocation, fileName)
} else {
throw new Error(
`Could not find Payload import map folder. Looked in ${appLocation} and ${srcAppLocation}`,
)
}
}
const imports: string[] = []
for (const [identifier, { path, specifier }] of Object.entries(importMap)) {
imports.push(`import { ${specifier} as ${identifier} } from '${path}'`)

View File

@@ -76,7 +76,7 @@ export function iterateConfig({
if (config.admin?.components?.views) {
if (Object.keys(config.admin?.components?.views)?.length) {
for (const key in config.admin?.components?.views) {
const adminViewConfig: AdminViewConfig = config.admin?.components?.views[key]
const adminViewConfig = config.admin?.components?.views[key]
addToImportMap(adminViewConfig?.Component)
}
}

View File

@@ -0,0 +1,87 @@
import crypto from 'crypto'
import path from 'path'
import type { PayloadComponent } from '../../../config/types.js'
import type { Imports, InternalImportMap } from '../index.js'
import { parsePayloadComponent } from './parsePayloadComponent.js'
/**
* Normalizes the component path based on the import map's base directory path.
*/
function getAdjustedComponentPath(importMapToBaseDirPath: string, componentPath: string): string {
// Normalize input paths to use forward slashes
const normalizedBasePath = importMapToBaseDirPath.replace(/\\/g, '/')
const normalizedComponentPath = componentPath.replace(/\\/g, '/')
// Base path starts with './' - preserve the './' prefix
// => import map is in a subdirectory of the base directory, or in the same directory as the base directory
if (normalizedBasePath.startsWith('./')) {
// Remove './' from component path if it exists
const cleanComponentPath = normalizedComponentPath.startsWith('./')
? normalizedComponentPath.substring(2)
: normalizedComponentPath
// Join the paths to preserve the './' prefix
return `${normalizedBasePath}${cleanComponentPath}`
}
return path.posix.join(normalizedBasePath, normalizedComponentPath)
}
/**
* Adds a payload component to the import map.
*/
export function addPayloadComponentToImportMap({
importMap,
importMapToBaseDirPath,
imports,
payloadComponent,
}: {
importMap: InternalImportMap
importMapToBaseDirPath: string
imports: Imports
payloadComponent: PayloadComponent
}): {
path: string
specifier: string
} | null {
if (!payloadComponent) {
return null
}
const { exportName, path: componentPath } = parsePayloadComponent(payloadComponent)
if (importMap[componentPath + '#' + exportName]) {
return null
}
const importIdentifier =
exportName + '_' + crypto.createHash('md5').update(componentPath).digest('hex')
importMap[componentPath + '#' + exportName] = importIdentifier
const isRelativePath = componentPath.startsWith('.') || componentPath.startsWith('/')
if (isRelativePath) {
const adjustedComponentPath = getAdjustedComponentPath(importMapToBaseDirPath, componentPath)
imports[importIdentifier] = {
path: adjustedComponentPath,
specifier: exportName,
}
return {
path: adjustedComponentPath,
specifier: exportName,
}
} else {
// Tsconfig alias or package import, e.g. '@payloadcms/ui' or '@/components/MyComponent'
imports[importIdentifier] = {
path: componentPath,
specifier: exportName,
}
return {
path: componentPath,
specifier: exportName,
}
}
}

View File

@@ -1,6 +1,5 @@
import type { PayloadComponent } from '../../config/types.js'
import type { ImportMap } from './index.js'
import type { PayloadComponent } from '../../../config/types.js'
import type { ImportMap } from '../index.js'
import { parsePayloadComponent } from './parsePayloadComponent.js'
export const getFromImportMap = <TOutput>(args: {

View File

@@ -0,0 +1,39 @@
import path from 'path'
/**
* Returns the path that navigates from the import map file to the base directory.
* This can then be prepended to relative paths in the import map to get the full, absolute path.
*/
export function getImportMapToBaseDirPath({
baseDir,
importMapPath,
}: {
/**
* Absolute path to the base directory
*/
baseDir: string
/**
* Absolute path to the import map file
*/
importMapPath: string
}): string {
const importMapDir = path.dirname(importMapPath)
// 1. Direct relative path from `importMapDir` -> `baseDir`
let relativePath = path.relative(importMapDir, baseDir).replace(/\\/g, '/')
// 2. If they're the same directory, path.relative will be "", so use "./"
if (!relativePath) {
relativePath = './'
} // Add ./ prefix for subdirectories of the current directory
else if (!relativePath.startsWith('.') && !relativePath.startsWith('/')) {
relativePath = `./${relativePath}`
}
// 3. For consistency ensure a trailing slash
if (!relativePath.endsWith('/')) {
relativePath += '/'
}
return relativePath
}

View File

@@ -1,5 +1,5 @@
// @ts-strict-ignore
import type { PayloadComponent } from '../../config/types.js'
import type { PayloadComponent } from '../../../config/types.js'
export function parsePayloadComponent(PayloadComponent: PayloadComponent): {
exportName: string

View File

@@ -0,0 +1,38 @@
import fs from 'fs'
import path from 'path'
/**
* Returns the path to the import map file. If the import map file is not found, it throws an error.
*/
export function resolveImportMapFilePath({
adminRoute = '/admin',
importMapFile,
rootDir,
}: {
adminRoute?: string
importMapFile?: string
rootDir: string
}) {
let importMapFilePath: string | undefined = undefined
if (importMapFile?.length) {
if (!fs.existsSync(importMapFile)) {
throw new Error(`Could not find the import map file at ${importMapFile}`)
}
importMapFilePath = importMapFile
} else {
const appLocation = path.resolve(rootDir, `app/(payload)${adminRoute}/`)
const srcAppLocation = path.resolve(rootDir, `src/app/(payload)${adminRoute}/`)
if (fs.existsSync(appLocation)) {
importMapFilePath = path.resolve(appLocation, 'importMap.js')
} else if (fs.existsSync(srcAppLocation)) {
importMapFilePath = path.resolve(srcAppLocation, 'importMap.js')
} else {
throw new Error(
`Could not find Payload import map folder. Looked in ${appLocation} and ${srcAppLocation}`,
)
}
}
return importMapFilePath
}

View File

@@ -327,6 +327,11 @@ export type CollectionAdminOptions = {
* Custom description for collection. This will also be used as JSDoc for the generated types
*/
description?: EntityDescription
/**
* Disable the Copy To Locale button in the edit document view
* @default false
*/
disableCopyToLocale?: boolean
enableRichTextLink?: boolean
enableRichTextRelationship?: boolean
/**
@@ -544,6 +549,10 @@ export type SanitizedJoins = {
[collectionSlug: string]: SanitizedJoin[]
}
/**
* @todo remove the `DeepRequired` in v4.
* We don't actually guarantee that all properties are set when sanitizing configs.
*/
export interface SanitizedCollectionConfig
extends Omit<
DeepRequired<CollectionConfig>,
@@ -557,7 +566,6 @@ export interface SanitizedCollectionConfig
* Rows / collapsible / tabs w/o name `fields` merged to top, UIs are excluded
*/
flattenedFields: FlattenedField[]
/**
* Object of collections to join 'Join Fields object keyed by collection
*/

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