Files
payload/test/fields/int.spec.ts
Alessio Gravili 90b7b20699 feat!: beta-next (#7620)
This PR makes three major changes to the codebase:

1. [Component Paths](#component-paths)
Instead of importing custom components into your config directly, they
are now defined as file paths and rendered only when needed. That way
the Payload config will be significantly more lightweight, and ensures
that the Payload config is 100% server-only and Node-safe. Related
discussion: https://github.com/payloadcms/payload/discussions/6938

2. [Client Config](#client-config)
Deprecates the component map by merging its logic into the client
config. The main goal of this change is for performance and
simplification. There was no need to deeply iterate over the Payload
config twice, once for the component map, and another for the client
config. Instead, we can do everything in the client config one time.
This has also dramatically simplified the client side prop drilling
through the UI library. Now, all components can share the same client
config which matches the exact shape of their Payload config (with the
exception of non-serializable props and mapped custom components).

3. [Custom client component are no longer
server-rendered](#custom-client-components-are-no-longer-server-rendered)
Previously, custom components would be server-rendered, no matter if
they are server or client components. Now, only server components are
rendered on the server. Client components are automatically detected,
and simply get passed through as `MappedComponent` to be rendered fully
client-side.

## Component Paths

Instead of importing custom components into your config directly, they
are now defined as file paths and rendered only when needed. That way
the Payload config will be significantly more lightweight, and ensures
that the Payload config is 100% server-only and Node-safe. Related
discussion: https://github.com/payloadcms/payload/discussions/6938

In order to reference any custom components in the Payload config, you
now have to specify a string path to the component instead of importing
it.

Old:

```ts
import { MyComponent2} from './MyComponent2.js'

admin: {
  components: {
    Label: MyComponent2
  },
},
```

New:

```ts
admin: {
  components: {
    Label: '/collections/Posts/MyComponent2.js#MyComponent2', // <= has to be a relative path based on a baseDir configured in the Payload config - NOT relative based on the importing file
  },
},
```

### Local API within Next.js routes

Previously, if you used the Payload Local API within Next.js pages, all
the client-side modules are being added to the bundle for that specific
page, even if you only need server-side functionality.

This `/test` route, which uses the Payload local API, was previously 460
kb. It is now down to 91 kb and does not bundle the Payload client-side
admin panel anymore.

All tests done
[here](https://github.com/payloadcms/payload-3.0-demo/tree/feat/path-test)
with beta.67/PR, db-mongodb and default richtext-lexical:

**dev /admin before:**
![CleanShot 2024-07-29 at 22 49
12@2x](https://github.com/user-attachments/assets/4428e766-b368-4bcf-8c18-d0187ab64f3e)

**dev /admin after:**
![CleanShot 2024-07-29 at 22 50
49@2x](https://github.com/user-attachments/assets/f494c848-7247-4b02-a650-a3fab4000de6)

---

**dev /test before:**
![CleanShot 2024-07-29 at 22 56
18@2x](https://github.com/user-attachments/assets/1a7e9500-b859-4761-bf63-abbcdac6f8d6)

**dev /test after:**
![CleanShot 2024-07-29 at 22 47
45@2x](https://github.com/user-attachments/assets/f89aa76d-f2d5-4572-9753-2267f034a45a)

---

**build before:**
![CleanShot 2024-07-29 at 22 57
14@2x](https://github.com/user-attachments/assets/5f8f7281-2a4a-40a5-a788-c30ddcdd51b5)

**build after::**
![CleanShot 2024-07-29 at 22 56
39@2x](https://github.com/user-attachments/assets/ea8772fd-512f-4db0-9a81-4b014715a1b7)

### Usage of the Payload Local API / config outside of Next.js

This will make it a lot easier to use the Payload config / local API in
other, server-side contexts. Previously, you might encounter errors due
to client files (like .scss files) not being allowed to be imported.

## Client Config

Deprecates the component map by merging its logic into the client
config. The main goal of this change is for performance and
simplification. There was no need to deeply iterate over the Payload
config twice, once for the component map, and another for the client
config. Instead, we can do everything in the client config one time.
This has also dramatically simplified the client side prop drilling
through the UI library. Now, all components can share the same client
config which matches the exact shape of their Payload config (with the
exception of non-serializable props and mapped custom components).

This is breaking change. The `useComponentMap` hook no longer exists,
and most component props have changed (for the better):

```ts
const { componentMap } = useComponentMap() // old
const { config } = useConfig() // new
```

The `useConfig` hook has also changed in shape, `config` is now a
property _within_ the context obj:

```ts
const config = useConfig() // old
const { config } = useConfig() // new
```

## Custom Client Components are no longer server rendered

Previously, custom components would be server-rendered, no matter if
they are server or client components. Now, only server components are
rendered on the server. Client components are automatically detected,
and simply get passed through as `MappedComponent` to be rendered fully
client-side.

The benefit of this change:

Custom client components can now receive props. Previously, the only way
for them to receive dynamic props from a parent client component was to
use hooks, e.g. `useFieldProps()`. Now, we do have the option of passing
in props to the custom components directly, if they are client
components. This will be simpler than having to look for the correct
hook.

This makes rendering them on the client a little bit more complex, as
you now have to check if that component is a server component (=>
already has been rendered) or a client component (=> not rendered yet,
has to be rendered here). However, this added complexity has been
alleviated through the easy-to-use `<RenderMappedComponent />` helper.

This helper now also handles rendering arrays of custom components (e.g.
beforeList, beforeLogin ...), which actually makes rendering custom
components easier in some cases.

## Misc improvements

This PR includes misc, breaking changes. For example, we previously
allowed unions between components and config object for the same
property. E.g. for the custom view property, you were allowed to pass in
a custom component or an object with other properties, alongside a
custom component.

Those union types are now gone. You can now either pass an object, or a
component. The previous `{ View: MyViewComponent}` is now `{ View: {
Component: MyViewComponent} }` or `{ View: { Default: { Component:
MyViewComponent} } }`.

This dramatically simplifies the way we read & process those properties,
especially in buildComponentMap. We can now simply check for the
existence of one specific property, which always has to be a component,
instead of running cursed runtime checks on a shared union property
which could contain a component, but could also contain functions or
objects.

![CleanShot 2024-07-29 at 23 07
07@2x](https://github.com/user-attachments/assets/1e75aa4c-7a4c-419f-9070-216bb7b9a5e5)

![CleanShot 2024-07-29 at 23 09
40@2x](https://github.com/user-attachments/assets/b4c96450-6b7e-496c-a4f7-59126bfd0991)

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

---------

Co-authored-by: PatrikKozak <patrik@payloadcms.com>
Co-authored-by: Paul <paul@payloadcms.com>
Co-authored-by: Paul Popus <paul@nouance.io>
Co-authored-by: Jacob Fletcher <jacobsfletch@gmail.com>
Co-authored-by: James <james@trbl.design>
2024-08-13 12:54:33 -04:00

1703 lines
45 KiB
TypeScript

import type { MongooseAdapter } from '@payloadcms/db-mongodb'
import type { IndexDirection, IndexOptions } from 'mongoose'
import type { PaginatedDocs, Payload } from 'payload'
import { reload } from '@payloadcms/next/utilities'
import path from 'path'
import { fileURLToPath } from 'url'
import type { NextRESTClient } from '../helpers/NextRESTClient.js'
import type { GroupField, RichTextField } from './payload-types.js'
import { devUser } from '../credentials.js'
import { initPayloadInt } from '../helpers/initPayloadInt.js'
import { isMongoose } from '../helpers/isMongoose.js'
import { arrayDefaultValue } from './collections/Array/index.js'
import { blocksDoc } from './collections/Blocks/shared.js'
import { dateDoc } from './collections/Date/shared.js'
import { groupDefaultChild, groupDefaultValue } from './collections/Group/index.js'
import { groupDoc } from './collections/Group/shared.js'
import { defaultNumber } from './collections/Number/index.js'
import { numberDoc } from './collections/Number/shared.js'
import { pointDoc } from './collections/Point/shared.js'
import {
localizedTextValue,
namedTabDefaultValue,
namedTabText,
} from './collections/Tabs/constants.js'
import { tabsDoc } from './collections/Tabs/shared.js'
import { defaultText } from './collections/Text/shared.js'
import { clearAndSeedEverything } from './seed.js'
import {
arrayFieldsSlug,
blockFieldsSlug,
groupFieldsSlug,
relationshipFieldsSlug,
tabsFieldsSlug,
textFieldsSlug,
} from './slugs.js'
let restClient: NextRESTClient
let user: any
let payload: Payload
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
describe('Fields', () => {
beforeAll(async () => {
process.env.SEED_IN_CONFIG_ONINIT = 'false' // Makes it so the payload config onInit seed is not run. Otherwise, the seed would be run unnecessarily twice for the initial test run - once for beforeEach and once for onInit
;({ payload, restClient } = await initPayloadInt(dirname))
})
afterAll(async () => {
if (typeof payload.db.destroy === 'function') {
await payload.db.destroy()
}
})
beforeEach(async () => {
await clearAndSeedEverything(payload)
await restClient.login({
slug: 'users',
credentials: devUser,
})
user = await payload.login({
collection: 'users',
data: {
email: devUser.email,
password: devUser.password,
},
})
})
describe('text', () => {
let doc
const text = 'text field'
beforeEach(async () => {
doc = await payload.create({
collection: 'text-fields',
data: { text },
})
})
it('creates with default values', () => {
expect(doc.text).toStrictEqual(text)
expect(doc.defaultString).toStrictEqual(defaultText)
expect(doc.defaultEmptyString).toStrictEqual('')
expect(doc.defaultFunction).toStrictEqual(defaultText)
expect(doc.defaultAsync).toStrictEqual(defaultText)
})
it('should populate default values in beforeValidate hook', async () => {
const { dependentOnFieldWithDefaultValue, fieldWithDefaultValue } = await payload.create({
collection: 'text-fields',
data: { text },
})
expect(fieldWithDefaultValue).toEqual(dependentOnFieldWithDefaultValue)
})
it('should localize an array of strings using hasMany', async () => {
const localizedHasMany = ['hello', 'world']
const { id } = await payload.create({
collection: 'text-fields',
data: {
localizedHasMany,
text,
},
locale: 'en',
})
const localizedDoc = await payload.findByID({
id,
collection: 'text-fields',
locale: 'all',
})
// @ts-expect-error
expect(localizedDoc.localizedHasMany.en).toEqual(localizedHasMany)
})
it('should query hasMany in', async () => {
const hit = await payload.create({
collection: 'text-fields',
data: {
hasMany: ['one', 'five'],
text: 'required',
},
})
const miss = await payload.create({
collection: 'text-fields',
data: {
hasMany: ['two'],
text: 'required',
},
})
const { docs } = await payload.find({
collection: 'text-fields',
where: {
hasMany: {
in: ['one'],
},
},
})
const hitResult = docs.find(({ id: findID }) => hit.id === findID)
const missResult = docs.find(({ id: findID }) => miss.id === findID)
expect(hitResult).toBeDefined()
expect(missResult).toBeFalsy()
})
})
describe('relationship', () => {
let textDoc
let otherTextDoc
let selfReferencing
let parent
let child
let grandChild
let relationshipInArray
const textDocText = 'text document'
const otherTextDocText = 'alt text'
const relationshipText = 'relationship text'
beforeEach(async () => {
textDoc = await payload.create({
collection: 'text-fields',
data: {
text: textDocText,
},
})
otherTextDoc = await payload.create({
collection: 'text-fields',
data: {
text: otherTextDocText,
},
})
const relationship = { relationTo: 'text-fields', value: textDoc.id }
parent = await payload.create({
collection: relationshipFieldsSlug,
data: {
relationship,
text: relationshipText,
},
})
child = await payload.create({
collection: relationshipFieldsSlug,
data: {
relationToSelf: parent.id,
relationship,
text: relationshipText,
},
})
grandChild = await payload.create({
collection: relationshipFieldsSlug,
data: {
relationToSelf: child.id,
relationship,
text: relationshipText,
},
})
selfReferencing = await payload.create({
collection: relationshipFieldsSlug,
data: {
relationship,
text: relationshipText,
},
})
relationshipInArray = await payload.create({
collection: relationshipFieldsSlug,
data: {
array: [
{
relationship: otherTextDoc.id,
},
],
relationship,
},
})
})
it('should query parent self-reference', async () => {
const childResult = await payload.find({
collection: relationshipFieldsSlug,
where: {
relationToSelf: { equals: parent.id },
},
})
const grandChildResult = await payload.find({
collection: relationshipFieldsSlug,
where: {
relationToSelf: { equals: child.id },
},
})
const anyChildren = await payload.find({
collection: relationshipFieldsSlug,
})
const allChildren = await payload.find({
collection: relationshipFieldsSlug,
where: {
'relationToSelf.text': { equals: relationshipText },
},
})
expect(childResult.docs[0].id).toStrictEqual(child.id)
expect(grandChildResult.docs[0].id).toStrictEqual(grandChild.id)
expect(allChildren.docs).toHaveLength(2)
})
it('should query relationship inside array', async () => {
const result = await payload.find({
collection: relationshipFieldsSlug,
where: {
'array.relationship.text': { equals: otherTextDocText },
},
})
expect(result.docs).toHaveLength(1)
expect(result.docs[0]).toMatchObject(relationshipInArray)
})
})
describe('timestamps', () => {
const tenMinutesAgo = new Date(Date.now() - 1000 * 60 * 10)
let doc
beforeEach(async () => {
doc = await payload.create({
collection: 'date-fields',
data: dateDoc,
})
})
it('should query updatedAt', async () => {
const { docs } = await payload.find({
collection: 'date-fields',
depth: 0,
where: {
updatedAt: {
greater_than_equal: tenMinutesAgo,
},
},
})
expect(docs.map(({ id }) => id)).toContain(doc.id)
})
it('should query createdAt', async () => {
const result = await payload.find({
collection: 'date-fields',
depth: 0,
where: {
createdAt: {
greater_than_equal: tenMinutesAgo,
},
},
})
expect(result.docs[0].id).toEqual(doc.id)
})
})
describe('select', () => {
let doc
beforeEach(async () => {
const { id } = await payload.create({
collection: 'select-fields',
data: {
selectHasManyLocalized: ['one', 'two'],
},
locale: 'en',
})
doc = await payload.findByID({
id,
collection: 'select-fields',
locale: 'all',
})
})
it('creates with hasMany localized', () => {
expect(doc.selectHasManyLocalized.en).toEqual(['one', 'two'])
})
it('retains hasMany updates', async () => {
const { id } = await payload.create({
collection: 'select-fields',
data: {
selectHasMany: ['one', 'two'],
},
})
const updatedDoc = await payload.update({
id,
collection: 'select-fields',
data: {
select: 'one',
},
})
expect(Array.isArray(updatedDoc.selectHasMany)).toBe(true)
expect(updatedDoc.selectHasMany).toEqual(['one', 'two'])
})
it('should query hasMany in', async () => {
const hit = await payload.create({
collection: 'select-fields',
data: {
selectHasMany: ['one', 'four'],
},
})
const miss = await payload.create({
collection: 'select-fields',
data: {
selectHasMany: ['three'],
},
})
const { docs } = await payload.find({
collection: 'select-fields',
where: {
selectHasMany: {
in: ['one'],
},
},
})
const hitResult = docs.find(({ id: findID }) => hit.id === findID)
const missResult = docs.find(({ id: findID }) => miss.id === findID)
expect(hitResult).toBeDefined()
expect(missResult).toBeFalsy()
})
})
describe('number', () => {
let doc
beforeEach(async () => {
doc = await payload.create({
collection: 'number-fields',
data: numberDoc,
})
})
it('creates with default values', () => {
expect(doc.number).toEqual(numberDoc.number)
expect(doc.min).toEqual(numberDoc.min)
expect(doc.max).toEqual(numberDoc.max)
expect(doc.positiveNumber).toEqual(numberDoc.positiveNumber)
expect(doc.negativeNumber).toEqual(numberDoc.negativeNumber)
expect(doc.decimalMin).toEqual(numberDoc.decimalMin)
expect(doc.decimalMax).toEqual(numberDoc.decimalMax)
expect(doc.defaultNumber).toEqual(defaultNumber)
})
it('should not create number below minimum', async () => {
await expect(async () =>
payload.create({
collection: 'number-fields',
data: {
min: 5,
},
}),
).rejects.toThrow('The following field is invalid: min')
})
it('should not create number above max', async () => {
await expect(async () =>
payload.create({
collection: 'number-fields',
data: {
max: 15,
},
}),
).rejects.toThrow('The following field is invalid: max')
})
it('should not create number below 0', async () => {
await expect(async () =>
payload.create({
collection: 'number-fields',
data: {
positiveNumber: -5,
},
}),
).rejects.toThrow('The following field is invalid: positiveNumber')
})
it('should not create number above 0', async () => {
await expect(async () =>
payload.create({
collection: 'number-fields',
data: {
negativeNumber: 5,
},
}),
).rejects.toThrow('The following field is invalid: negativeNumber')
})
it('should not create a decimal number below min', async () => {
await expect(async () =>
payload.create({
collection: 'number-fields',
data: {
decimalMin: -0.25,
},
}),
).rejects.toThrow('The following field is invalid: decimalMin')
})
it('should not create a decimal number above max', async () => {
await expect(async () =>
payload.create({
collection: 'number-fields',
data: {
decimalMax: 1.5,
},
}),
).rejects.toThrow('The following field is invalid: decimalMax')
})
it('should localize an array of numbers using hasMany', async () => {
const localizedHasMany = [5, 10]
const { id } = await payload.create({
collection: 'number-fields',
data: {
localizedHasMany,
},
locale: 'en',
})
const localizedDoc = await payload.findByID({
id,
collection: 'number-fields',
locale: 'all',
})
// @ts-expect-error
expect(localizedDoc.localizedHasMany.en).toEqual(localizedHasMany)
})
it('should query hasMany in', async () => {
const hit = await payload.create({
collection: 'number-fields',
data: {
hasMany: [5, 10],
},
})
const miss = await payload.create({
collection: 'number-fields',
data: {
hasMany: [13],
},
})
const { docs } = await payload.find({
collection: 'number-fields',
where: {
hasMany: {
in: [5],
},
},
})
const hitResult = docs.find(({ id: findID }) => hit.id === findID)
const missResult = docs.find(({ id: findID }) => miss.id === findID)
expect(hitResult).toBeDefined()
expect(missResult).toBeFalsy()
})
})
if (isMongoose(payload)) {
describe('indexes', () => {
let indexes
const definitions: Record<string, IndexDirection> = {}
const options: Record<string, IndexOptions> = {}
beforeAll(() => {
indexes = (payload.db as MongooseAdapter).collections[
'indexed-fields'
].schema.indexes() as [Record<string, IndexDirection>, IndexOptions]
indexes.forEach((index) => {
const field = Object.keys(index[0])[0]
definitions[field] = index[0][field]
options[field] = index[1]
})
})
it('should have indexes', () => {
expect(definitions.text).toEqual(1)
})
it('should have unique sparse indexes when field is not required', () => {
expect(definitions.uniqueText).toEqual(1)
expect(options.uniqueText).toMatchObject({ sparse: true, unique: true })
})
it('should have unique indexes that are not sparse when field is required', () => {
expect(definitions.uniqueRequiredText).toEqual(1)
expect(options.uniqueText).toMatchObject({ unique: true })
})
it('should have 2dsphere indexes on point fields', () => {
expect(definitions.point).toEqual('2dsphere')
})
it('should have 2dsphere indexes on point fields in groups', () => {
expect(definitions['group.point']).toEqual('2dsphere')
})
it('should have a sparse index on a unique localized field in a group', () => {
expect(definitions['group.localizedUnique.en']).toEqual(1)
expect(options['group.localizedUnique.en']).toMatchObject({ sparse: true, unique: true })
expect(definitions['group.localizedUnique.es']).toEqual(1)
expect(options['group.localizedUnique.es']).toMatchObject({ sparse: true, unique: true })
})
it('should have unique indexes in a collapsible', () => {
expect(definitions['collapsibleLocalizedUnique.en']).toEqual(1)
expect(options['collapsibleLocalizedUnique.en']).toMatchObject({
sparse: true,
unique: true,
})
expect(definitions.collapsibleTextUnique).toEqual(1)
expect(options.collapsibleTextUnique).toMatchObject({ unique: true })
})
})
describe('version indexes', () => {
let indexes
const definitions: Record<string, IndexDirection> = {}
const options: Record<string, IndexOptions> = {}
beforeEach(() => {
indexes = (payload.db as MongooseAdapter).versions['indexed-fields'].schema.indexes() as [
Record<string, IndexDirection>,
IndexOptions,
]
indexes.forEach((index) => {
const field = Object.keys(index[0])[0]
definitions[field] = index[0][field]
options[field] = index[1]
})
})
it('should have versions indexes', () => {
expect(definitions['version.text']).toEqual(1)
})
})
describe('point', () => {
let doc
const point = [7, -7]
const localized = [5, -2]
const group = { point: [1, 9] }
beforeEach(async () => {
const findDoc = await payload.find({
collection: 'point-fields',
pagination: false,
})
;[doc] = findDoc.docs
})
it('should read', async () => {
const find = await payload.find({
collection: 'point-fields',
pagination: false,
})
;[doc] = find.docs
expect(doc.point).toEqual(pointDoc.point)
expect(doc.localized).toEqual(pointDoc.localized)
expect(doc.group).toMatchObject(pointDoc.group)
})
it('should create', async () => {
doc = await payload.create({
collection: 'point-fields',
data: {
group,
localized,
point,
},
})
expect(doc.point).toEqual(point)
expect(doc.localized).toEqual(localized)
expect(doc.group).toMatchObject(group)
})
it('should not create duplicate point when unique', async () => {
// first create the point field
doc = await payload.create({
collection: 'point-fields',
data: {
group,
localized,
point,
},
})
// Now make sure we can't create a duplicate (since 'localized' is a unique field)
await expect(() =>
payload.create({
collection: 'point-fields',
data: {
group,
localized,
point,
},
}),
).rejects.toThrow(Error)
await expect(async () =>
payload.create({
collection: 'number-fields',
data: {
min: 5,
},
}),
).rejects.toThrow('The following field is invalid: min')
expect(doc.point).toEqual(point)
expect(doc.localized).toEqual(localized)
expect(doc.group).toMatchObject(group)
})
})
}
describe('unique indexes', () => {
it('should throw validation error saving on unique fields', async () => {
const data = {
text: 'a',
uniqueText: 'a',
}
await payload.create({
collection: 'indexed-fields',
data,
})
expect(async () => {
const result = await payload.create({
collection: 'indexed-fields',
data,
})
return result.error
}).toBeDefined()
})
it('should not throw validation error saving multiple null values for unique fields', async () => {
const data = {
localizedUniqueRequiredText: 'en1',
text: 'a',
uniqueRequiredText: 'a',
// uniqueText omitted on purpose
}
const doc = await payload.create({
collection: 'indexed-fields',
data,
})
// Update spanish so we do not run into the unique constraint for other locales
await payload.update({
id: doc.id,
collection: 'indexed-fields',
data: {
localizedUniqueRequiredText: 'es1',
},
locale: 'es',
})
data.uniqueRequiredText = 'b'
const result = await payload.create({
collection: 'indexed-fields',
data: { ...data, localizedUniqueRequiredText: 'en2' },
})
expect(result.id).toBeDefined()
})
it('should duplicate with unique fields', async () => {
const data = {
text: 'a',
// uniqueRequiredText: 'duplicate',
}
const doc = await payload.create({
collection: 'indexed-fields',
data,
})
const result = await payload.duplicate({
id: doc.id,
collection: 'indexed-fields',
})
expect(result.id).not.toEqual(doc.id)
expect(result.uniqueRequiredText).toStrictEqual('uniqueRequired - Copy')
})
})
describe('array', () => {
let doc
const collection = arrayFieldsSlug
beforeEach(async () => {
doc = await payload.create({
collection,
data: {},
})
})
it('should create with ids and nested ids', async () => {
const docWithIDs = (await payload.create({
collection: groupFieldsSlug,
data: groupDoc,
})) as Partial<GroupField>
expect(docWithIDs.group.subGroup.arrayWithinGroup[0].id).toBeDefined()
})
it('should create with defaultValue', () => {
expect(doc.items).toMatchObject(arrayDefaultValue)
expect(doc.localized).toMatchObject(arrayDefaultValue)
})
it('should create and update localized subfields with versions', async () => {
const doc = await payload.create({
collection,
data: {
items: [
{
localizedText: 'test',
text: 'required',
},
],
localized: [
{
text: 'english',
},
],
},
})
const spanish = await payload.update({
id: doc.id,
collection,
data: {
items: [
{
id: doc.items[0].id,
localizedText: 'spanish',
text: 'required',
},
],
},
locale: 'es',
})
const result = await payload.findByID({
id: doc.id,
collection,
locale: 'all',
})
expect(doc.items[0].localizedText).toStrictEqual('test')
expect(spanish.items[0].localizedText).toStrictEqual('spanish')
expect(result.items[0].localizedText.en).toStrictEqual('test')
expect(result.items[0].localizedText.es).toStrictEqual('spanish')
})
it('should create with nested array', async () => {
const subArrayText = 'something expected'
const doc = await payload.create({
collection,
data: {
items: [
{
subArray: [
{
text: subArrayText,
},
],
text: 'test',
},
],
},
})
const result = await payload.findByID({
id: doc.id,
collection,
})
expect(result.items[0]).toMatchObject({
subArray: [
{
text: subArrayText,
},
],
text: 'test',
})
expect(result.items[0].subArray[0].text).toStrictEqual(subArrayText)
})
it('should update without overwriting other locales with defaultValue', async () => {
const localized = [{ text: 'unique' }]
const enText = 'english'
const esText = 'spanish'
const { id } = await payload.create({
collection,
data: {
localized,
},
})
const enDoc = await payload.update({
id,
collection,
data: {
localized: [{ text: enText }],
},
locale: 'en',
})
const esDoc = await payload.update({
id,
collection,
data: {
localized: [{ text: esText }],
},
locale: 'es',
})
const allLocales = (await payload.findByID({
id,
collection,
locale: 'all',
})) as unknown as {
localized: {
en: unknown
es: unknown
}
}
expect(enDoc.localized[0].text).toStrictEqual(enText)
expect(esDoc.localized[0].text).toStrictEqual(esText)
expect(allLocales.localized.en[0].text).toStrictEqual(enText)
expect(allLocales.localized.es[0].text).toStrictEqual(esText)
})
})
describe('group', () => {
let document
beforeEach(async () => {
document = await payload.create({
collection: groupFieldsSlug,
data: {},
})
})
it('should create with defaultValue', () => {
expect(document.group.defaultParent).toStrictEqual(groupDefaultValue)
expect(document.group.defaultChild).toStrictEqual(groupDefaultChild)
})
it('should not have duplicate keys', () => {
expect(document.arrayOfGroups[0]).toMatchObject({
id: expect.any(String),
groupItem: {
text: 'Hello world',
},
})
})
it('should query a subfield within a localized group', async () => {
const text = 'find this'
const hit = await payload.create({
collection: groupFieldsSlug,
data: {
localizedGroup: {
text,
},
},
})
const miss = await payload.create({
collection: groupFieldsSlug,
data: {
localizedGroup: {
text: 'do not find this',
},
},
})
const result = await payload.find({
collection: groupFieldsSlug,
where: {
'localizedGroup.text': { equals: text },
},
})
const resultIDs = result.docs.map(({ id }) => id)
expect(resultIDs).toContain(hit.id)
expect(resultIDs).not.toContain(miss.id)
})
})
describe('tabs', () => {
let document
beforeEach(async () => {
document = await payload.create({
collection: tabsFieldsSlug,
data: tabsDoc,
})
})
it('should hot module reload and still be able to create', async () => {
const testDoc1 = await payload.findByID({
id: document.id,
collection: tabsFieldsSlug,
})
await reload(payload.config, payload)
const testDoc2 = await payload.findByID({
id: document.id,
collection: tabsFieldsSlug,
})
expect(testDoc1.id).toStrictEqual(testDoc2.id)
})
it('should create with fields inside a named tab', () => {
expect(document.tab.text).toStrictEqual(namedTabText)
})
it('should create with defaultValue inside a named tab', () => {
expect(document.tab.defaultValue).toStrictEqual(namedTabDefaultValue)
})
it('should create with defaultValue inside a named tab with no other values', () => {
expect(document.namedTabWithDefaultValue.defaultValue).toStrictEqual(namedTabDefaultValue)
})
it('should create with localized text inside a named tab', async () => {
document = await payload.findByID({
id: document.id,
collection: tabsFieldsSlug,
locale: 'all',
})
expect(document.localizedTab.en.text).toStrictEqual(localizedTextValue)
})
it('should allow access control on a named tab', async () => {
document = await payload.findByID({
id: document.id,
collection: tabsFieldsSlug,
overrideAccess: false,
})
expect(document.accessControlTab).toBeUndefined()
})
it('should allow hooks on a named tab', async () => {
const newDocument = await payload.create({
collection: tabsFieldsSlug,
data: tabsDoc,
})
expect(newDocument.hooksTab.beforeValidate).toBe(true)
expect(newDocument.hooksTab.beforeChange).toBe(true)
expect(newDocument.hooksTab.afterChange).toBe(true)
expect(newDocument.hooksTab.afterRead).toBe(true)
})
it('should return empty object for groups when no data present', async () => {
const doc = await payload.create({
collection: groupFieldsSlug,
data: groupDoc,
})
expect(doc.potentiallyEmptyGroup).toBeDefined()
})
})
describe('blocks', () => {
it('should retrieve doc with blocks', async () => {
const blockFields = await payload.find({
collection: 'block-fields',
})
expect(blockFields.docs[0].blocks[0].blockType).toEqual(blocksDoc.blocks[0].blockType)
expect(blockFields.docs[0].blocks[0].text).toEqual(blocksDoc.blocks[0].text)
expect(blockFields.docs[0].blocks[2].blockType).toEqual(blocksDoc.blocks[2].blockType)
expect(blockFields.docs[0].blocks[2].blockName).toEqual(blocksDoc.blocks[2].blockName)
expect(blockFields.docs[0].blocks[2].subBlocks[0].number).toEqual(
blocksDoc.blocks[2].subBlocks[0].number,
)
expect(blockFields.docs[0].blocks[2].subBlocks[1].text).toEqual(
blocksDoc.blocks[2].subBlocks[1].text,
)
})
it('should query based on richtext data within a block', async () => {
const blockFieldsSuccess = await payload.find({
collection: 'block-fields',
where: {
'blocks.richText.children.text': {
like: 'fun',
},
},
})
expect(blockFieldsSuccess.docs).toHaveLength(1)
const blockFieldsFail = await payload.find({
collection: 'block-fields',
where: {
'blocks.richText.children.text': {
like: 'funny',
},
},
})
expect(blockFieldsFail.docs).toHaveLength(0)
})
it('should query based on richtext data within a localized block, specifying locale', async () => {
const blockFieldsSuccess = await payload.find({
collection: 'block-fields',
where: {
'localizedBlocks.en.richText.children.text': {
like: 'fun',
},
},
})
expect(blockFieldsSuccess.docs).toHaveLength(1)
const blockFieldsFail = await payload.find({
collection: 'block-fields',
where: {
'localizedBlocks.en.richText.children.text': {
like: 'funny',
},
},
})
expect(blockFieldsFail.docs).toHaveLength(0)
})
it('should query based on richtext data within a localized block, without specifying locale', async () => {
const blockFieldsSuccess = await payload.find({
collection: 'block-fields',
where: {
'localizedBlocks.richText.children.text': {
like: 'fun',
},
},
})
expect(blockFieldsSuccess.docs).toHaveLength(1)
const blockFieldsFail = await payload.find({
collection: 'block-fields',
where: {
'localizedBlocks.richText.children.text': {
like: 'funny',
},
},
})
expect(blockFieldsFail.docs).toHaveLength(0)
})
it('should create when existing block ids are used', async () => {
const blockFields = await payload.find({
collection: 'block-fields',
limit: 1,
})
const [doc] = blockFields.docs
const result = await payload.create({
collection: 'block-fields',
data: {
...doc,
},
})
expect(result.id).toBeDefined()
})
it('should filter based on nested block fields', async () => {
await payload.create({
collection: 'block-fields',
data: {
blocks: [
{
blockType: 'content',
text: 'green',
},
],
},
})
await payload.create({
collection: 'block-fields',
data: {
blocks: [
{
blockType: 'content',
text: 'pink',
},
],
},
})
await payload.create({
collection: 'block-fields',
data: {
blocks: [
{
blockType: 'content',
text: 'green',
},
],
},
})
const blockFields = await payload.find({
collection: 'block-fields',
overrideAccess: false,
user,
where: {
and: [
{
'blocks.text': {
equals: 'green',
},
},
],
},
})
const { docs } = blockFields
expect(docs).toHaveLength(2)
})
it('should query blocks with nested relationship', async () => {
const textDoc = await payload.create({
collection: textFieldsSlug,
data: {
text: 'test',
},
})
const blockDoc = await payload.create({
collection: blockFieldsSlug,
data: {
relationshipBlocks: [
{
blockType: 'relationships',
relationship: textDoc.id,
},
],
},
})
const result = await payload.find({
collection: blockFieldsSlug,
where: {
'relationshipBlocks.relationship': { equals: textDoc.id },
},
})
expect(result.docs).toHaveLength(1)
expect(result.docs[0]).toMatchObject(blockDoc)
})
it('should query by blockType', async () => {
const text = 'blockType query test'
const hit = await payload.create({
collection: blockFieldsSlug,
data: {
blocks: [
{
blockType: 'content',
text,
},
],
},
})
const miss = await payload.create({
collection: blockFieldsSlug,
data: {
blocks: [
{
blockType: 'number',
number: 5,
},
],
duplicate: [
{
blockType: 'content',
text,
},
],
},
})
const { docs: equalsDocs } = await payload.find({
collection: blockFieldsSlug,
where: {
and: [
{
'blocks.blockType': { equals: 'content' },
},
{
'blocks.text': { equals: text },
},
],
},
})
const { docs: inDocs } = await payload.find({
collection: blockFieldsSlug,
where: {
'blocks.blockType': { in: ['content'] },
},
})
const equalsHitResult = equalsDocs.find(({ id }) => id === hit.id)
const inHitResult = inDocs.find(({ id }) => id === hit.id)
const equalsMissResult = equalsDocs.find(({ id }) => id === miss.id)
const inMissResult = inDocs.find(({ id }) => id === miss.id)
expect(equalsHitResult.id).toStrictEqual(hit.id)
expect(inHitResult.id).toStrictEqual(hit.id)
expect(equalsMissResult).toBeUndefined()
expect(inMissResult).toBeUndefined()
})
it('should allow localized array of blocks', async () => {
const result = await payload.create({
collection: blockFieldsSlug,
data: {
blocksWithLocalizedArray: [
{
blockType: 'localizedArray',
array: [
{
text: 'localized',
},
],
},
],
},
})
expect(result.blocksWithLocalizedArray[0].array[0].text).toEqual('localized')
})
})
describe('json', () => {
it('should save json data', async () => {
const json = { foo: 'bar' }
const doc = await payload.create({
collection: 'json-fields',
data: {
json,
},
})
expect(doc.json).toStrictEqual({ foo: 'bar' })
})
it('should validate json', async () => {
await expect(async () =>
payload.create({
collection: 'json-fields',
data: {
json: '{ bad input: true }',
},
}),
).rejects.toThrow('The following field is invalid: json')
})
it('should validate json schema', async () => {
await expect(async () =>
payload.create({
collection: 'json-fields',
data: {
json: { foo: 'bad' },
},
}),
).rejects.toThrow('The following field is invalid: json')
})
it('should save empty json objects', async () => {
const jsonFieldsDoc = await payload.create({
collection: 'json-fields',
data: {
json: {
state: {},
},
},
})
expect(jsonFieldsDoc.json.state).toEqual({})
const updatedJsonFieldsDoc = await payload.update({
id: jsonFieldsDoc.id,
collection: 'json-fields',
data: {
json: {
state: {},
},
},
})
expect(updatedJsonFieldsDoc.json.state).toEqual({})
})
describe('querying', () => {
let fooBar
let bazBar
beforeEach(async () => {
fooBar = await payload.create({
collection: 'json-fields',
data: {
json: { foo: 'foobar', number: 5 },
},
})
bazBar = await payload.create({
collection: 'json-fields',
data: {
json: { baz: 'bar', number: 10 },
},
})
})
it('should query nested properties - like', async () => {
const { docs } = await payload.find({
collection: 'json-fields',
where: {
'json.foo': { like: 'bar' },
},
})
const docIDs = docs.map(({ id }) => id)
expect(docIDs).toContain(fooBar.id)
expect(docIDs).not.toContain(bazBar.id)
})
it('should query nested properties - equals', async () => {
const { docs } = await payload.find({
collection: 'json-fields',
where: {
'json.foo': { equals: 'foobar' },
},
})
const notEquals = await payload.find({
collection: 'json-fields',
where: {
'json.foo': { equals: 'bar' },
},
})
const docIDs = docs.map(({ id }) => id)
expect(docIDs).toContain(fooBar.id)
expect(docIDs).not.toContain(bazBar.id)
expect(notEquals.docs).toHaveLength(0)
})
it('should query nested numbers - equals', async () => {
const { docs } = await payload.find({
collection: 'json-fields',
where: {
'json.number': { equals: 5 },
},
})
const docIDs = docs.map(({ id }) => id)
expect(docIDs).toContain(fooBar.id)
expect(docIDs).not.toContain(bazBar.id)
})
it('should query nested properties - exists', async () => {
const { docs } = await payload.find({
collection: 'json-fields',
where: {
'json.foo': { exists: true },
},
})
const docIDs = docs.map(({ id }) => id)
expect(docIDs).toContain(fooBar.id)
expect(docIDs).not.toContain(bazBar.id)
})
it('should query - exists', async () => {
const nullJSON = await payload.create({
collection: 'json-fields',
data: {},
})
const hasJSON = await payload.create({
collection: 'json-fields',
data: {
json: [],
},
})
const docsExistsFalse = await payload.find({
collection: 'json-fields',
where: {
json: { exists: false },
},
})
const docsExistsTrue = await payload.find({
collection: 'json-fields',
where: {
json: { exists: true },
},
})
const existFalseIDs = docsExistsFalse.docs.map(({ id }) => id)
const existTrueIDs = docsExistsTrue.docs.map(({ id }) => id)
expect(existFalseIDs).toContain(nullJSON.id)
expect(existTrueIDs).not.toContain(nullJSON.id)
expect(existTrueIDs).toContain(hasJSON.id)
expect(existFalseIDs).not.toContain(hasJSON.id)
})
it('exists should not return null values', async () => {
const { id } = await payload.create({
collection: 'select-fields',
data: {
select: 'one',
},
})
const existsResult = await payload.find({
collection: 'select-fields',
where: {
id: { equals: id },
select: { exists: true },
},
})
expect(existsResult.docs).toHaveLength(1)
const existsFalseResult = await payload.find({
collection: 'select-fields',
where: {
id: { equals: id },
select: { exists: false },
},
})
expect(existsFalseResult.docs).toHaveLength(0)
await payload.update({
id,
collection: 'select-fields',
data: {
select: null,
},
})
const existsTrueResult = await payload.find({
collection: 'select-fields',
where: {
id: { equals: id },
select: { exists: true },
},
})
expect(existsTrueResult.docs).toHaveLength(0)
const result = await payload.find({
collection: 'select-fields',
where: {
id: { equals: id },
select: { exists: false },
},
})
expect(result.docs).toHaveLength(1)
})
})
})
describe('richText', () => {
it('should allow querying on rich text content', async () => {
const emptyRichTextQuery = await payload.find({
collection: 'rich-text-fields',
where: {
'richText.children.text': {
like: 'doesnt exist',
},
},
})
expect(emptyRichTextQuery.docs).toHaveLength(0)
const workingRichTextQuery = await payload.find({
collection: 'rich-text-fields',
where: {
'richText.children.text': {
like: 'hello',
},
},
})
expect(workingRichTextQuery.docs).toHaveLength(1)
})
it('should show center alignment', async () => {
const query = await payload.find({
collection: 'rich-text-fields',
where: {
'richText.children.text': {
like: 'hello',
},
},
})
expect(query.docs[0].richText[0].textAlign).toEqual('center')
})
it('should populate link relationship', async () => {
const query = await payload.find({
collection: 'rich-text-fields',
where: {
'richText.children.linkType': {
equals: 'internal',
},
},
})
const nodes = query.docs[0].richText
expect(nodes).toBeDefined()
const child = nodes.flatMap((n) => n.children).find((c) => c.doc)
expect(child).toMatchObject({
type: 'link',
linkType: 'internal',
})
expect(child.doc.relationTo).toEqual('array-fields')
if (payload.db.defaultIDType === 'number') {
expect(typeof child.doc.value.id).toBe('number')
} else {
expect(typeof child.doc.value.id).toBe('string')
}
expect(child.doc.value.items).toHaveLength(6)
})
it('should respect rich text depth parameter', async () => {
const query = `query {
RichTextFields {
docs {
richText(depth: 2)
}
}
}`
const { data } = await restClient
.GRAPHQL_POST({
body: JSON.stringify({ query }),
})
.then((res) => res.json())
const { docs }: PaginatedDocs<RichTextField> = data.RichTextFields
const uploadElement = docs[0].richText.find((el) => el.type === 'upload') as any
expect(uploadElement.value.media.filename).toStrictEqual('payload.png')
})
})
describe('relationships', () => {
it('should not crash if querying with empty in operator', async () => {
const query = await payload.find({
collection: 'relationship-fields',
where: {
'relationship.value': {
in: [],
},
},
})
expect(query.docs).toBeDefined()
})
})
describe('clearable fields - exists', () => {
it('exists should not return null values', async () => {
const { id } = await payload.create({
collection: 'select-fields',
data: {
select: 'one',
},
})
const existsResult = await payload.find({
collection: 'select-fields',
where: {
id: { equals: id },
select: { exists: true },
},
})
expect(existsResult.docs).toHaveLength(1)
const existsFalseResult = await payload.find({
collection: 'select-fields',
where: {
id: { equals: id },
select: { exists: false },
},
})
expect(existsFalseResult.docs).toHaveLength(0)
await payload.update({
id,
collection: 'select-fields',
data: {
select: null,
},
})
const existsTrueResult = await payload.find({
collection: 'select-fields',
where: {
id: { equals: id },
select: { exists: true },
},
})
expect(existsTrueResult.docs).toHaveLength(0)
const result = await payload.find({
collection: 'select-fields',
where: {
id: { equals: id },
select: { exists: false },
},
})
expect(result.docs).toHaveLength(1)
})
})
})