Compare commits

...

18 Commits

Author SHA1 Message Date
Dan Ribbens
f04cf75bfd fix: temp file handler safe path handling windows 2025-02-25 13:36:41 -05:00
Elliot DeNolf
48f183bd42 ci: remove codeowners file (#11385)
Since codeowner approvals are not currently required, the codeowners
file is only serving to add reviewers to PRs.

Removing the codeowners file for now as this is not desired. Can be
re-introduced at a later date if required approvers are implemented.
2025-02-25 10:06:14 -05:00
Jarrod Flesch
36e152d69d fix(ui): allow json fields to be updated externally (#11371)
### What?
Unable to update json fields externally. For example, calling `setValue`
on a json field would not be reflected in the admin panel UI.

### Why?
JSON fields use the monaco editor to manage state internally, so
programmatically updating the value in state does not change the
internal value.

### How?
Set a ref when the user updates the value and then unset the ref after
the change is complete.

Inside the hook that watches `value`, if the value changed and the
change came from the system (i.e. a programmatic change) refresh the
editor by adjusting its key prop. If the change was made by the user,
there is no need to refresh the editor.

Fixes https://github.com/payloadcms/payload/issues/10819
2025-02-25 09:44:06 -05:00
Kendell Joseph
1e698c2bdf chore(plugin-cloud): refresh session on ExpiredToken error code (#8904)
Fixes https://github.com/payloadcms/payload/issues/8404

This code will refresh the session token upon receiving an
`ExpiredToken` error when `storageClient.getObject()` receives that
error.

- The `Error Code` detected is determined by the error reported in the
issue, and is an actual code from [AWS
documentation](https://docs.aws.amazon.com/AmazonS3/latest/API/ErrorResponses.html)
for Error Responses.

- If the request fails again, the error is thrown.
2025-02-25 09:17:30 -05:00
Patrik
7bb1c9d3c6 fix(ui): bulk upload DiscardWithoutSaving modal styles (#11381)
This PR fixes an issue where the `DiscardWithoutSaving` modal in the
bulk upload process was missing its styles.

The modal now displays correctly with the intended styling.

### Before:
![Screenshot 2025-02-24 at 8 20
52 PM](https://github.com/user-attachments/assets/c83a3119-28ce-4701-bc64-1219adeb2505)

### After:
![Screenshot 2025-02-24 at 8 20
07 PM](https://github.com/user-attachments/assets/62d364c2-b64c-4bd9-bf05-7481d609c6e4)

Fixes #11380
2025-02-25 08:56:42 -05:00
Alessio Gravili
4410a49132 refactor: simplify collection, global and auth operations (#11374)
Continuation of https://github.com/payloadcms/payload/pull/11372 but for our collection, global and auth operations

Previously, we were quite frequently using `.reduce()` to run hooks. This PR replaces them with simple `for` loops, which is less overhead, less code, less confusing and simpler to understand.
2025-02-24 20:50:25 +00:00
Alessio Gravili
820a6ec55d fix: ensure generated types for config.blocks are not undefined if no blocks defined (#11377)
Previously, if no `config.blocks` were defined, `blocks: undefined` would incorrectly be added to the generated types.
2025-02-24 20:33:22 +00:00
Jacob Fletcher
0a1af45549 fix(next): nested relationship filter options (#11375)
Continuation of #11008. When `filterOptions` are set on a relationship
field that is _nested within another field_, those filter options are
not applied to `Filter` component in the list view. This is because we
were only shallowly resolving filter options on top-level fields, as
opposed to recursively traversing fields to resolve them even when
deeply nested.
2025-02-24 15:24:25 -05:00
Patrik
09ca5143eb fix(plugin-nested-docs): fallback to empty string if useAsTitle field is undefined (#11338)
Updated `formatBreadcrumb` to fall back to an empty string if the
`useAsTitle` field for the document is undefined.

This handles cases where the field is optional or not filled out,
ensuring the label is never `undefined`.

Fixes #10377
2025-02-24 14:00:41 -05:00
Patrik
f1b005c4f5 fix(ui): object type field labels not displaying in search filter (#11366)
This PR ensures that when `titleField.label` is provided as an object,
it is correctly translated and displayed in the search filter's
placeholder.

Previously, the implementation only supported string values, which could
lead to issues with object type labels. With these changes, object type
labels will now properly show as intended in the search filter.

Fixes #11348
2025-02-24 13:38:21 -05:00
Alessio Gravili
dc9e8fa655 refactor: simplify running field hooks (#11372)
Previously, we were quite frequently using `.reduce()` to sequentially run field hooks. This PR replaces them with simple `for` loops, which is less overhead, less code, less confusing and simpler to understand.

Additionally, it refactors `mergeLocaleActions` which previously was unnecessarily complex. They no longer entail async code, thus we no longer have to juggle with promises
2025-02-24 18:37:33 +00:00
Jacob Fletcher
2477fc6c75 fix(ui): custom block labels stale when reordering blocks (#11367)
When blocks have custom row labels, those row labels become stale when
reordering blocks. After moving a block, for example, the row label will
jump back the original block until form state returns with the proper
rendering order. This is especially evident on slow networks.
2025-02-24 16:52:12 +00:00
Jarrod Flesch
37781808eb fix(plugin-multi-tenant): user access, thread field names through (#11365)
### What?
Two things:
1. Users unassigned to a tenant could not access their own account
2. Custom `tenantsArrayFieldName` and `tenantsArrayTenantFieldName`
configurations were not being used in all cases

### Why?
1. The access constraint provided by the plugin would not allow them to
make changes to their own account
2. `getUserTenantIDs` and `afterTenantDelete` were not using the custom
field names properly

### How?
1. Adds constraint for users allowing them to manage their own account
by default. Externally nothing has changed. If you need to lock your
users access control down you should do that just as you would without
this plugin.
2. Threads the field names through for usage.

Fixes https://github.com/payloadcms/payload/issues/11317
2025-02-24 11:17:09 -05:00
Jessica Chowdhury
d92c0009ed fix(plugin-nested-docs): corrects data shape of breadcrumbs returned in hooks (#10866)
### What?
The `plugin-nested-docs` returns an array of breadcrumbs - the
`resaveChildren` file accidentally processed the breadcrumbs twice, once
where the data is updated and once within the `populateBreadcrumbs`
function which was causing the objects to be double nested.

### How?
Removes the extra nesting from `resaveChildren` file and allows the
`populateBreadcrumbs` to return the final data.

Fixes #10855
2025-02-24 11:10:21 -05:00
Rafal Sypien
d32608649c docs: add info about changes in localized fields to v2 -> v3 migration guide (#11244)
### What?
Information that locale fields in database are changing to a simpler
data structure in v3.

### Why?
Simple data migration is not enough to get it working, I had to spend
quite some time to figure out migration files and still it required some
manual input. Maybe others will find it useful when starting a v3
migration.

### How?
I had to do some custom migration scripts on my own to get v3 to work
with existing pages data.

---------

Co-authored-by: Sasha <64744993+r1tsuu@users.noreply.github.com>
2025-02-24 17:36:11 +02:00
Paul
f9121c1a3a fix(ui): link element triggering clicks twice (#11362)
Fixes
https://github.com/payloadcms/payload/issues/11359#issuecomment-2678213414

The link element by using startTransitionRoute and manually calling
router.push would technically cause links to be clicked twice, by not
preventing default browser behaviour.

This caused a problem on clicking /create links as it hit the route
twice. Added a test making sure Create new doesn't lead to abnormally
increased document counts

Changes:
- Added `e.preventDefault()` in our Link element
- Added `preventDefault` as an optional prop to this element so that
people can handle it on their own if needed via a custom `onClick`
2025-02-24 14:49:52 +00:00
Jarrod Flesch
f477e0e3c4 feat(plugin-multi-tenant): export useTenantSelection hook for public usage (#11364)
Exports the `useTenantSelection` hook from the multi-tenant plugin, this
way other users can import and use the hook along with it's methods.

Can be imported:
```ts
import { useTenantSelection } from '@payloadcms/plugin-multi-tenant/client'
```

The context returned:

```ts
type ContextType = {
  /**
   * Array of options to select from
   */
  options: OptionObject[]
  /**
   * The currently selected tenant ID
   */
  selectedTenantID: number | string | undefined
  /**
   * Prevents a refresh when the tenant is changed
   *
   * If not switching tenants while viewing a "global", set to true
   */
  setPreventRefreshOnChange: React.Dispatch<React.SetStateAction<boolean>>
  /**
   * Sets the selected tenant ID
   * 
   * @param args.id - The ID of the tenant to select
   * @param args.refresh - Whether to refresh the page after changing the tenant
   */
  setTenant: (args: { id: number | string | undefined; refresh?: boolean }) => void
}
```
2025-02-24 09:29:45 -05:00
Sasha
4224c68002 docs: fix links to react hooks (#11344) 2025-02-22 13:23:00 +00:00
85 changed files with 1375 additions and 952 deletions

37
.github/CODEOWNERS vendored
View File

@@ -1,37 +0,0 @@
# Order matters. The last matching pattern takes precedence.
### Package Exports
**/exports/ @denolfe @jmikrut @DanRibbens
### Packages
/packages/plugin-cloud*/src/ @denolfe @jmikrut @DanRibbens
/packages/email-*/src/ @denolfe @jmikrut @DanRibbens
/packages/live-preview*/src/ @jacobsfletch
/packages/plugin-stripe/src/ @jacobsfletch
/packages/plugin-multi-tenant/src/ @JarrodMFlesch
/packages/richtext-*/src/ @AlessioGr
/packages/next/src/ @jmikrut @jacobsfletch @AlessioGr @JarrodMFlesch
/packages/ui/src/ @jmikrut @jacobsfletch @AlessioGr @JarrodMFlesch
/packages/storage-*/src/ @denolfe @jmikrut @DanRibbens
/packages/create-payload-app/src/ @denolfe @jmikrut @DanRibbens
/packages/eslint-*/ @denolfe @jmikrut @DanRibbens @AlessioGr
### Templates
/templates/_data/ @denolfe @jmikrut @DanRibbens
/templates/_template/ @denolfe @jmikrut @DanRibbens
### Build Files
**/tsconfig*.json @denolfe @jmikrut @DanRibbens @AlessioGr
**/jest.config.js @denolfe @jmikrut @DanRibbens @AlessioGr
### Root
/package.json @denolfe @jmikrut @DanRibbens
/tools/ @denolfe @jmikrut @DanRibbens
/.husky/ @denolfe @jmikrut @DanRibbens
/.vscode/ @denolfe @jmikrut @DanRibbens @AlessioGr
/.github/ @denolfe @jmikrut @DanRibbens

View File

@@ -276,7 +276,7 @@ export default async function MyServerComponent({
But, the Payload Config is [non-serializable](https://react.dev/reference/rsc/use-client#serializable-types) by design. It is full of custom validation functions and more. This means that the Payload Config, in its entirety, cannot be passed directly to Client Components.
For this reason, Payload creates a Client Config and passes it into the Config Provider. This is a serializable version of the Payload Config that can be accessed from any Client Component via the [`useConfig`](../admin/hooks#useconfig) hook:
For this reason, Payload creates a Client Config and passes it into the Config Provider. This is a serializable version of the Payload Config that can be accessed from any Client Component via the [`useConfig`](../admin/react-hooks#useconfig) hook:
```tsx
'use client'
@@ -375,7 +375,7 @@ export function MyClientComponent() {
```
<Banner type="success">
See the [Hooks](../admin/hooks) documentation for a full list of available hooks.
See the [Hooks](../admin/react-hooks) documentation for a full list of available hooks.
</Banner>
### Getting the Current Locale
@@ -422,12 +422,12 @@ function Greeting() {
```
<Banner type="success">
See the [Hooks](../admin/hooks) documentation for a full list of available hooks.
See the [Hooks](../admin/react-hooks) documentation for a full list of available hooks.
</Banner>
### Using Hooks
To make it easier to [build your Custom Components](#building-custom-components), you can use [Payload's built-in React Hooks](../admin/hooks) in any Client Component. For example, you might want to interact with one of Payload's many React Contexts. To do this, you can use one of the many hooks available depending on your needs.
To make it easier to [build your Custom Components](#building-custom-components), you can use [Payload's built-in React Hooks](../admin/react-hooks) in any Client Component. For example, you might want to interact with one of Payload's many React Contexts. To do this, you can use one of the many hooks available depending on your needs.
```tsx
'use client'
@@ -444,7 +444,7 @@ export function MyClientComponent() {
```
<Banner type="success">
See the [Hooks](../admin/hooks) documentation for a full list of available hooks.
See the [Hooks](../admin/react-hooks) documentation for a full list of available hooks.
</Banner>
### Adding Styles

View File

@@ -658,7 +658,7 @@ In addition to the above props, all Server Components will also receive the foll
When swapping out the `Field` component, you are responsible for sending and receiving the field's `value` from the form itself.
To do so, import the [`useField`](../admin/hooks#usefield) hook from `@payloadcms/ui` and use it to manage the field's value:
To do so, import the [`useField`](../admin/react-hooks#usefield) hook from `@payloadcms/ui` and use it to manage the field's value:
```tsx
'use client'
@@ -677,7 +677,7 @@ export const CustomTextField: React.FC = () => {
```
<Banner type="success">
For a complete list of all available React hooks, see the [Payload React Hooks](../admin/hooks) documentation. For additional help, see [Building Custom Components](../custom-components/overview#building-custom-components).
For a complete list of all available React hooks, see the [Payload React Hooks](../admin/react-hooks) documentation. For additional help, see [Building Custom Components](../custom-components/overview#building-custom-components).
</Banner>
##### TypeScript#field-component-types

View File

@@ -27,7 +27,7 @@ There are four main types of Hooks in Payload:
<Banner type="warning">
**Reminder:**
Payload also ships a set of _React_ hooks that you can use in your frontend application. Although they share a common name, these are very different things and should not be confused. [More details](../admin/hooks).
Payload also ships a set of _React_ hooks that you can use in your frontend application. Although they share a common name, these are very different things and should not be confused. [More details](../admin/react-hooks).
</Banner>
## Root Hooks

View File

@@ -414,6 +414,15 @@ For more details, see the [Documentation](https://payloadcms.com/docs/getting-st
```
1. The `./src/public` directory is now located directly at root level `./public` [see Next.js docs for details](https://nextjs.org/docs/pages/building-your-application/optimizing/static-assets)
1. Payload now automatically removes `localized: true` property from sub-fields if a parent is localized, as it's redunant and unnecessary. If you have some existing data in this structure and you want to disable that behavior, you need to enable `allowLocalizedWithinLocalized` flag in your payload.config [read more in documentation](https://payloadcms.com/docs/configuration/overview#compatibility-flags), or create a migration script that aligns your data.
Mongodb example for a link in a page layout.
```diff
- layout.columns.en.link.en.type.en
+ layout.columns.en.link.type
```
## Custom Components
1. All Payload React components have been moved from the `payload` package to `@payloadcms/ui`. If you were previously importing components into your app from the `payload` package, for example to create Custom Components, you will need to change your import paths:

View File

@@ -278,6 +278,50 @@ async rewrites() {
}
```
### React Hooks
Below are the hooks exported from the plugin that you can import into your own custom components to consume.
#### useTenantSelection
You can import this like so:
```tsx
import { useTenantSelection } from '@payloadcms/plugin-multi-tenant/client'
...
const tenantContext = useTenantSelection()
```
The hook returns the following context:
```ts
type ContextType = {
/**
* Array of options to select from
*/
options: OptionObject[]
/**
* The currently selected tenant ID
*/
selectedTenantID: number | string | undefined
/**
* Prevents a refresh when the tenant is changed
*
* If not switching tenants while viewing a "global", set to true
*/
setPreventRefreshOnChange: React.Dispatch<React.SetStateAction<boolean>>
/**
* Sets the selected tenant ID
*
* @param args.id - The ID of the tenant to select
* @param args.refresh - Whether to refresh the page after changing the tenant
*/
setTenant: (args: { id: number | string | undefined; refresh?: boolean }) => void
}
```
## Examples

View File

@@ -153,7 +153,7 @@ export const renderListView = async (
const renderedFilters = renderFilters(collectionConfig.fields, req.payload.importMap)
const resolvedFilterOptions = await resolveAllFilterOptions({
collectionConfig,
fields: collectionConfig.fields,
req,
})

View File

@@ -1,19 +1,21 @@
import type { CollectionConfig, PayloadRequest, ResolvedFilterOptions } from 'payload'
import type { Field, PayloadRequest, ResolvedFilterOptions } from 'payload'
import { resolveFilterOptions } from '@payloadcms/ui/rsc'
import { fieldIsHiddenOrDisabled } from 'payload/shared'
import { fieldHasSubFields, fieldIsHiddenOrDisabled } from 'payload/shared'
export const resolveAllFilterOptions = async ({
collectionConfig,
fields,
req,
result,
}: {
collectionConfig: CollectionConfig
fields: Field[]
req: PayloadRequest
result?: Map<string, ResolvedFilterOptions>
}): Promise<Map<string, ResolvedFilterOptions>> => {
const resolvedFilterOptions = new Map<string, ResolvedFilterOptions>()
const resolvedFilterOptions = !result ? new Map<string, ResolvedFilterOptions>() : result
await Promise.all(
collectionConfig.fields.map(async (field) => {
fields.map(async (field) => {
if (fieldIsHiddenOrDisabled(field)) {
return
}
@@ -28,8 +30,29 @@ export const resolveAllFilterOptions = async ({
siblingData: {}, // use empty object to prevent breaking queries when accessing properties of data
user: req.user,
})
resolvedFilterOptions.set(field.name, options)
}
if (fieldHasSubFields(field)) {
await resolveAllFilterOptions({
fields: field.fields,
req,
result: resolvedFilterOptions,
})
}
if (field.type === 'tabs') {
await Promise.all(
field.tabs.map((tab) =>
resolveAllFilterOptions({
fields: tab.fields,
req,
result: resolvedFilterOptions,
}),
),
)
}
}),
)

View File

@@ -1,19 +1,13 @@
import type * as AWS from '@aws-sdk/client-s3'
import type { CognitoUserSession } from 'amazon-cognito-identity-js'
import { CognitoIdentityClient } from '@aws-sdk/client-cognito-identity'
import * as AWS from '@aws-sdk/client-s3'
import { fromCognitoIdentityPool } from '@aws-sdk/credential-providers'
import type { GetStorageClient } from './refreshSession.js'
import { authAsCognitoUser } from './authAsCognitoUser.js'
import { refreshSession } from './refreshSession.js'
export type GetStorageClient = () => Promise<{
identityID: string
storageClient: AWS.S3
}>
let storageClient: AWS.S3 | null = null
let session: CognitoUserSession | null = null
let identityID: string
export let storageClient: AWS.S3 | null = null
export let session: CognitoUserSession | null = null
export let identityID: string
export const getStorageClient: GetStorageClient = async () => {
if (storageClient && session?.isValid()) {
@@ -23,6 +17,8 @@ export const getStorageClient: GetStorageClient = async () => {
}
}
;({ identityID, session, storageClient } = await refreshSession())
if (!process.env.PAYLOAD_CLOUD_PROJECT_ID) {
throw new Error('PAYLOAD_CLOUD_PROJECT_ID is required')
}
@@ -33,34 +29,6 @@ export const getStorageClient: GetStorageClient = async () => {
throw new Error('PAYLOAD_CLOUD_COGNITO_IDENTITY_POOL_ID is required')
}
session = await authAsCognitoUser(
process.env.PAYLOAD_CLOUD_PROJECT_ID,
process.env.PAYLOAD_CLOUD_COGNITO_PASSWORD,
)
const cognitoIdentity = new CognitoIdentityClient({
credentials: fromCognitoIdentityPool({
clientConfig: {
region: 'us-east-1',
},
identityPoolId: process.env.PAYLOAD_CLOUD_COGNITO_IDENTITY_POOL_ID,
logins: {
[`cognito-idp.us-east-1.amazonaws.com/${process.env.PAYLOAD_CLOUD_COGNITO_USER_POOL_ID}`]:
session.getIdToken().getJwtToken(),
},
}),
})
const credentials = await cognitoIdentity.config.credentials()
// @ts-expect-error - Incorrect AWS types
identityID = credentials.identityId
storageClient = new AWS.S3({
credentials,
region: process.env.PAYLOAD_CLOUD_BUCKET_REGION,
})
return {
identityID,
storageClient,

View File

@@ -0,0 +1,46 @@
import { CognitoIdentityClient } from '@aws-sdk/client-cognito-identity'
import * as AWS 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
}>
export const refreshSession = async () => {
const session = await authAsCognitoUser(
process.env.PAYLOAD_CLOUD_PROJECT_ID || '',
process.env.PAYLOAD_CLOUD_COGNITO_PASSWORD || '',
)
const cognitoIdentity = new CognitoIdentityClient({
credentials: fromCognitoIdentityPool({
clientConfig: {
region: 'us-east-1',
},
identityPoolId: process.env.PAYLOAD_CLOUD_COGNITO_IDENTITY_POOL_ID || '',
logins: {
[`cognito-idp.us-east-1.amazonaws.com/${process.env.PAYLOAD_CLOUD_COGNITO_USER_POOL_ID}`]:
session.getIdToken().getJwtToken(),
},
}),
})
const credentials = await cognitoIdentity.config.credentials()
// @ts-expect-error - Incorrect AWS types
const identityID = credentials.identityId
const storageClient = new AWS.S3({
credentials,
region: process.env.PAYLOAD_CLOUD_BUCKET_REGION,
})
return {
identityID,
session,
storageClient,
}
}

View File

@@ -88,7 +88,7 @@ export type BeforeChangeRichTextHookArgs<
errors?: ValidationFieldError[]
/** Only available in `beforeChange` field hooks */
mergeLocaleActions?: (() => Promise<void>)[]
mergeLocaleActions?: (() => Promise<void> | void)[]
/** A string relating to which operation the field type is currently executing within. */
operation?: 'create' | 'delete' | 'read' | 'update'
/** The sibling data of the document before changes being applied. */

View File

@@ -1,20 +1,19 @@
// @ts-strict-ignore
import type { AuthStrategyFunctionArgs, AuthStrategyResult } from './index.js'
export const executeAuthStrategies = async (
args: AuthStrategyFunctionArgs,
): Promise<AuthStrategyResult> => {
return args.payload.authStrategies.reduce(
async (accumulatorPromise, strategy) => {
const result: AuthStrategyResult = await accumulatorPromise
if (!result.user) {
// add the configured AuthStrategy `name` to the strategy function args
args.strategyName = strategy.name
if (!args.payload.authStrategies?.length) {
return { user: null }
}
return strategy.authenticate(args)
}
for (const strategy of args.payload.authStrategies) {
// add the configured AuthStrategy `name` to the strategy function args
args.strategyName = strategy.name
const result = await strategy.authenticate(args)
if (result.user) {
return result
},
Promise.resolve({ user: null }),
)
}
}
return { user: null }
}

View File

@@ -64,18 +64,18 @@ export const forgotPasswordOperation = async <TSlug extends CollectionSlug>(
// beforeOperation - Collection
// /////////////////////////////////////
await args.collection.config.hooks.beforeOperation.reduce(async (priorHook, hook) => {
await priorHook
args =
(await hook({
args,
collection: args.collection?.config,
context: args.req.context,
operation: 'forgotPassword',
req: args.req,
})) || args
}, Promise.resolve())
if (args.collection.config.hooks?.beforeOperation?.length) {
for (const hook of args.collection.config.hooks.beforeOperation) {
args =
(await hook({
args,
collection: args.collection?.config,
context: args.req.context,
operation: 'forgotPassword',
req: args.req,
})) || args
}
}
const {
collection: { config: collectionConfig },
@@ -190,10 +190,11 @@ export const forgotPasswordOperation = async <TSlug extends CollectionSlug>(
// afterForgotPassword - Collection
// /////////////////////////////////////
await collectionConfig.hooks.afterForgotPassword.reduce(async (priorHook, hook) => {
await priorHook
await hook({ args, collection: args.collection?.config, context: req.context })
}, Promise.resolve())
if (collectionConfig.hooks?.afterForgotPassword?.length) {
for (const hook of collectionConfig.hooks.afterForgotPassword) {
await hook({ args, collection: args.collection?.config, context: req.context })
}
}
// /////////////////////////////////////
// afterOperation - Collection

View File

@@ -51,18 +51,18 @@ export const loginOperation = async <TSlug extends CollectionSlug>(
// beforeOperation - Collection
// /////////////////////////////////////
await args.collection.config.hooks.beforeOperation.reduce(async (priorHook, hook) => {
await priorHook
args =
(await hook({
args,
collection: args.collection?.config,
context: args.req.context,
operation: 'login',
req: args.req,
})) || args
}, Promise.resolve())
if (args.collection.config.hooks?.beforeOperation?.length) {
for (const hook of args.collection.config.hooks.beforeOperation) {
args =
(await hook({
args,
collection: args.collection?.config,
context: args.req.context,
operation: 'login',
req: args.req,
})) || args
}
}
const {
collection: { config: collectionConfig },
@@ -227,17 +227,17 @@ export const loginOperation = async <TSlug extends CollectionSlug>(
// beforeLogin - Collection
// /////////////////////////////////////
await collectionConfig.hooks.beforeLogin.reduce(async (priorHook, hook) => {
await priorHook
user =
(await hook({
collection: args.collection?.config,
context: args.req.context,
req: args.req,
user,
})) || user
}, Promise.resolve())
if (collectionConfig.hooks?.beforeLogin?.length) {
for (const hook of collectionConfig.hooks.beforeLogin) {
user =
(await hook({
collection: args.collection?.config,
context: args.req.context,
req: args.req,
user,
})) || user
}
}
const { exp, token } = await jwtSign({
fieldsToSign,
@@ -251,18 +251,18 @@ export const loginOperation = async <TSlug extends CollectionSlug>(
// afterLogin - Collection
// /////////////////////////////////////
await collectionConfig.hooks.afterLogin.reduce(async (priorHook, hook) => {
await priorHook
user =
(await hook({
collection: args.collection?.config,
context: args.req.context,
req: args.req,
token,
user,
})) || user
}, Promise.resolve())
if (collectionConfig.hooks?.afterLogin?.length) {
for (const hook of collectionConfig.hooks.afterLogin) {
user =
(await hook({
collection: args.collection?.config,
context: args.req.context,
req: args.req,
token,
user,
})) || user
}
}
// /////////////////////////////////////
// afterRead - Fields
@@ -286,17 +286,17 @@ export const loginOperation = async <TSlug extends CollectionSlug>(
// afterRead - Collection
// /////////////////////////////////////
await collectionConfig.hooks.afterRead.reduce(async (priorHook, hook) => {
await priorHook
user =
(await hook({
collection: args.collection?.config,
context: req.context,
doc: user,
req,
})) || user
}, Promise.resolve())
if (collectionConfig.hooks?.afterRead?.length) {
for (const hook of collectionConfig.hooks.afterRead) {
user =
(await hook({
collection: args.collection?.config,
context: req.context,
doc: user,
req,
})) || user
}
}
let result: { user: DataFromCollectionSlug<TSlug> } & Result = {
exp,

View File

@@ -25,16 +25,16 @@ export const logoutOperation = async (incomingArgs: Arguments): Promise<boolean>
throw new APIError('Incorrect collection', httpStatus.FORBIDDEN)
}
await collectionConfig.hooks.afterLogout.reduce(async (priorHook, hook) => {
await priorHook
args =
(await hook({
collection: args.collection?.config,
context: req.context,
req,
})) || args
}, Promise.resolve())
if (collectionConfig.hooks?.afterLogout?.length) {
for (const hook of collectionConfig.hooks.afterLogout) {
args =
(await hook({
collection: args.collection?.config,
context: req.context,
req,
})) || args
}
}
return true
}

View File

@@ -86,17 +86,17 @@ export const meOperation = async (args: Arguments): Promise<MeOperationResult> =
// After Me - Collection
// /////////////////////////////////////
await collection.config.hooks.afterMe.reduce(async (priorHook, hook) => {
await priorHook
result =
(await hook({
collection: collection?.config,
context: req.context,
req,
response: result,
})) || result
}, Promise.resolve())
if (collection.config.hooks?.afterMe?.length) {
for (const hook of collection.config.hooks.afterMe) {
result =
(await hook({
collection: collection?.config,
context: req.context,
req,
response: result,
})) || result
}
}
return result
}

View File

@@ -35,10 +35,8 @@ export const refreshOperation = async (incomingArgs: Arguments): Promise<Result>
// beforeOperation - Collection
// /////////////////////////////////////
await args.collection.config.hooks.beforeOperation.reduce(
async (priorHook: BeforeOperationHook | Promise<void>, hook: BeforeOperationHook) => {
await priorHook
if (args.collection.config.hooks?.beforeOperation?.length) {
for (const hook of args.collection.config.hooks.beforeOperation) {
args =
(await hook({
args,
@@ -47,9 +45,8 @@ export const refreshOperation = async (incomingArgs: Arguments): Promise<Result>
operation: 'refresh',
req: args.req,
})) || args
},
Promise.resolve(),
)
}
}
// /////////////////////////////////////
// Refresh
@@ -122,18 +119,18 @@ export const refreshOperation = async (incomingArgs: Arguments): Promise<Result>
// After Refresh - Collection
// /////////////////////////////////////
await collectionConfig.hooks.afterRefresh.reduce(async (priorHook, hook) => {
await priorHook
result =
(await hook({
collection: args.collection?.config,
context: args.req.context,
exp: result.exp,
req: args.req,
token: result.refreshedToken,
})) || result
}, Promise.resolve())
if (collectionConfig.hooks?.afterRefresh?.length) {
for (const hook of collectionConfig.hooks.afterRefresh) {
result =
(await hook({
collection: args.collection?.config,
context: args.req.context,
exp: result.exp,
req: args.req,
token: result.refreshedToken,
})) || result
}
}
// /////////////////////////////////////
// afterOperation - Collection

View File

@@ -91,17 +91,17 @@ export const resetPasswordOperation = async (args: Arguments): Promise<Result> =
// beforeValidate - Collection
// /////////////////////////////////////
await collectionConfig.hooks.beforeValidate.reduce(async (priorHook, hook) => {
await priorHook
await hook({
collection: args.collection?.config,
context: req.context,
data: user,
operation: 'update',
req,
})
}, Promise.resolve())
if (collectionConfig.hooks?.beforeValidate?.length) {
for (const hook of collectionConfig.hooks.beforeValidate) {
await hook({
collection: args.collection?.config,
context: req.context,
data: user,
operation: 'update',
req,
})
}
}
// /////////////////////////////////////
// Update new password

View File

@@ -44,7 +44,9 @@ const batchAndLoadDocs =
*
**/
const batchByFindArgs = keys.reduce((batches, key) => {
const batchByFindArgs = {}
for (const key of keys) {
const [
transactionID,
collection,
@@ -77,27 +79,16 @@ const batchAndLoadDocs =
const batchKey = JSON.stringify(batchKeyArray)
const idType = payload.collections?.[collection].customIDType || payload.db.defaultIDType
let sanitizedID: number | string = id
if (idType === 'number') {
sanitizedID = parseFloat(id)
}
const sanitizedID = idType === 'number' ? parseFloat(id) : id
if (isValidID(sanitizedID, idType)) {
return {
...batches,
[batchKey]: [...(batches[batchKey] || []), sanitizedID],
}
batchByFindArgs[batchKey] = [...(batchByFindArgs[batchKey] || []), sanitizedID]
}
return batches
}, {})
}
// Run find requests one after another, so as to not hang transactions
await Object.entries(batchByFindArgs).reduce(async (priorFind, [batchKey, ids]) => {
await priorFind
for (const [batchKey, ids] of Object.entries(batchByFindArgs)) {
const [
transactionID,
collection,
@@ -137,8 +128,7 @@ const batchAndLoadDocs =
// For each returned doc, find index in original keys
// Inject doc within docs array if index exists
result.docs.forEach((doc) => {
for (const doc of result.docs) {
const docKey = createDataloaderCacheKey({
collectionSlug: collection,
currentDepth,
@@ -158,8 +148,8 @@ const batchAndLoadDocs =
if (docsIndex > -1) {
docs[docsIndex] = doc
}
})
}, Promise.resolve())
}
}
// Return docs array,
// which has now been injected with all fetched docs

View File

@@ -28,18 +28,18 @@ export const countOperation = async <TSlug extends CollectionSlug>(
// beforeOperation - Collection
// /////////////////////////////////////
await args.collection.config.hooks.beforeOperation.reduce(async (priorHook, hook) => {
await priorHook
args =
(await hook({
args,
collection: args.collection.config,
context: args.req.context,
operation: 'count',
req: args.req,
})) || args
}, Promise.resolve())
if (args.collection.config.hooks?.beforeOperation?.length) {
for (const hook of args.collection.config.hooks.beforeOperation) {
args =
(await hook({
args,
collection: args.collection.config,
context: args.req.context,
operation: 'count',
req: args.req,
})) || args
}
}
const {
collection: { config: collectionConfig },

View File

@@ -28,18 +28,18 @@ export const countVersionsOperation = async <TSlug extends CollectionSlug>(
// beforeOperation - Collection
// /////////////////////////////////////
await args.collection.config.hooks.beforeOperation.reduce(async (priorHook, hook) => {
await priorHook
args =
(await hook({
args,
collection: args.collection.config,
context: args.req.context,
operation: 'countVersions',
req: args.req,
})) || args
}, Promise.resolve())
if (args.collection.config.hooks.beforeOperation?.length) {
for (const hook of args.collection.config.hooks.beforeOperation) {
args =
(await hook({
args,
collection: args.collection.config,
context: args.req.context,
operation: 'countVersions',
req: args.req,
})) || args
}
}
const {
collection: { config: collectionConfig },

View File

@@ -78,10 +78,8 @@ export const createOperation = async <
// beforeOperation - Collection
// /////////////////////////////////////
await args.collection.config.hooks.beforeOperation.reduce(
async (priorHook: BeforeOperationHook | Promise<void>, hook: BeforeOperationHook) => {
await priorHook
if (args.collection.config.hooks.beforeOperation?.length) {
for (const hook of args.collection.config.hooks.beforeOperation) {
args =
(await hook({
args,
@@ -90,9 +88,8 @@ export const createOperation = async <
operation: 'create',
req: args.req,
})) || args
},
Promise.resolve(),
)
}
}
const {
autosave = false,
@@ -183,10 +180,8 @@ export const createOperation = async <
// beforeValidate - Collections
// /////////////////////////////////////
await collectionConfig.hooks.beforeValidate.reduce(
async (priorHook: BeforeValidateHook | Promise<void>, hook: BeforeValidateHook) => {
await priorHook
if (collectionConfig.hooks.beforeValidate?.length) {
for (const hook of collectionConfig.hooks.beforeValidate) {
data =
(await hook({
collection: collectionConfig,
@@ -196,27 +191,26 @@ export const createOperation = async <
originalDoc: duplicatedFromDoc,
req,
})) || data
},
Promise.resolve(),
)
}
}
// /////////////////////////////////////
// beforeChange - Collection
// /////////////////////////////////////
await collectionConfig.hooks.beforeChange.reduce(async (priorHook, hook) => {
await priorHook
data =
(await hook({
collection: collectionConfig,
context: req.context,
data,
operation: 'create',
originalDoc: duplicatedFromDoc,
req,
})) || data
}, Promise.resolve())
if (collectionConfig.hooks?.beforeChange?.length) {
for (const hook of collectionConfig.hooks.beforeChange) {
data =
(await hook({
collection: collectionConfig,
context: req.context,
data,
operation: 'create',
originalDoc: duplicatedFromDoc,
req,
})) || data
}
}
// /////////////////////////////////////
// beforeChange - Fields
@@ -332,17 +326,17 @@ export const createOperation = async <
// afterRead - Collection
// /////////////////////////////////////
await collectionConfig.hooks.afterRead.reduce(async (priorHook, hook) => {
await priorHook
result =
(await hook({
collection: collectionConfig,
context: req.context,
doc: result,
req,
})) || result
}, Promise.resolve())
if (collectionConfig.hooks?.afterRead?.length) {
for (const hook of collectionConfig.hooks.afterRead) {
result =
(await hook({
collection: collectionConfig,
context: req.context,
doc: result,
req,
})) || result
}
}
// /////////////////////////////////////
// afterChange - Fields
@@ -363,10 +357,8 @@ export const createOperation = async <
// afterChange - Collection
// /////////////////////////////////////
await collectionConfig.hooks.afterChange.reduce(
async (priorHook: AfterChangeHook | Promise<void>, hook: AfterChangeHook) => {
await priorHook
if (collectionConfig.hooks?.afterChange?.length) {
for (const hook of collectionConfig.hooks.afterChange) {
result =
(await hook({
collection: collectionConfig,
@@ -376,9 +368,8 @@ export const createOperation = async <
previousDoc: {},
req: args.req,
})) || result
},
Promise.resolve(),
)
}
}
// /////////////////////////////////////
// afterOperation - Collection

View File

@@ -54,10 +54,8 @@ export const deleteOperation = async <
// beforeOperation - Collection
// /////////////////////////////////////
await args.collection.config.hooks.beforeOperation.reduce(
async (priorHook: BeforeOperationHook | Promise<void>, hook: BeforeOperationHook) => {
await priorHook
if (args.collection.config.hooks?.beforeOperation?.length) {
for (const hook of args.collection.config.hooks.beforeOperation) {
args =
(await hook({
args,
@@ -66,9 +64,8 @@ export const deleteOperation = async <
operation: 'delete',
req: args.req,
})) || args
},
Promise.resolve(),
)
}
}
const {
collection: { config: collectionConfig },
@@ -147,16 +144,16 @@ export const deleteOperation = async <
// beforeDelete - Collection
// /////////////////////////////////////
await collectionConfig.hooks.beforeDelete.reduce(async (priorHook, hook) => {
await priorHook
return hook({
id,
collection: collectionConfig,
context: req.context,
req,
})
}, Promise.resolve())
if (collectionConfig.hooks?.beforeDelete?.length) {
for (const hook of collectionConfig.hooks.beforeDelete) {
await hook({
id,
collection: collectionConfig,
context: req.context,
req,
})
}
}
await deleteAssociatedFiles({
collectionConfig,
@@ -229,34 +226,34 @@ export const deleteOperation = async <
// afterRead - Collection
// /////////////////////////////////////
await collectionConfig.hooks.afterRead.reduce(async (priorHook, hook) => {
await priorHook
result =
(await hook({
collection: collectionConfig,
context: req.context,
doc: result || doc,
req,
})) || result
}, Promise.resolve())
if (collectionConfig.hooks?.afterRead?.length) {
for (const hook of collectionConfig.hooks.afterRead) {
result =
(await hook({
collection: collectionConfig,
context: req.context,
doc: result || doc,
req,
})) || result
}
}
// /////////////////////////////////////
// afterDelete - Collection
// /////////////////////////////////////
await collectionConfig.hooks.afterDelete.reduce(async (priorHook, hook) => {
await priorHook
result =
(await hook({
id,
collection: collectionConfig,
context: req.context,
doc: result,
req,
})) || result
}, Promise.resolve())
if (collectionConfig.hooks?.afterDelete?.length) {
for (const hook of collectionConfig.hooks.afterDelete) {
result =
(await hook({
id,
collection: collectionConfig,
context: req.context,
doc: result,
req,
})) || result
}
}
// /////////////////////////////////////
// 8. Return results

View File

@@ -48,10 +48,8 @@ export const deleteByIDOperation = async <TSlug extends CollectionSlug, TSelect
// beforeOperation - Collection
// /////////////////////////////////////
await args.collection.config.hooks.beforeOperation.reduce(
async (priorHook: BeforeOperationHook | Promise<void>, hook: BeforeOperationHook) => {
await priorHook
if (args.collection.config.hooks?.beforeOperation?.length) {
for (const hook of args.collection.config.hooks.beforeOperation) {
args =
(await hook({
args,
@@ -60,9 +58,8 @@ export const deleteByIDOperation = async <TSlug extends CollectionSlug, TSelect
operation: 'delete',
req: args.req,
})) || args
},
Promise.resolve(),
)
}
}
const {
id,
@@ -95,16 +92,16 @@ export const deleteByIDOperation = async <TSlug extends CollectionSlug, TSelect
// beforeDelete - Collection
// /////////////////////////////////////
await collectionConfig.hooks.beforeDelete.reduce(async (priorHook, hook) => {
await priorHook
return hook({
id,
collection: collectionConfig,
context: req.context,
req,
})
}, Promise.resolve())
if (collectionConfig.hooks?.beforeDelete?.length) {
for (const hook of collectionConfig.hooks.beforeDelete) {
await hook({
id,
collection: collectionConfig,
context: req.context,
req,
})
}
}
// /////////////////////////////////////
// Retrieve document
@@ -215,34 +212,34 @@ export const deleteByIDOperation = async <TSlug extends CollectionSlug, TSelect
// afterRead - Collection
// /////////////////////////////////////
await collectionConfig.hooks.afterRead.reduce(async (priorHook, hook) => {
await priorHook
result =
(await hook({
collection: collectionConfig,
context: req.context,
doc: result,
req,
})) || result
}, Promise.resolve())
if (collectionConfig.hooks?.afterRead?.length) {
for (const hook of collectionConfig.hooks.afterRead) {
result =
(await hook({
collection: collectionConfig,
context: req.context,
doc: result,
req,
})) || result
}
}
// /////////////////////////////////////
// afterDelete - Collection
// /////////////////////////////////////
await collectionConfig.hooks.afterDelete.reduce(async (priorHook, hook) => {
await priorHook
result =
(await hook({
id,
collection: collectionConfig,
context: req.context,
doc: result,
req,
})) || result
}, Promise.resolve())
if (collectionConfig.hooks?.afterDelete?.length) {
for (const hook of collectionConfig.hooks.afterDelete) {
result =
(await hook({
id,
collection: collectionConfig,
context: req.context,
doc: result,
req,
})) || result
}
}
// /////////////////////////////////////
// afterOperation - Collection

View File

@@ -63,18 +63,18 @@ export const findOperation = async <
// beforeOperation - Collection
// /////////////////////////////////////
await args.collection.config.hooks.beforeOperation.reduce(async (priorHook, hook) => {
await priorHook
args =
(await hook({
args,
collection: args.collection.config,
context: args.req.context,
operation: 'read',
req: args.req,
})) || args
}, Promise.resolve())
if (args.collection.config.hooks?.beforeOperation?.length) {
for (const hook of args.collection.config.hooks.beforeOperation) {
args =
(await hook({
args,
collection: args.collection.config,
context: args.req.context,
operation: 'read',
req: args.req,
})) || args
}
}
const {
collection: { config: collectionConfig },
@@ -257,9 +257,7 @@ export const findOperation = async <
result.docs.map(async (doc) => {
let docRef = doc
await collectionConfig.hooks.beforeRead.reduce(async (priorHook, hook) => {
await priorHook
for (const hook of collectionConfig.hooks.beforeRead) {
docRef =
(await hook({
collection: collectionConfig,
@@ -268,7 +266,7 @@ export const findOperation = async <
query: fullWhere,
req,
})) || docRef
}, Promise.resolve())
}
return docRef
}),
@@ -310,9 +308,7 @@ export const findOperation = async <
result.docs.map(async (doc) => {
let docRef = doc
await collectionConfig.hooks.afterRead.reduce(async (priorHook, hook) => {
await priorHook
for (const hook of collectionConfig.hooks.afterRead) {
docRef =
(await hook({
collection: collectionConfig,
@@ -322,7 +318,7 @@ export const findOperation = async <
query: fullWhere,
req,
})) || doc
}, Promise.resolve())
}
return docRef
}),

View File

@@ -54,18 +54,18 @@ export const findByIDOperation = async <
// beforeOperation - Collection
// /////////////////////////////////////
await args.collection.config.hooks.beforeOperation.reduce(async (priorHook, hook) => {
await priorHook
args =
(await hook({
args,
collection: args.collection.config,
context: args.req.context,
operation: 'read',
req: args.req,
})) || args
}, Promise.resolve())
if (args.collection.config.hooks?.beforeOperation?.length) {
for (const hook of args.collection.config.hooks.beforeOperation) {
args =
(await hook({
args,
collection: args.collection.config,
context: args.req.context,
operation: 'read',
req: args.req,
})) || args
}
}
const {
id,
@@ -221,18 +221,18 @@ export const findByIDOperation = async <
// beforeRead - Collection
// /////////////////////////////////////
await collectionConfig.hooks.beforeRead.reduce(async (priorHook, hook) => {
await priorHook
result =
(await hook({
collection: collectionConfig,
context: req.context,
doc: result,
query: findOneArgs.where,
req,
})) || result
}, Promise.resolve())
if (collectionConfig.hooks?.beforeRead?.length) {
for (const hook of collectionConfig.hooks.beforeRead) {
result =
(await hook({
collection: collectionConfig,
context: req.context,
doc: result,
query: findOneArgs.where,
req,
})) || result
}
}
// /////////////////////////////////////
// afterRead - Fields
@@ -259,18 +259,18 @@ export const findByIDOperation = async <
// afterRead - Collection
// /////////////////////////////////////
await collectionConfig.hooks.afterRead.reduce(async (priorHook, hook) => {
await priorHook
result =
(await hook({
collection: collectionConfig,
context: req.context,
doc: result,
query: findOneArgs.where,
req,
})) || result
}, Promise.resolve())
if (collectionConfig.hooks?.afterRead?.length) {
for (const hook of collectionConfig.hooks.afterRead) {
result =
(await hook({
collection: collectionConfig,
context: req.context,
doc: result,
query: findOneArgs.where,
req,
})) || result
}
}
// /////////////////////////////////////
// afterOperation - Collection

View File

@@ -101,18 +101,18 @@ export const findVersionByIDOperation = async <TData extends TypeWithID = any>(
// beforeRead - Collection
// /////////////////////////////////////
await collectionConfig.hooks.beforeRead.reduce(async (priorHook, hook) => {
await priorHook
result.version =
(await hook({
collection: collectionConfig,
context: req.context,
doc: result.version,
query: fullWhere,
req,
})) || result.version
}, Promise.resolve())
if (collectionConfig.hooks?.beforeRead?.length) {
for (const hook of collectionConfig.hooks.beforeRead) {
result.version =
(await hook({
collection: collectionConfig,
context: req.context,
doc: result.version,
query: fullWhere,
req,
})) || result.version
}
}
// /////////////////////////////////////
// afterRead - Fields
@@ -139,18 +139,18 @@ export const findVersionByIDOperation = async <TData extends TypeWithID = any>(
// afterRead - Collection
// /////////////////////////////////////
await collectionConfig.hooks.afterRead.reduce(async (priorHook, hook) => {
await priorHook
result.version =
(await hook({
collection: collectionConfig,
context: req.context,
doc: result.version,
query: fullWhere,
req,
})) || result.version
}, Promise.resolve())
if (collectionConfig.hooks?.afterRead?.length) {
for (const hook of collectionConfig.hooks.afterRead) {
result.version =
(await hook({
collection: collectionConfig,
context: req.context,
doc: result.version,
query: fullWhere,
req,
})) || result.version
}
}
// /////////////////////////////////////
// Return results

View File

@@ -96,18 +96,19 @@ export const findVersionsOperation = async <TData extends TypeWithVersion<TData>
if (!docRef.version) {
;(docRef as any).version = {}
}
await collectionConfig.hooks.beforeRead.reduce(async (priorHook, hook) => {
await priorHook
docRef.version =
(await hook({
collection: collectionConfig,
context: req.context,
doc: docRef.version,
query: fullWhere,
req,
})) || docRef.version
}, Promise.resolve())
if (collectionConfig.hooks?.beforeRead?.length) {
for (const hook of collectionConfig.hooks.beforeRead) {
docRef.version =
(await hook({
collection: collectionConfig,
context: req.context,
doc: docRef.version,
query: fullWhere,
req,
})) || docRef.version
}
}
return docRef
}),
@@ -147,9 +148,7 @@ export const findVersionsOperation = async <TData extends TypeWithVersion<TData>
result.docs.map(async (doc) => {
const docRef = doc
await collectionConfig.hooks.afterRead.reduce(async (priorHook, hook) => {
await priorHook
for (const hook of collectionConfig.hooks.afterRead) {
docRef.version =
(await hook({
collection: collectionConfig,
@@ -159,7 +158,7 @@ export const findVersionsOperation = async <TData extends TypeWithVersion<TData>
query: fullWhere,
req,
})) || doc.version
}, Promise.resolve())
}
return docRef
}),

View File

@@ -165,17 +165,17 @@ export const restoreVersionOperation = async <TData extends TypeWithID = any>(
// afterRead - Collection
// /////////////////////////////////////
await collectionConfig.hooks.afterRead.reduce(async (priorHook, hook) => {
await priorHook
result =
(await hook({
collection: collectionConfig,
context: req.context,
doc: result,
req,
})) || result
}, Promise.resolve())
if (collectionConfig.hooks?.afterRead?.length) {
for (const hook of collectionConfig.hooks.afterRead) {
result =
(await hook({
collection: collectionConfig,
context: req.context,
doc: result,
req,
})) || result
}
}
// /////////////////////////////////////
// afterChange - Fields
@@ -196,19 +196,19 @@ export const restoreVersionOperation = async <TData extends TypeWithID = any>(
// afterChange - Collection
// /////////////////////////////////////
await collectionConfig.hooks.afterChange.reduce(async (priorHook, hook) => {
await priorHook
result =
(await hook({
collection: collectionConfig,
context: req.context,
doc: result,
operation: 'update',
previousDoc: prevDocWithLocales,
req,
})) || result
}, Promise.resolve())
if (collectionConfig.hooks?.afterChange?.length) {
for (const hook of collectionConfig.hooks.afterChange) {
result =
(await hook({
collection: collectionConfig,
context: req.context,
doc: result,
operation: 'update',
previousDoc: prevDocWithLocales,
req,
})) || result
}
}
return result
} catch (error: unknown) {

View File

@@ -62,18 +62,18 @@ export const updateOperation = async <
// beforeOperation - Collection
// /////////////////////////////////////
await args.collection.config.hooks.beforeOperation.reduce(async (priorHook, hook) => {
await priorHook
args =
(await hook({
args,
collection: args.collection.config,
context: args.req.context,
operation: 'update',
req: args.req,
})) || args
}, Promise.resolve())
if (args.collection.config.hooks?.beforeOperation?.length) {
for (const hook of args.collection.config.hooks.beforeOperation) {
args =
(await hook({
args,
collection: args.collection.config,
context: args.req.context,
operation: 'update',
req: args.req,
})) || args
}
}
const {
collection: { config: collectionConfig },

View File

@@ -64,18 +64,18 @@ export const updateByIDOperation = async <
// beforeOperation - Collection
// /////////////////////////////////////
await args.collection.config.hooks.beforeOperation.reduce(async (priorHook, hook) => {
await priorHook
args =
(await hook({
args,
collection: args.collection.config,
context: args.req.context,
operation: 'update',
req: args.req,
})) || args
}, Promise.resolve())
if (args.collection.config.hooks?.beforeOperation?.length) {
for (const hook of args.collection.config.hooks.beforeOperation) {
args =
(await hook({
args,
collection: args.collection.config,
context: args.req.context,
operation: 'update',
req: args.req,
})) || args
}
}
if (args.publishSpecificLocale) {
args.req.locale = args.publishSpecificLocale

View File

@@ -171,19 +171,19 @@ export const updateDocument = async <
// beforeValidate - Collection
// /////////////////////////////////////
await collectionConfig.hooks.beforeValidate.reduce(async (priorHook, hook) => {
await priorHook
data =
(await hook({
collection: collectionConfig,
context: req.context,
data,
operation: 'update',
originalDoc,
req,
})) || data
}, Promise.resolve())
if (collectionConfig.hooks?.beforeValidate?.length) {
for (const hook of collectionConfig.hooks.beforeValidate) {
data =
(await hook({
collection: collectionConfig,
context: req.context,
data,
operation: 'update',
originalDoc,
req,
})) || data
}
}
// /////////////////////////////////////
// Write files to local storage
@@ -197,19 +197,19 @@ export const updateDocument = async <
// beforeChange - Collection
// /////////////////////////////////////
await collectionConfig.hooks.beforeChange.reduce(async (priorHook, hook) => {
await priorHook
data =
(await hook({
collection: collectionConfig,
context: req.context,
data,
operation: 'update',
originalDoc,
req,
})) || data
}, Promise.resolve())
if (collectionConfig.hooks?.beforeChange?.length) {
for (const hook of collectionConfig.hooks.beforeChange) {
data =
(await hook({
collection: collectionConfig,
context: req.context,
data,
operation: 'update',
originalDoc,
req,
})) || data
}
}
// /////////////////////////////////////
// beforeChange - Fields
@@ -338,17 +338,17 @@ export const updateDocument = async <
// afterRead - Collection
// /////////////////////////////////////
await collectionConfig.hooks.afterRead.reduce(async (priorHook, hook) => {
await priorHook
result =
(await hook({
collection: collectionConfig,
context: req.context,
doc: result,
req,
})) || result
}, Promise.resolve())
if (collectionConfig.hooks?.afterRead?.length) {
for (const hook of collectionConfig.hooks.afterRead) {
result =
(await hook({
collection: collectionConfig,
context: req.context,
doc: result,
req,
})) || result
}
}
// /////////////////////////////////////
// afterChange - Fields
@@ -369,19 +369,19 @@ export const updateDocument = async <
// afterChange - Collection
// /////////////////////////////////////
await collectionConfig.hooks.afterChange.reduce(async (priorHook, hook) => {
await priorHook
result =
(await hook({
collection: collectionConfig,
context: req.context,
doc: result,
operation: 'update',
previousDoc: originalDoc,
req,
})) || result
}, Promise.resolve())
if (collectionConfig.hooks?.afterChange?.length) {
for (const hook of collectionConfig.hooks.afterChange) {
result =
(await hook({
collection: collectionConfig,
context: req.context,
doc: result,
operation: 'update',
previousDoc: originalDoc,
req,
})) || result
}
}
return result as TransformCollectionWithSelect<TSlug, TSelect>
}

View File

@@ -125,10 +125,8 @@ export const buildAfterOperation = async <
let newResult = result as OperationResult<TOperationGeneric, O>
await args.collection.config.hooks.afterOperation.reduce(
async (priorHook, hook: AfterOperationHook<TOperationGeneric>) => {
await priorHook
if (args.collection.config.hooks?.afterOperation?.length) {
for (const hook of args.collection.config.hooks.afterOperation) {
const hookResult = await hook({
args,
collection,
@@ -140,9 +138,8 @@ export const buildAfterOperation = async <
if (hookResult !== undefined) {
newResult = hookResult as OperationResult<TOperationGeneric, O>
}
},
Promise.resolve(),
)
}
}
return newResult
}

View File

@@ -9,11 +9,10 @@ import { sanitizeConfig } from './sanitize.js'
*/
export async function buildConfig(config: Config): Promise<SanitizedConfig> {
if (Array.isArray(config.plugins)) {
const configAfterPlugins = await config.plugins.reduce(async (acc, plugin) => {
const configAfterPlugin = await acc
return plugin(configAfterPlugin)
}, Promise.resolve(config))
let configAfterPlugins = config
for (const plugin of config.plugins) {
configAfterPlugins = await plugin(configAfterPlugins)
}
return await sanitizeConfig(configAfterPlugins)
}

View File

@@ -179,10 +179,7 @@ export const sanitizeConfig = async (incomingConfig: Config): Promise<SanitizedC
}))
} else {
// is Locale[], so convert to string[] for localeCodes
config.localization.localeCodes = config.localization.locales.reduce((locales, locale) => {
locales.push(locale.code)
return locales
}, [] as string[])
config.localization.localeCodes = config.localization.locales.map((locale) => locale.code)
config.localization.locales = (
config.localization as LocalizationConfigWithLabels

View File

@@ -75,10 +75,8 @@ export const promise = async ({
if (fieldAffectsData(field)) {
// Execute hooks
if (field.hooks?.afterChange) {
await field.hooks.afterChange.reduce(async (priorHook, currentHook) => {
await priorHook
const hookedValue = await currentHook({
for (const hook of field.hooks.afterChange) {
const hookedValue = await hook({
blockData,
collection,
context,
@@ -102,7 +100,7 @@ export const promise = async ({
if (hookedValue !== undefined) {
siblingDoc[field.name] = hookedValue
}
}, Promise.resolve())
}
}
}
@@ -242,17 +240,15 @@ export const promise = async ({
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') {
if (typeof field.editor === 'function') {
throw new Error('Attempted to access unsanitized rich text editor.')
}
const editor: RichTextAdapter = field?.editor
const editor: RichTextAdapter = field.editor
if (editor?.hooks?.afterChange?.length) {
await editor.hooks.afterChange.reduce(async (priorHook, currentHook) => {
await priorHook
const hookedValue = await currentHook({
for (const hook of editor.hooks.afterChange) {
const hookedValue = await hook({
collection,
context,
data,
@@ -275,7 +271,7 @@ export const promise = async ({
if (hookedValue !== undefined) {
siblingDoc[field.name] = hookedValue
}
}, Promise.resolve())
}
}
break
}

View File

@@ -237,18 +237,17 @@ export const promise = async ({
if (fieldAffectsData(field)) {
// Execute hooks
if (triggerHooks && field.hooks?.afterRead) {
await field.hooks.afterRead.reduce(async (priorHook, currentHook) => {
await priorHook
for (const hook of field.hooks.afterRead) {
const shouldRunHookOnAllLocales =
fieldShouldBeLocalized({ field, parentIsLocalized }) &&
(locale === 'all' || !flattenLocales) &&
typeof siblingDoc[field.name] === 'object'
if (shouldRunHookOnAllLocales) {
const hookPromises = Object.entries(siblingDoc[field.name]).map(([locale, value]) =>
(async () => {
const hookedValue = await currentHook({
const localesAndValues = Object.entries(siblingDoc[field.name])
await Promise.all(
localesAndValues.map(async ([localeKey, value]) => {
const hookedValue = await hook({
blockData,
collection,
context,
@@ -273,14 +272,12 @@ export const promise = async ({
})
if (hookedValue !== undefined) {
siblingDoc[field.name][locale] = hookedValue
siblingDoc[field.name][localeKey] = hookedValue
}
})(),
}),
)
await Promise.all(hookPromises)
} else {
const hookedValue = await currentHook({
const hookedValue = await hook({
blockData,
collection,
context,
@@ -308,7 +305,7 @@ export const promise = async ({
siblingDoc[field.name] = hookedValue
}
}
}, Promise.resolve())
}
}
// Execute access control
@@ -677,18 +674,18 @@ export const promise = async ({
const editor: RichTextAdapter = field?.editor
if (editor?.hooks?.afterRead?.length) {
await editor.hooks.afterRead.reduce(async (priorHook, currentHook) => {
await priorHook
for (const hook of editor.hooks.afterRead) {
const shouldRunHookOnAllLocales =
fieldShouldBeLocalized({ field, parentIsLocalized }) &&
(locale === 'all' || !flattenLocales) &&
typeof siblingDoc[field.name] === 'object'
if (shouldRunHookOnAllLocales) {
const hookPromises = Object.entries(siblingDoc[field.name]).map(([locale, value]) =>
(async () => {
const hookedValue = await currentHook({
const localesAndValues = Object.entries(siblingDoc[field.name])
await Promise.all(
localesAndValues.map(async ([locale, value]) => {
const hookedValue = await hook({
collection,
context,
currentDepth,
@@ -722,12 +719,10 @@ export const promise = async ({
if (hookedValue !== undefined) {
siblingDoc[field.name][locale] = hookedValue
}
})(),
}),
)
await Promise.all(hookPromises)
} else {
const hookedValue = await currentHook({
const hookedValue = await hook({
collection,
context,
currentDepth,
@@ -762,7 +757,7 @@ export const promise = async ({
siblingDoc[field.name] = hookedValue
}
}
}, Promise.resolve())
}
}
break
}

View File

@@ -81,10 +81,9 @@ export const beforeChange = async <T extends JsonObject>({
)
}
await mergeLocaleActions.reduce(async (priorAction, action) => {
await priorAction
for (const action of mergeLocaleActions) {
await action()
}, Promise.resolve())
}
return data
}

View File

@@ -31,7 +31,7 @@ type Args = {
fieldIndex: number
global: null | SanitizedGlobalConfig
id?: number | string
mergeLocaleActions: (() => Promise<void>)[]
mergeLocaleActions: (() => Promise<void> | void)[]
operation: Operation
parentIndexPath: string
parentIsLocalized: boolean
@@ -108,10 +108,8 @@ export const promise = async ({
// Execute hooks
if (field.hooks?.beforeChange) {
await field.hooks.beforeChange.reduce(async (priorHook, currentHook) => {
await priorHook
const hookedValue = await currentHook({
for (const hook of field.hooks.beforeChange) {
const hookedValue = await hook({
blockData,
collection,
context,
@@ -135,7 +133,7 @@ export const promise = async ({
if (hookedValue !== undefined) {
siblingData[field.name] = hookedValue
}
}, Promise.resolve())
}
}
// Validate
@@ -192,28 +190,20 @@ export const promise = async ({
// Push merge locale action if applicable
if (localization && fieldShouldBeLocalized({ field, parentIsLocalized })) {
mergeLocaleActions.push(async () => {
const localeData = await localization.localeCodes.reduce(
async (localizedValuesPromise: Promise<JsonObject>, locale) => {
const localizedValues = await localizedValuesPromise
const fieldValue =
locale === req.locale
? siblingData[field.name]
: siblingDocWithLocales?.[field.name]?.[locale]
mergeLocaleActions.push(() => {
const localeData = {}
// const result = await localizedValues
// update locale value if it's not undefined
if (typeof fieldValue !== 'undefined') {
return {
...localizedValues,
[locale]: fieldValue,
}
}
for (const locale of localization.localeCodes) {
const fieldValue =
locale === req.locale
? siblingData[field.name]
: siblingDocWithLocales?.[field.name]?.[locale]
return localizedValuesPromise
},
Promise.resolve({}),
)
// update locale value if it's not undefined
if (typeof fieldValue !== 'undefined') {
localeData[locale] = fieldValue
}
}
// If there are locales with data, set the data
if (Object.keys(localeData).length > 0) {
@@ -423,10 +413,8 @@ export const promise = async ({
const editor: RichTextAdapter = field?.editor
if (editor?.hooks?.beforeChange?.length) {
await editor.hooks.beforeChange.reduce(async (priorHook, currentHook) => {
await priorHook
const hookedValue = await currentHook({
for (const hook of editor.hooks.beforeChange) {
const hookedValue = await hook({
collection,
context,
data,
@@ -453,7 +441,7 @@ export const promise = async ({
if (hookedValue !== undefined) {
siblingData[field.name] = hookedValue
}
}, Promise.resolve())
}
}
break

View File

@@ -28,7 +28,7 @@ type Args = {
fields: (Field | TabAsField)[]
global: null | SanitizedGlobalConfig
id?: number | string
mergeLocaleActions: (() => Promise<void>)[]
mergeLocaleActions: (() => Promise<void> | void)[]
operation: Operation
parentIndexPath: string
/**

View File

@@ -6,7 +6,6 @@ import type { Block, Field, FieldHookArgs, TabAsField } from '../../config/types
import { fieldAffectsData, fieldShouldBeLocalized } from '../../config/types.js'
import { getFieldPathsModified as getFieldPaths } from '../../getFieldPaths.js'
import { runBeforeDuplicateHooks } from './runHook.js'
import { traverseFields } from './traverseFields.js'
type Args<T> = {
@@ -68,42 +67,37 @@ export const promise = async <T>({
// Run field beforeDuplicate hooks
if (Array.isArray(field.hooks?.beforeDuplicate)) {
if (fieldIsLocalized) {
const localeData = await localization.localeCodes.reduce(
async (localizedValuesPromise: Promise<JsonObject>, locale) => {
const localizedValues = await localizedValuesPromise
const localeData: JsonObject = {}
const beforeDuplicateArgs: FieldHookArgs = {
blockData,
collection,
context,
data: doc,
field,
global: undefined,
indexPath: indexPathSegments,
path: pathSegments,
previousSiblingDoc: siblingDoc,
previousValue: siblingDoc[field.name]?.[locale],
req,
schemaPath: schemaPathSegments,
siblingData: siblingDoc,
siblingDocWithLocales: siblingDoc,
siblingFields,
value: siblingDoc[field.name]?.[locale],
}
for (const locale of localization.localeCodes) {
const beforeDuplicateArgs: FieldHookArgs = {
blockData,
collection,
context,
data: doc,
field,
global: undefined,
indexPath: indexPathSegments,
path: pathSegments,
previousSiblingDoc: siblingDoc,
previousValue: siblingDoc[field.name]?.[locale],
req,
schemaPath: schemaPathSegments,
siblingData: siblingDoc,
siblingDocWithLocales: siblingDoc,
siblingFields,
value: siblingDoc[field.name]?.[locale],
}
const hookResult = await runBeforeDuplicateHooks(beforeDuplicateArgs)
let hookResult
for (const hook of field.hooks.beforeDuplicate) {
hookResult = await hook(beforeDuplicateArgs)
}
if (typeof hookResult !== 'undefined') {
return {
...localizedValues,
[locale]: hookResult,
}
}
return localizedValuesPromise
},
Promise.resolve({}),
)
if (typeof hookResult !== 'undefined') {
localeData[locale] = hookResult
}
}
siblingDoc[field.name] = localeData
} else {
@@ -126,7 +120,11 @@ export const promise = async <T>({
value: siblingDoc[field.name],
}
const hookResult = await runBeforeDuplicateHooks(beforeDuplicateArgs)
let hookResult
for (const hook of field.hooks.beforeDuplicate) {
hookResult = await hook(beforeDuplicateArgs)
}
if (typeof hookResult !== 'undefined') {
siblingDoc[field.name] = hookResult
}

View File

@@ -1,8 +0,0 @@
// @ts-strict-ignore
import type { FieldHookArgs } from '../../config/types.js'
export const runBeforeDuplicateHooks = async (args: FieldHookArgs) =>
await args.field.hooks.beforeDuplicate.reduce(async (priorHook, currentHook) => {
await priorHook
return await currentHook(args)
}, Promise.resolve())

View File

@@ -276,10 +276,8 @@ export const promise = async <T>({
// Execute hooks
if (field.hooks?.beforeValidate) {
await field.hooks.beforeValidate.reduce(async (priorHook, currentHook) => {
await priorHook
const hookedValue = await currentHook({
for (const hook of field.hooks.beforeValidate) {
const hookedValue = await hook({
blockData,
collection,
context,
@@ -303,7 +301,7 @@ export const promise = async <T>({
if (hookedValue !== undefined) {
siblingData[field.name] = hookedValue
}
}, Promise.resolve())
}
}
// Execute access control
@@ -493,10 +491,8 @@ export const promise = async <T>({
const editor: RichTextAdapter = field?.editor
if (editor?.hooks?.beforeValidate?.length) {
await editor.hooks.beforeValidate.reduce(async (priorHook, currentHook) => {
await priorHook
const hookedValue = await currentHook({
for (const hook of editor.hooks.beforeValidate) {
const hookedValue = await hook({
collection,
context,
data,
@@ -519,7 +515,7 @@ export const promise = async <T>({
if (hookedValue !== undefined) {
siblingData[field.name] = hookedValue
}
}, Promise.resolve())
}
}
break
}

View File

@@ -133,17 +133,17 @@ export const findOneOperation = async <T extends Record<string, unknown>>(
// Execute before global hook
// /////////////////////////////////////
await globalConfig.hooks.beforeRead.reduce(async (priorHook, hook) => {
await priorHook
doc =
(await hook({
context: req.context,
doc,
global: globalConfig,
req,
})) || doc
}, Promise.resolve())
if (globalConfig.hooks?.beforeRead?.length) {
for (const hook of globalConfig.hooks.beforeRead) {
doc =
(await hook({
context: req.context,
doc,
global: globalConfig,
req,
})) || doc
}
}
// /////////////////////////////////////
// Execute globalType field if not selected
@@ -182,17 +182,17 @@ export const findOneOperation = async <T extends Record<string, unknown>>(
// Execute after global hook
// /////////////////////////////////////
await globalConfig.hooks.afterRead.reduce(async (priorHook, hook) => {
await priorHook
doc =
(await hook({
context: req.context,
doc,
global: globalConfig,
req,
})) || doc
}, Promise.resolve())
if (globalConfig.hooks?.afterRead?.length) {
for (const hook of globalConfig.hooks.afterRead) {
doc =
(await hook({
context: req.context,
doc,
global: globalConfig,
req,
})) || doc
}
}
// /////////////////////////////////////
// Return results

View File

@@ -102,17 +102,17 @@ export const findVersionByIDOperation = async <T extends TypeWithVersion<T> = an
// beforeRead - Collection
// /////////////////////////////////////
await globalConfig.hooks.beforeRead.reduce(async (priorHook, hook) => {
await priorHook
result =
(await hook({
context: req.context,
doc: result.version,
global: globalConfig,
req,
})) || result.version
}, Promise.resolve())
if (globalConfig.hooks?.beforeRead?.length) {
for (const hook of globalConfig.hooks.beforeRead) {
result =
(await hook({
context: req.context,
doc: result.version,
global: globalConfig,
req,
})) || result.version
}
}
// /////////////////////////////////////
// afterRead - Fields
@@ -139,18 +139,18 @@ export const findVersionByIDOperation = async <T extends TypeWithVersion<T> = an
// afterRead - Global
// /////////////////////////////////////
await globalConfig.hooks.afterRead.reduce(async (priorHook, hook) => {
await priorHook
result.version =
(await hook({
context: req.context,
doc: result.version,
global: globalConfig,
query: findGlobalVersionsArgs.where,
req,
})) || result.version
}, Promise.resolve())
if (globalConfig.hooks?.afterRead?.length) {
for (const hook of globalConfig.hooks.afterRead) {
result.version =
(await hook({
context: req.context,
doc: result.version,
global: globalConfig,
query: findGlobalVersionsArgs.where,
req,
})) || result.version
}
}
return result
} catch (error: unknown) {

View File

@@ -126,15 +126,12 @@ export const findVersionsOperation = async <T extends TypeWithVersion<T>>(
// afterRead - Global
// /////////////////////////////////////
result = {
...result,
docs: await Promise.all(
if (globalConfig.hooks?.afterRead?.length) {
result.docs = await Promise.all(
result.docs.map(async (doc) => {
const docRef = doc
await globalConfig.hooks.afterRead.reduce(async (priorHook, hook) => {
await priorHook
for (const hook of globalConfig.hooks.afterRead) {
docRef.version =
(await hook({
context: req.context,
@@ -144,11 +141,11 @@ export const findVersionsOperation = async <T extends TypeWithVersion<T>>(
query: fullWhere,
req,
})) || doc.version
}, Promise.resolve())
}
return docRef
}),
),
)
}
// /////////////////////////////////////

View File

@@ -143,17 +143,17 @@ export const restoreVersionOperation = async <T extends TypeWithVersion<T> = any
// afterRead - Global
// /////////////////////////////////////
await globalConfig.hooks.afterRead.reduce(async (priorHook, hook) => {
await priorHook
result =
(await hook({
context: req.context,
doc: result,
global: globalConfig,
req,
})) || result
}, Promise.resolve())
if (globalConfig.hooks?.afterRead?.length) {
for (const hook of globalConfig.hooks.afterRead) {
result =
(await hook({
context: req.context,
doc: result,
global: globalConfig,
req,
})) || result
}
}
// /////////////////////////////////////
// afterChange - Fields
@@ -174,18 +174,18 @@ export const restoreVersionOperation = async <T extends TypeWithVersion<T> = any
// afterChange - Global
// /////////////////////////////////////
await globalConfig.hooks.afterChange.reduce(async (priorHook, hook) => {
await priorHook
result =
(await hook({
context: req.context,
doc: result,
global: globalConfig,
previousDoc,
req,
})) || result
}, Promise.resolve())
if (globalConfig.hooks?.afterChange?.length) {
for (const hook of globalConfig.hooks.afterChange) {
result =
(await hook({
context: req.context,
doc: result,
global: globalConfig,
previousDoc,
req,
})) || result
}
}
if (shouldCommit) {
await commitTransaction(req)

View File

@@ -168,35 +168,35 @@ export const updateOperation = async <
// beforeValidate - Global
// /////////////////////////////////////
await globalConfig.hooks.beforeValidate.reduce(async (priorHook, hook) => {
await priorHook
data =
(await hook({
context: req.context,
data,
global: globalConfig,
originalDoc,
req,
})) || data
}, Promise.resolve())
if (globalConfig.hooks?.beforeValidate?.length) {
for (const hook of globalConfig.hooks.beforeValidate) {
data =
(await hook({
context: req.context,
data,
global: globalConfig,
originalDoc,
req,
})) || data
}
}
// /////////////////////////////////////
// beforeChange - Global
// /////////////////////////////////////
await globalConfig.hooks.beforeChange.reduce(async (priorHook, hook) => {
await priorHook
data =
(await hook({
context: req.context,
data,
global: globalConfig,
originalDoc,
req,
})) || data
}, Promise.resolve())
if (globalConfig.hooks?.beforeChange?.length) {
for (const hook of globalConfig.hooks.beforeChange) {
data =
(await hook({
context: req.context,
data,
global: globalConfig,
originalDoc,
req,
})) || data
}
}
// /////////////////////////////////////
// beforeChange - Fields
@@ -326,17 +326,17 @@ export const updateOperation = async <
// afterRead - Global
// /////////////////////////////////////
await globalConfig.hooks.afterRead.reduce(async (priorHook, hook) => {
await priorHook
result =
(await hook({
context: req.context,
doc: result,
global: globalConfig,
req,
})) || result
}, Promise.resolve())
if (globalConfig.hooks?.afterRead?.length) {
for (const hook of globalConfig.hooks.afterRead) {
result =
(await hook({
context: req.context,
doc: result,
global: globalConfig,
req,
})) || result
}
}
// /////////////////////////////////////
// afterChange - Fields
@@ -357,18 +357,18 @@ export const updateOperation = async <
// afterChange - Global
// /////////////////////////////////////
await globalConfig.hooks.afterChange.reduce(async (priorHook, hook) => {
await priorHook
result =
(await hook({
context: req.context,
doc: result,
global: globalConfig,
previousDoc: originalDoc,
req,
})) || result
}, Promise.resolve())
if (globalConfig.hooks?.afterChange?.length) {
for (const hook of globalConfig.hooks.afterChange) {
result =
(await hook({
context: req.context,
doc: result,
global: globalConfig,
previousDoc: originalDoc,
req,
})) || result
}
}
// /////////////////////////////////////
// Return results

View File

@@ -22,8 +22,12 @@ type Handler = (
}
export const tempFileHandler: Handler = (options, fieldname, filename) => {
const dir = path.normalize(options.tempFileDir)
const tempFilePath = path.join(process.cwd(), dir, getTempFilename())
const tempFilePath = path.join(
process.cwd(),
// Remove drive letter prefix on Windows
path.normalize(options.tempFileDir).replace(/^[A-Z]:\\/i, ''),
getTempFilename(),
)
checkAndMakeDir({ createParentPath: true }, tempFilePath)
debugLog(options, `Temporary file path is ${tempFilePath}`)

View File

@@ -119,7 +119,7 @@ export const checkAndMakeDir: CheckAndMakeDir = (fileUploadOptions, filePath) =>
return false
}
// Check whether folder for the file exists.
const parentPath = path.dirname(filePath)
const parentPath = path.resolve(path.dirname(filePath))
// Create folder if it doesn't exist.
if (!fs.existsSync(parentPath)) {
fs.mkdirSync(parentPath, { recursive: true })

View File

@@ -1156,14 +1156,13 @@ export function configToJSONSchema(
)
: {}
let blocksDefinition: JSONSchema4 | undefined = undefined
const blocksDefinition: JSONSchema4 | undefined = {
type: 'object',
additionalProperties: false,
properties: {},
required: [],
}
if (config?.blocks?.length) {
blocksDefinition = {
type: 'object',
additionalProperties: false,
properties: {},
required: [],
}
for (const block of config.blocks) {
const blockFieldSchemas = fieldsToJSONSchema(
collectionIDFieldTypes,

View File

@@ -1,2 +1,3 @@
export { TenantField } from '../components/TenantField/index.client.js'
export { TenantSelector } from '../components/TenantSelector/index.js'
export { useTenantSelection } from '../providers/TenantSelectionProvider/index.client.js'

View File

@@ -115,7 +115,13 @@ export const afterTenantDelete =
id: user.id,
collection: usersSlug,
data: {
tenants: (user.tenants || []).filter(({ tenant: tenantID }) => tenantID !== id),
[usersTenantsArrayFieldName]: (user[usersTenantsArrayFieldName] || []).filter(
(row: Record<string, string>) => {
if (row[usersTenantsArrayTenantFieldName]) {
return row[usersTenantsArrayTenantFieldName] !== id
}
},
),
},
}),
)

View File

@@ -92,6 +92,8 @@ export const multiTenantPlugin =
addCollectionAccess({
collection: adminUsersCollection,
fieldName: `${tenantsArrayFieldName}.${tenantsArrayTenantFieldName}`,
tenantsArrayFieldName,
tenantsArrayTenantFieldName,
userHasAccessToAllTenants,
})
@@ -130,6 +132,8 @@ export const multiTenantPlugin =
addCollectionAccess({
collection,
fieldName: 'id',
tenantsArrayFieldName,
tenantsArrayTenantFieldName,
userHasAccessToAllTenants,
})
}
@@ -205,6 +209,8 @@ export const multiTenantPlugin =
addCollectionAccess({
collection,
fieldName: tenantFieldName,
tenantsArrayFieldName,
tenantsArrayTenantFieldName,
userHasAccessToAllTenants,
})
}

View File

@@ -9,9 +9,26 @@ import React, { createContext } from 'react'
import { SELECT_ALL } from '../../constants.js'
type ContextType = {
/**
* Array of options to select from
*/
options: OptionObject[]
/**
* The currently selected tenant ID
*/
selectedTenantID: number | string | undefined
/**
* Prevents a refresh when the tenant is changed
*
* If not switching tenants while viewing a "global", set to true
*/
setPreventRefreshOnChange: React.Dispatch<React.SetStateAction<boolean>>
/**
* Sets the selected tenant ID
*
* @param args.id - The ID of the tenant to select
* @param args.refresh - Whether to refresh the page after changing the tenant
*/
setTenant: (args: { id: number | string | undefined; refresh?: boolean }) => void
}

View File

@@ -20,6 +20,8 @@ const collectionAccessKeys: AllAccessKeys<
type Args<ConfigType> = {
collection: CollectionConfig
fieldName: string
tenantsArrayFieldName?: string
tenantsArrayTenantFieldName?: string
userHasAccessToAllTenants: Required<
MultiTenantPluginConfig<ConfigType>
>['userHasAccessToAllTenants']
@@ -32,6 +34,8 @@ type Args<ConfigType> = {
export const addCollectionAccess = <ConfigType>({
collection,
fieldName,
tenantsArrayFieldName,
tenantsArrayTenantFieldName,
userHasAccessToAllTenants,
}: Args<ConfigType>): void => {
collectionAccessKeys.forEach((key) => {
@@ -40,7 +44,11 @@ export const addCollectionAccess = <ConfigType>({
}
collection.access[key] = withTenantAccess<ConfigType>({
accessFunction: collection.access?.[key],
collection,
fieldName: key === 'readVersions' ? `version.${fieldName}` : fieldName,
operation: key,
tenantsArrayFieldName,
tenantsArrayTenantFieldName,
userHasAccessToAllTenants,
})
})

View File

@@ -2,14 +2,25 @@ import type { Where } from 'payload'
import type { UserWithTenantsField } from '../types.js'
import { defaults } from '../defaults.js'
import { getUserTenantIDs } from './getUserTenantIDs.js'
type Args = {
fieldName: string
tenantsArrayFieldName?: string
tenantsArrayTenantFieldName?: string
user: UserWithTenantsField
}
export function getTenantAccess({ fieldName, user }: Args): Where {
const userAssignedTenantIDs = getUserTenantIDs(user)
export function getTenantAccess({
fieldName,
tenantsArrayFieldName = defaults.tenantsArrayFieldName,
tenantsArrayTenantFieldName = defaults.tenantsArrayTenantFieldName,
user,
}: Args): Where {
const userAssignedTenantIDs = getUserTenantIDs(user, {
tenantsArrayFieldName,
tenantsArrayTenantFieldName,
})
return {
[fieldName]: {

View File

@@ -1,5 +1,6 @@
import type { Tenant, UserWithTenantsField } from '../types.js'
import { defaults } from '../defaults.js'
import { extractID } from './extractID.js'
/**
@@ -9,15 +10,26 @@ import { extractID } from './extractID.js'
*/
export const getUserTenantIDs = <IDType extends number | string>(
user: null | UserWithTenantsField,
options?: {
tenantsArrayFieldName?: string
tenantsArrayTenantFieldName?: string
},
): IDType[] => {
if (!user) {
return []
}
const {
tenantsArrayFieldName = defaults.tenantsArrayFieldName,
tenantsArrayTenantFieldName = defaults.tenantsArrayTenantFieldName,
} = options || {}
return (
user?.tenants?.reduce<IDType[]>((acc, { tenant }) => {
if (tenant) {
acc.push(extractID<IDType>(tenant as Tenant<IDType>))
(Array.isArray(user[tenantsArrayFieldName]) ? user[tenantsArrayFieldName] : [])?.reduce<
IDType[]
>((acc, row) => {
if (row[tenantsArrayTenantFieldName]) {
acc.push(extractID<IDType>(row[tenantsArrayTenantFieldName] as Tenant<IDType>))
}
return acc

View File

@@ -1,4 +1,12 @@
import type { Access, AccessArgs, AccessResult, User } from 'payload'
import type {
Access,
AccessArgs,
AccessResult,
AllOperations,
CollectionConfig,
User,
Where,
} from 'payload'
import type { MultiTenantPluginConfig, UserWithTenantsField } from '../types.js'
@@ -7,15 +15,26 @@ import { getTenantAccess } from './getTenantAccess.js'
type Args<ConfigType> = {
accessFunction?: Access
collection: CollectionConfig
fieldName: string
operation: AllOperations
tenantsArrayFieldName?: string
tenantsArrayTenantFieldName?: string
userHasAccessToAllTenants: Required<
MultiTenantPluginConfig<ConfigType>
>['userHasAccessToAllTenants']
}
export const withTenantAccess =
<ConfigType>({ accessFunction, fieldName, userHasAccessToAllTenants }: Args<ConfigType>) =>
<ConfigType>({
accessFunction,
collection,
fieldName,
tenantsArrayFieldName,
tenantsArrayTenantFieldName,
userHasAccessToAllTenants,
}: Args<ConfigType>) =>
async (args: AccessArgs): Promise<AccessResult> => {
const constraints = []
const constraints: Where[] = []
const accessFn =
typeof accessFunction === 'function'
? accessFunction
@@ -34,12 +53,26 @@ export const withTenantAccess =
args.req.user as ConfigType extends { user: unknown } ? ConfigType['user'] : User,
)
) {
constraints.push(
getTenantAccess({
fieldName,
user: args.req.user as UserWithTenantsField,
}),
)
const tenantConstraint = getTenantAccess({
fieldName,
tenantsArrayFieldName,
tenantsArrayTenantFieldName,
user: args.req.user as UserWithTenantsField,
})
if (collection.slug === args.req.user.collection) {
constraints.push({
or: [
{
id: {
equals: args.req.user.id,
},
},
tenantConstraint,
],
})
} else {
constraints.push(tenantConstraint)
}
return combineWhereConstraints(constraints)
}

View File

@@ -81,10 +81,7 @@ const resave = async ({ collection, doc, draft, pluginConfig, req }: ResaveArgs)
await req.payload.update({
id: child.id,
collection: collection.slug,
data: {
...child,
[breadcrumbSlug]: await populateBreadcrumbs(req, pluginConfig, collection, child),
},
data: populateBreadcrumbs(req, pluginConfig, collection, child),
depth: 0,
draft: isDraft,
locale: req.locale,

View File

@@ -19,8 +19,9 @@ export const formatBreadcrumb = (
if (typeof pluginConfig?.generateLabel === 'function') {
label = pluginConfig.generateLabel(docs, lastDoc)
} else {
const useAsTitle = collection?.admin?.useAsTitle || 'id'
label = lastDoc[useAsTitle] as string
const title = lastDoc[collection.admin.useAsTitle]
label = typeof title === 'string' || typeof title === 'number' ? String(title) : ''
}
return {

View File

@@ -176,7 +176,7 @@ export type BeforeChangeNodeHookArgs<T extends SerializedLexicalNode> = {
* Only available in `beforeChange` hooks.
*/
errors: ValidationFieldError[]
mergeLocaleActions: (() => Promise<void>)[]
mergeLocaleActions: (() => Promise<void> | void)[]
/** A string relating to which operation the field type is currently executing within. Useful within beforeValidate, beforeChange, and afterChange hooks to differentiate between create and update operations. */
operation: 'create' | 'delete' | 'read' | 'update'
/** The value of the node before any changes. Not available in afterRead hooks */

View File

@@ -4,12 +4,9 @@ import { useModal } from '@faceless-ui/modal'
import React from 'react'
import { useTranslation } from '../../../providers/Translation/index.js'
import { Button } from '../../Button/index.js'
import { FullscreenModal } from '../../FullscreenModal/index.js'
import { ConfirmationModal } from '../../ConfirmationModal/index.js'
import { useBulkUpload } from '../index.js'
export const discardBulkUploadModalSlug = 'bulk-upload--discard-without-saving'
const baseClass = 'leave-without-saving'
export function DiscardWithoutSaving() {
const { t } = useTranslation()
@@ -26,21 +23,14 @@ export function DiscardWithoutSaving() {
}, [closeModal, drawerSlug])
return (
<FullscreenModal className={baseClass} slug={discardBulkUploadModalSlug}>
<div className={`${baseClass}__wrapper`}>
<div className={`${baseClass}__content`}>
<h1>{t('general:leaveWithoutSaving')}</h1>
<p>{t('general:changesNotSaved')}</p>
</div>
<div className={`${baseClass}__controls`}>
<Button buttonStyle="secondary" onClick={onCancel} size="large">
{t('general:stayOnThisPage')}
</Button>
<Button onClick={onConfirm} size="large">
{t('general:leaveAnyway')}
</Button>
</div>
</div>
</FullscreenModal>
<ConfirmationModal
body={t('general:changesNotSaved')}
cancelLabel={t('general:stayOnThisPage')}
confirmLabel={t('general:leaveAnyway')}
heading={t('general:leaveWithoutSaving')}
modalSlug={discardBulkUploadModalSlug}
onCancel={onCancel}
onConfirm={onConfirm}
/>
)
}

View File

@@ -23,10 +23,20 @@ function isModifiedEvent(event: React.MouseEvent): boolean {
)
}
export const Link: React.FC<Parameters<typeof NextLink>[0]> = ({
type Props = {
/**
* Disable the e.preventDefault() call on click if you want to handle it yourself via onClick
*
* @default true
*/
preventDefault?: boolean
} & Parameters<typeof NextLink>[0]
export const Link: React.FC<Props> = ({
children,
href,
onClick,
preventDefault = true,
ref,
replace,
scroll,
@@ -47,6 +57,12 @@ export const Link: React.FC<Parameters<typeof NextLink>[0]> = ({
onClick(e)
}
// We need a preventDefault here so that a clicked link doesn't trigger twice,
// once for default browser navigation and once for startRouteTransition
if (preventDefault) {
e.preventDefault()
}
startRouteTransition(() => {
const url = typeof href === 'string' ? href : formatUrl(href)

View File

@@ -73,7 +73,8 @@ export const ListControls: React.FC<ListControlsProps> = (props) => {
const searchLabel =
(titleField &&
getTranslation(
'label' in titleField && typeof titleField.label === 'string'
'label' in titleField &&
(typeof titleField.label === 'string' || typeof titleField.label === 'object')
? titleField.label
: 'name' in titleField
? titleField.name

View File

@@ -10,10 +10,10 @@ import { useField } from '../../forms/useField/index.js'
import { withCondition } from '../../forms/withCondition/index.js'
import { FieldDescription } from '../FieldDescription/index.js'
import { FieldError } from '../FieldError/index.js'
import './index.scss'
import { FieldLabel } from '../FieldLabel/index.js'
import { mergeFieldStyles } from '../mergeFieldStyles.js'
import { fieldBaseClass } from '../shared/index.js'
import './index.scss'
const baseClass = 'json-field'
@@ -31,10 +31,9 @@ const JSONFieldComponent: JSONFieldClientComponent = (props) => {
readOnly,
validate,
} = props
const [stringValue, setStringValue] = useState<string>()
const [jsonError, setJsonError] = useState<string>()
const [hasLoadedValue, setHasLoadedValue] = useState(false)
const inputChangeFromRef = React.useRef<'system' | 'user'>('system')
const [editorKey, setEditorKey] = useState<string>('')
const memoizedValidate = useCallback(
(value, options) => {
@@ -56,6 +55,12 @@ const JSONFieldComponent: JSONFieldClientComponent = (props) => {
validate: memoizedValidate,
})
const [initialStringValue, setInitialStringValue] = useState<string | undefined>(() =>
(value || initialValue) !== undefined
? JSON.stringify(value ?? initialValue, null, 2)
: undefined,
)
const handleMount = useCallback<OnMount>(
(editor, monaco) => {
if (!jsonSchema) {
@@ -88,7 +93,7 @@ const JSONFieldComponent: JSONFieldClientComponent = (props) => {
if (readOnly) {
return
}
setStringValue(val)
inputChangeFromRef.current = 'user'
try {
setValue(val ? JSON.parse(val) : null)
@@ -98,20 +103,21 @@ const JSONFieldComponent: JSONFieldClientComponent = (props) => {
setJsonError(e)
}
},
[readOnly, setValue, setStringValue],
[readOnly, setValue],
)
useEffect(() => {
if (hasLoadedValue || value === undefined) {
return
if (inputChangeFromRef.current === 'system') {
setInitialStringValue(
(value || initialValue) !== undefined
? JSON.stringify(value ?? initialValue, null, 2)
: undefined,
)
setEditorKey(new Date().toString())
}
setStringValue(
value || initialValue ? JSON.stringify(value ? value : initialValue, null, 2) : '',
)
setHasLoadedValue(true)
}, [initialValue, value, hasLoadedValue])
inputChangeFromRef.current = 'system'
}, [initialValue, value])
const styles = useMemo(() => mergeFieldStyles(field), [field])
@@ -142,12 +148,16 @@ const JSONFieldComponent: JSONFieldClientComponent = (props) => {
{BeforeInput}
<CodeEditor
defaultLanguage="json"
key={editorKey}
maxHeight={maxHeight}
onChange={handleChange}
onMount={handleMount}
options={editorOptions}
readOnly={readOnly}
value={stringValue}
value={initialStringValue}
wrapperProps={{
id: `field-${path?.replace(/\./g, '__')}`,
}}
/>
{AfterInput}
</div>

View File

@@ -24,7 +24,7 @@ export type RenderedFieldSlots = Map<string, RenderedField>
/**
* Get the state of the form, can be used to submit & validate the form.
*
* @see https://payloadcms.com/docs/admin/hooks#useform
* @see https://payloadcms.com/docs/admin/react-hooks#useform
*/
const useForm = (): Context => useContext(FormContext)
/**
@@ -42,7 +42,7 @@ const useFormInitializing = (): boolean => useContext(InitializingContext)
/**
* Get and set the value of a form field based on a selector
*
* @see https://payloadcms.com/docs/admin/hooks#useformfields
* @see https://payloadcms.com/docs/admin/react-hooks#useformfields
*/
const useFormFields = <Value = unknown>(
selector: (context: FormFieldsContextType) => Value,
@@ -51,7 +51,7 @@ const useFormFields = <Value = unknown>(
/**
* Get the state of all form fields.
*
* @see https://payloadcms.com/docs/admin/hooks#useallformfields
* @see https://payloadcms.com/docs/admin/react-hooks#useallformfields
*/
const useAllFormFields = (): FormFieldsContextType => useFullContext(FormFieldsContext)

View File

@@ -180,31 +180,52 @@ export function fieldReducer(state: FormState, action: FieldAction): FormState {
case 'MOVE_ROW': {
const { moveFromIndex, moveToIndex, path } = action
const { remainingFields, rows } = separateRows(path, state)
// copy the row to move
const copyOfMovingRow = rows[moveFromIndex]
// delete the row by index
rows.splice(moveFromIndex, 1)
// insert row copyOfMovingRow back in
rows.splice(moveToIndex, 0, copyOfMovingRow)
// Handle moving rows on the top-level, i.e. `array.0.text` -> `array.1.text`
const { remainingFields, rows: topLevelRows } = separateRows(path, state)
const copyOfMovingRow = topLevelRows[moveFromIndex]
topLevelRows.splice(moveFromIndex, 1)
topLevelRows.splice(moveToIndex, 0, copyOfMovingRow)
// modify array/block internal row state (i.e. collapsed, blockType)
const rowStateCopy = [...(state[path]?.rows || [])]
const movingRowState = { ...rowStateCopy[moveFromIndex] }
rowStateCopy.splice(moveFromIndex, 1)
rowStateCopy.splice(moveToIndex, 0, movingRowState)
const rowsWithinField = [...(state[path]?.rows || [])]
const copyOfMovingRow2 = { ...rowsWithinField[moveFromIndex] }
rowsWithinField.splice(moveFromIndex, 1)
rowsWithinField.splice(moveToIndex, 0, copyOfMovingRow2)
const newState = {
...remainingFields,
...flattenRows(path, rows),
...flattenRows(path, topLevelRows),
[path]: {
...state[path],
requiresRender: true,
rows: rowStateCopy,
rows: rowsWithinField,
},
}
// Do the same for custom components, i.e. `array.customComponents.RowLabels[0]` -> `array.customComponents.RowLabels[1]`
// Do this _after_ initializing `newState` to avoid adding the `customComponents` key to the state if it doesn't exist
if (newState[path]?.customComponents?.RowLabels) {
const customComponents = {
...newState[path].customComponents,
RowLabels: [...newState[path].customComponents.RowLabels],
}
// Ensure the array grows if necessary
if (moveToIndex >= customComponents.RowLabels.length) {
customComponents.RowLabels.length = moveToIndex + 1
}
const copyOfMovingLabel = customComponents.RowLabels[moveFromIndex]
// eslint-disable-next-line @typescript-eslint/no-floating-promises
customComponents.RowLabels.splice(moveFromIndex, 1)
// eslint-disable-next-line @typescript-eslint/no-floating-promises
customComponents.RowLabels.splice(moveToIndex, 0, copyOfMovingLabel)
newState[path].customComponents = customComponents
}
return newState
}

View File

@@ -27,7 +27,7 @@ import {
/**
* Get and set the value of a form field.
*
* @see https://payloadcms.com/docs/admin/hooks#usefield
* @see https://payloadcms.com/docs/admin/react-hooks#usefield
*/
export const useField = <TValue,>(options: Options): FieldType<TValue> => {
const { disableFormData = false, hasRows, path, validate } = options

View File

@@ -88,6 +88,28 @@ export const Relationship: CollectionConfig = {
relationTo: slug,
type: 'relationship',
},
{
type: 'collapsible',
label: 'Collapsible',
fields: [
{
name: 'nestedRelationshipFilteredByField',
filterOptions: () => {
return {
filter: {
equals: 'Include me',
},
}
},
admin: {
description:
'This will filter the relationship options if the filter field in this document is set to "Include me"',
},
relationTo: slug,
type: 'relationship',
},
],
},
{
name: 'relationshipFilteredAsync',
filterOptions: (args: FilterOptionsProps<FieldsRelationship>) => {

View File

@@ -351,6 +351,41 @@ describe('Relationship Field', () => {
await expect(valueOptions.locator(`text=${idToInclude}`)).toBeVisible()
})
test('should apply filter options of nested fields to list view filter controls', async () => {
const { id: idToInclude } = await payload.create({
collection: slug,
data: {
filter: 'Include me',
},
})
// first ensure that filter options are applied in the edit view
await page.goto(url.edit(idToInclude))
const field = page.locator('#field-nestedRelationshipFilteredByField')
await field.click({ delay: 100 })
const options = field.locator('.rs__option')
await expect(options).toHaveCount(1)
await expect(options).toContainText(idToInclude)
// now ensure that the same filter options are applied in the list view
await page.goto(url.list)
const whereBuilder = await addListFilter({
page,
fieldLabel: 'Collapsible > Nested Relationship Filtered By Field',
operatorLabel: 'equals',
skipValueInput: true,
})
const valueInput = page.locator('.condition__value input')
await valueInput.click()
const valueOptions = whereBuilder.locator('.condition__value .rs__option')
await expect(valueOptions).toHaveCount(2)
await expect(valueOptions.locator(`text=None`)).toBeVisible()
await expect(valueOptions.locator(`text=${idToInclude}`)).toBeVisible()
})
test('should allow usage of relationTo in filterOptions', async () => {
const { id: include } = (await payload.create({
collection: relationOneSlug,

View File

@@ -177,6 +177,10 @@ export interface FieldsRelationship {
* This will filter the relationship options if the filter field in this document is set to "Include me"
*/
relationshipFilteredByField?: (string | null) | FieldsRelationship;
/**
* This will filter the relationship options if the filter field in this document is set to "Include me"
*/
nestedRelationshipFilteredByField?: (string | null) | FieldsRelationship;
relationshipFilteredAsync?: (string | null) | RelationOne;
relationshipManyFiltered?:
| (
@@ -506,6 +510,7 @@ export interface FieldsRelationshipSelect<T extends boolean = true> {
relationshipWithTitle?: T;
relationshipFilteredByID?: T;
relationshipFilteredByField?: T;
nestedRelationshipFilteredByField?: T;
relationshipFilteredAsync?: T;
relationshipManyFiltered?: T;
filter?: T;

View File

@@ -3,6 +3,7 @@ import type { BrowserContext, Page } from '@playwright/test'
import { expect, test } from '@playwright/test'
import { addBlock } from 'helpers/e2e/addBlock.js'
import { openBlocksDrawer } from 'helpers/e2e/openBlocksDrawer.js'
import { reorderBlocks } from 'helpers/e2e/reorderBlocks.js'
import path from 'path'
import { fileURLToPath } from 'url'
@@ -289,6 +290,39 @@ describe('Block fields', () => {
})
describe('row manipulation', () => {
test('moving rows should immediately move custom row labels', async () => {
await page.goto(url.create)
// first ensure that the first block has the custom header, and that the second block doesn't
await expect(
page.locator('#field-blocks #blocks-row-0 .blocks-field__block-header'),
).toHaveText('Custom Block Label: Content 01')
const secondBlockHeader = page.locator(
'#field-blocks #blocks-row-1 .blocks-field__block-header',
)
await expect(secondBlockHeader.locator('.blocks-field__block-pill')).toHaveText('Number')
await expect(secondBlockHeader.locator('input[id="blocks.1.blockName"]')).toHaveValue(
'Second block',
)
await reorderBlocks({
page,
fieldName: 'blocks',
fromBlockIndex: 0,
toBlockIndex: 1,
})
// Important: do _not_ poll here, use `textContent()` instead of `toHaveText()`
// This will prevent Playwright from polling for the change to the DOM
expect(
await page.locator('#field-blocks #blocks-row-1 .blocks-field__block-header').textContent(),
).toMatch(/^Custom Block Label: Content/)
})
describe('react hooks', () => {
test('should add 2 new block rows', async () => {
await page.goto(url.create)

View File

@@ -0,0 +1,38 @@
'use client'
import { useField } from '@payloadcms/ui'
export function AfterField() {
const { setValue } = useField({ path: 'customJSON' })
return (
<button
id="set-custom-json"
onClick={(e) => {
e.preventDefault()
setValue({
users: [
{
id: 1,
name: 'John Doe',
email: 'john.doe@example.com',
isActive: true,
roles: ['admin', 'editor'],
},
{
id: 2,
name: 'Jane Smith',
email: 'jane.smith@example.com',
isActive: false,
roles: ['viewer'],
},
],
})
}}
style={{ marginTop: '5px', padding: '5px 10px' }}
type="button"
>
Set Custom JSON
</button>
)
}

View File

@@ -103,4 +103,24 @@ describe('JSON', () => {
'"foo.with.periods": "bar"',
)
})
test('should update', async () => {
const createdDoc = await payload.create({
collection: 'json-fields',
data: {
customJSON: {
default: 'value',
},
},
})
await page.goto(url.edit(createdDoc.id))
const jsonField = page.locator('.json-field #field-customJSON')
await expect(jsonField).toContainText('"default": "value"')
const originalHeight = (await page.locator('#field-customJSON').boundingBox())?.height || 0
await page.locator('#set-custom-json').click()
const newHeight = (await page.locator('#field-customJSON').boundingBox())?.height || 0
expect(newHeight).toBeGreaterThan(originalHeight)
})
})

View File

@@ -67,6 +67,16 @@ const JSON: CollectionConfig = {
},
],
},
{
name: 'customJSON',
type: 'json',
admin: {
components: {
afterInput: ['./collections/JSON/AfterField#AfterField'],
},
},
label: 'Custom Json',
},
],
versions: {
maxPerDoc: 1,

View File

@@ -1474,6 +1474,15 @@ export interface JsonField {
| boolean
| null;
};
customJSON?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
updatedAt: string;
createdAt: string;
}
@@ -3165,6 +3174,7 @@ export interface JsonFieldsSelect<T extends boolean = true> {
| {
jsonWithinGroup?: T;
};
customJSON?: T;
updatedAt?: T;
createdAt?: T;
}

View File

@@ -64,8 +64,16 @@ export default buildConfigWithDefaults({
NestedArray,
NestedFields,
{
admin: {
listSearchableFields: 'name',
},
auth: true,
fields: [
{
name: 'name',
label: { en: 'Full name' },
type: 'text',
},
{
name: 'relation',
relationTo: localizedPostsSlug,
@@ -83,6 +91,7 @@ export default buildConfigWithDefaults({
fields: [
{
name: 'title',
label: { en: 'Full title' },
index: true,
localized: true,
type: 'text',

View File

@@ -1,7 +1,11 @@
import type { BrowserContext, Page } from '@playwright/test'
import type { GeneratedTypes } from 'helpers/sdk/types.js'
import { expect, test } from '@playwright/test'
import { navigateToDoc } from 'helpers/e2e/navigateToDoc.js'
import { openDocControls } from 'helpers/e2e/openDocControls.js'
import { upsertPrefs } from 'helpers/e2e/upsertPrefs.js'
import { RESTClient } from 'helpers/rest.js'
import path from 'path'
import { fileURLToPath } from 'url'
@@ -31,11 +35,6 @@ import {
spanishLocale,
withRequiredLocalizedFields,
} from './shared.js'
import { navigateToDoc } from 'helpers/e2e/navigateToDoc.js'
import { upsertPrefs } from 'helpers/e2e/upsertPrefs.js'
import { RESTClient } from 'helpers/rest.js'
import { GeneratedTypes } from 'helpers/sdk/types.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
@@ -119,16 +118,16 @@ describe('Localization', () => {
await expect(page.locator('.localizer .popup')).toHaveClass(/popup--active/)
const activeOption = await page.locator(
const activeOption = page.locator(
`.localizer .popup.popup--active .popup-button-list__button--selected`,
)
await expect(activeOption).toBeVisible()
const tagName = await activeOption.evaluate((node) => node.tagName)
await expect(tagName).not.toBe('A')
expect(tagName).not.toBe('A')
await expect(activeOption).not.toHaveAttribute('href')
await expect(tagName).not.toBe('BUTTON')
await expect(tagName).toBe('DIV')
expect(tagName).not.toBe('BUTTON')
expect(tagName).toBe('DIV')
})
})
@@ -140,7 +139,7 @@ describe('Localization', () => {
const createNewButtonLocator =
'.collection-list a[href="/admin/collections/cannot-create-default-locale/create"]'
await expect(page.locator(createNewButtonLocator)).not.toBeVisible()
await expect(page.locator(createNewButtonLocator)).toBeHidden()
await changeLocale(page, spanishLocale)
await expect(page.locator(createNewButtonLocator).first()).toBeVisible()
await page.goto(urlCannotCreateDefaultLocale.create)
@@ -330,11 +329,11 @@ describe('Localization', () => {
await page.goto(url.list)
const localeLabel = await page
const localeLabel = page
.locator('.localizer.app-header__localizer .localizer-button__current-label')
.innerText()
expect(localeLabel).not.toEqual('English')
await expect(localeLabel).not.toHaveText('English')
})
})
@@ -351,7 +350,7 @@ describe('Localization', () => {
await navigateToDoc(page, urlRelationshipLocalized)
const drawerToggler =
'#field-relationMultiRelationTo .relationship--single-value__drawer-toggler'
expect(page.locator(drawerToggler)).toBeEnabled()
await expect(page.locator(drawerToggler)).toBeEnabled()
await openDocDrawer(page, drawerToggler)
await expect(page.locator('.doc-drawer__header-text')).toContainText('spanish-relation2')
await page.locator('.doc-drawer__header-close').click()
@@ -518,7 +517,7 @@ describe('Localization', () => {
// only throttle test after initial load to avoid timeouts
const cdpSession = await throttleTest({
page: page,
page,
context,
delay: 'Fast 4G',
})
@@ -541,6 +540,13 @@ describe('Localization', () => {
await cdpSession.detach()
})
})
test('should use label in search filter when string or object', async () => {
await page.goto(url.list)
const searchInput = page.locator('.search-filter__input')
await expect(searchInput).toBeVisible()
await expect(searchInput).toHaveAttribute('placeholder', 'Search by Full title')
})
})
async function fillValues(data: Partial<LocalizedPost>) {

View File

@@ -64,7 +64,6 @@ export interface Config {
auth: {
users: UserAuthOperations;
};
blocks: {};
collections: {
richText: RichText;
'blocks-fields': BlocksField;
@@ -322,6 +321,7 @@ export interface NestedFieldTable {
*/
export interface User {
id: string;
name?: string | null;
relation?: (string | null) | LocalizedPost;
updatedAt: string;
createdAt: string;
@@ -928,6 +928,7 @@ export interface NestedFieldTablesSelect<T extends boolean = true> {
* via the `definition` "users_select".
*/
export interface UsersSelect<T extends boolean = true> {
name?: T;
relation?: T;
updatedAt?: T;
createdAt?: T;

View File

@@ -7,6 +7,7 @@ import type { NextRESTClient } from '../helpers/NextRESTClient.js'
import { devUser } from '../credentials.js'
import { initPayloadInt } from '../helpers/initPayloadInt.js'
import { tenantsSlug } from './shared.js'
let payload: Payload
let restClient: NextRESTClient
@@ -40,7 +41,7 @@ describe('@payloadcms/plugin-multi-tenant', () => {
describe('tenants', () => {
it('should create a tenant', async () => {
const tenant1 = await payload.create({
collection: 'tenants',
collection: tenantsSlug,
data: {
name: 'tenant1',
domain: 'tenant1.com',

View File

@@ -1,19 +1,19 @@
import type { Config } from 'payload'
import { devUser } from '../../credentials.js'
import { menuItemsSlug, menuSlug, usersSlug } from '../shared.js'
import { menuItemsSlug, menuSlug, tenantsSlug, usersSlug } from '../shared.js'
export const seed: Config['onInit'] = async (payload) => {
// create tenants
const blueDogTenant = await payload.create({
collection: 'tenants',
collection: tenantsSlug,
data: {
name: 'Blue Dog',
domain: 'bluedog.com',
},
})
const steelCatTenant = await payload.create({
collection: 'tenants',
collection: tenantsSlug,
data: {
name: 'Steel Cat',
domain: 'steelcat.com',

View File

@@ -76,7 +76,6 @@ describe('@payloadcms/plugin-nested-docs', () => {
},
})
}
// update parent doc
await payload.update({
collection: 'pages',
@@ -110,6 +109,91 @@ describe('@payloadcms/plugin-nested-docs', () => {
// @ts-ignore
expect(lastUpdatedChildBreadcrumbs[0].url).toStrictEqual('/11-children-updated')
})
it('should return breadcrumbs as an array of objects', async () => {
const parentDoc = await payload.create({
collection: 'pages',
data: {
title: 'parent doc',
slug: 'parent-doc',
_status: 'published',
},
})
const childDoc = await payload.create({
collection: 'pages',
data: {
title: 'child doc',
slug: 'child-doc',
parent: parentDoc.id,
_status: 'published',
},
})
// expect breadcrumbs to be an array
expect(childDoc.breadcrumbs).toBeInstanceOf(Array)
expect(childDoc.breadcrumbs).toBeDefined()
// expect each to be objects
childDoc.breadcrumbs?.map((breadcrumb) => {
expect(breadcrumb).toBeInstanceOf(Object)
})
})
it('should update child doc breadcrumb without affecting any other data', async () => {
const parentDoc = await payload.create({
collection: 'pages',
data: {
title: 'parent doc',
slug: 'parent',
},
})
const childDoc = await payload.create({
collection: 'pages',
data: {
title: 'child doc',
slug: 'child',
parent: parentDoc.id,
_status: 'published',
},
})
await payload.update({
collection: 'pages',
id: parentDoc.id,
data: {
title: 'parent updated',
slug: 'parent-updated',
_status: 'published',
},
})
const updatedChild = await payload
.find({
collection: 'pages',
where: {
id: {
equals: childDoc.id,
},
},
})
.then(({ docs }) => docs[0])
if (!updatedChild) {
return
}
// breadcrumbs should be updated
expect(updatedChild.breadcrumbs).toHaveLength(2)
expect(updatedChild.breadcrumbs?.[0]?.url).toStrictEqual('/parent-updated')
expect(updatedChild.breadcrumbs?.[1]?.url).toStrictEqual('/parent-updated/child')
// no other data should be affected
expect(updatedChild.title).toEqual('child doc')
expect(updatedChild.slug).toEqual('child')
})
})
describe('overrides', () => {

View File

@@ -438,6 +438,47 @@ describe('Versions', () => {
await expect(drawer.locator('.id-label')).toBeVisible()
})
test('collection - autosave - should not create duplicates when clicking Create new', async () => {
// This test checks that when we click "Create new" in the list view, it only creates 1 extra document and not more
const { totalDocs: initialDocsCount } = await payload.find({
collection: autosaveCollectionSlug,
draft: true,
})
await page.goto(autosaveURL.create)
await page.locator('#field-title').fill('autosave title')
await waitForAutoSaveToRunAndComplete(page)
await expect(page.locator('#field-title')).toHaveValue('autosave title')
const { totalDocs: updatedDocsCount } = await payload.find({
collection: autosaveCollectionSlug,
draft: true,
})
await expect(() => {
expect(updatedDocsCount).toBe(initialDocsCount + 1)
}).toPass({ timeout: POLL_TOPASS_TIMEOUT, intervals: [100] })
await page.goto(autosaveURL.list)
const createNewButton = page.locator('.list-header .btn:has-text("Create New")')
await createNewButton.click()
await page.waitForURL(`**/${autosaveCollectionSlug}/**`)
await page.locator('#field-title').fill('autosave title')
await waitForAutoSaveToRunAndComplete(page)
await expect(page.locator('#field-title')).toHaveValue('autosave title')
const { totalDocs: latestDocsCount } = await payload.find({
collection: autosaveCollectionSlug,
draft: true,
})
await expect(() => {
expect(latestDocsCount).toBe(updatedDocsCount + 1)
}).toPass({ timeout: POLL_TOPASS_TIMEOUT, intervals: [100] })
})
test('collection - should update updatedAt', async () => {
await page.goto(url.create)
await page.waitForURL(`**/${url.create}`)
@@ -757,7 +798,7 @@ describe('Versions', () => {
// schedule publish should not be available before document has been saved
await page.locator('#action-save-popup').click()
await expect(page.locator('#schedule-publish')).not.toBeVisible()
await expect(page.locator('#schedule-publish')).toBeHidden()
// save draft then try to schedule publish
await saveDocAndAssert(page)

View File

@@ -31,7 +31,7 @@
}
],
"paths": {
"@payload-config": ["./test/admin/config.ts"],
"@payload-config": ["./test/fields-relationship/config.ts"],
"@payloadcms/live-preview": ["./packages/live-preview/src"],
"@payloadcms/live-preview-react": ["./packages/live-preview-react/src/index.ts"],
"@payloadcms/live-preview-vue": ["./packages/live-preview-vue/src/index.ts"],