Compare commits

..

17 Commits

Author SHA1 Message Date
Elliot DeNolf
2d8b752ef2 chore(release): v3.0.0-beta.90 [skip ci] 2024-08-21 22:58:28 -04:00
Elliot DeNolf
3e5c31a024 chore(scripts): add db-vercel-postgres to publish list 2024-08-21 22:55:29 -04:00
Elliot DeNolf
631431e006 feat: @payloadcms/db-vercel-postgres adapter (#7806)
Dedicated adapter for Vercel Postgres

- Uses the `@vercel/postgres` package under the hood.
- No `pg` dependency, speeds up invocation
- Includes refactoring all base postgres functionality into a
`BasePostgresAdapter` type, which will ease implementation of [other
adapters supported by
drizzle-orm](https://orm.drizzle.team/docs/get-started-postgresql)

## Usage

```ts
import { buildConfig } from 'payload'
import { vercelPostgresAdapter } from '@payloadcms/db-vercel-postgres'

export default buildConfig({
  db: vercelPostgresAdapter({
    pool: {
      connectionString: process.env.DATABASE_URI,
    },
  }),
  // ...rest of config
})
```

### Automatic Connection String Detection

Have Vercel automatically detect from environment variable (typically
`process.env.POSTGRES_URL`)

```ts
export default buildConfig({
  db: postgresAdapter(),
  // ...rest of config
})
```
2024-08-21 22:54:47 -04:00
Paul
492d920133 fix(ui): upload has many no rows error (#7804) 2024-08-21 22:52:20 -04:00
Elliot DeNolf
f754edc375 chore(release): v3.0.0-beta.89 [skip ci] 2024-08-21 20:54:18 -04:00
Paul
d2571e10d6 feat: upload hasmany (#7796)
Supports `hasMany` upload fields, similar to how `hasMany` works in
other fields, i.e.:

```ts
{
  type: 'upload',
  relationTo: 'media',
  hasMany: true
}
```

---------

Co-authored-by: Jacob Fletcher <jacobsfletch@gmail.com>
Co-authored-by: James <james@trbl.design>
2024-08-21 20:44:04 -04:00
Alessio Gravili
a687cb9c5b Merge PR: Lexical migration and validation improvements 2024-08-21 18:31:53 -04:00
Alessio Gravili
cf6634111f fix(richtext-lexical): ensure errors during slate => lexical migration are caught and do not halt migration progress 2024-08-21 17:52:45 -04:00
Jarrod Flesch
1ee19d3016 feat: bulk upload (#7800)
## Description

Adds bulk upload functionality to upload enabled configs.

You can disable the ability by defining `upload.bulkUpload: false` in
your upload enabled config.

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

## Type of change

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

## Checklist:

- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] Existing test suite passes locally with my changes
- [ ] I have made corresponding changes to the documentation
2024-08-21 17:44:34 -04:00
Alessio Gravili
9beaa281dc feat: log document id in ValidationError cause, if present 2024-08-21 17:31:31 -04:00
Alessio Gravili
5174c7092f fix(richtext-lexical): inaccurate detection of whether the editor is empty or not 2024-08-21 17:26:02 -04:00
Alessio Gravili
d894ac75f0 fix(richtext-lexical): migrate scripts not working due to migration hooks running during migrate script 2024-08-21 16:43:56 -04:00
Alessio Gravili
af0105ced5 fix: no longer handle disabling node deprecation warnings within bin script shebangs, as it errored on some systems (#7797)
Fixes https://github.com/payloadcms/payload/issues/7741

I have no idea why it broke and was not able to reproduce this at all.
But given the amount of people reporting this issue, it's not worth
keeping this around for the small benefit this brings
2024-08-21 17:01:57 +00:00
Tylan Davis
93e81314df fix(ui, richtext-lexical): corrects clickable areas on block headers (#7791)
## Description

Fixes an issue where Block component section titles were taking up the
entire clickable area of block headers.

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

## Type of change

<!-- Please delete options that are not relevant. -->

- [x] Bug fix (non-breaking change which fixes an issue)

## Checklist:

- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] Existing test suite passes locally with my changes
- [ ] I have made corresponding changes to the documentation
2024-08-21 10:45:05 -04:00
Jarrod Flesch
163d1c85da chore: corrects icon color styles (#7792) 2024-08-21 10:28:01 -04:00
Jacob Fletcher
cb9b80aaf9 fix!: handles custom collection description components (#7789)
## Description

Closes #7784 by properly handling custom collection description
components via `admin.components.Description`. This component was
incorrectly added to the `admin.components.edit` key, and also was never
handled on the front-end. This was especially misleading because the
client-side config had a duplicative key in the proper position.

## Breaking Changes

This PR is only labeled as a breaking change because the key has changed
position within the config. If you were previously defining a custom
description component on a collection, simply move it into the correct
position:

Old:
```ts
{
  admin: {
    components: {
      edit: {
        Description: ''
      }
    }
  }
}
```

New:

```ts
{
  admin: {
    components: {
      Description: ''
    }
  }
}
```

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

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)
- [x] This change requires a documentation update

## Checklist:

- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] Existing test suite passes locally with my changes
- [x] I have made corresponding changes to the documentation
2024-08-21 10:20:22 -04:00
Jarrod Flesch
cad1906725 feat: extends Button and extracts ListHeader components (#7777) 2024-08-21 09:37:11 -04:00
227 changed files with 5737 additions and 711 deletions

View File

@@ -31,7 +31,7 @@ The following options are available:
| **`hidden`** | Set to true or a function, called with the current user, returning true to exclude this Collection from navigation and admin routing. |
| **`hooks`** | Admin-specific hooks for this Collection. [More details](../hooks/collections). |
| **`useAsTitle`** | Specify a top-level field to use for a document title throughout the Admin Panel. If no field is defined, the ID of the document is used as the title. |
| **`description`** | Text or React component to display below the Collection label in the List View to give editors more information. |
| **`description`** | Text to display below the Collection label in the List View to give editors more information. Alternatively, you can use the `admin.components.Description` to render a React component. [More details](#components). |
| **`defaultColumns`** | Array of field names that correspond to which columns to show by default in this Collection's List View. |
| **`hideAPIURL`** | Hides the "API URL" meta field while editing documents within this Collection. |
| **`enableRichTextLink`** | The [Rich Text](../fields/rich-text) field features a `Link` element which allows for users to automatically reference related documents within their rich text. Set to `true` by default. |
@@ -69,7 +69,8 @@ The following options are available:
| **`beforeList`** | An array of components to inject _before_ the built-in List View |
| **`beforeListTable`** | An array of components to inject _before_ the built-in List View's table |
| **`afterList`** | An array of components to inject _after_ the built-in List View |
| **`afterListTable`** | An array of components to inject _after_ the built-in List View's table |
| **`afterListTable`** | An array of components to inject _after_ the built-in List View's table
| **`Description`** | A component to render below the Collection label in the List View. An alternative to the `admin.description` property. |
| **`edit.SaveButton`** | Replace the default Save Button with a Custom Component. [Drafts](../versions/drafts) must be disabled. |
| **`edit.SaveDraftButton`** | Replace the default Save Draft Button with a Custom Component. [Drafts](../versions/drafts) must be enabled and autosave must be disabled. |
| **`edit.PublishButton`** | Replace the default Publish Button with a Custom Component. [Drafts](../versions/drafts) must be enabled. |

View File

@@ -18,6 +18,7 @@ IMPORTANT: This will overwrite all slate data. We recommend doing the following
1. Take a backup of your entire database. If anything goes wrong and you do not have a backup, you are on your own and will not receive any support.
2. Make every richText field a lexical editor. This script will only convert lexical richText fields with old Slate data
3. Add the SlateToLexicalFeature (as seen below) first, and test it out by loading up the Admin Panel, to see if the migrator works as expected. You might have to build some custom converters for some fields first in order to convert custom Slate nodes. The SlateToLexicalFeature is where the converters are stored. Only fields with this feature added will be migrated.
4. If this works as expected, add the `disableHooks: true` prop everywhere you're initializing `SlateToLexicalFeature`. Example: `SlateToLexicalFeature({ disableHooks: true })`. Once you did that, you're ready to run the migration script.
```ts
import { migrateSlateToLexical } from '@payloadcms/richtext-lexical/migrate'

View File

@@ -92,6 +92,7 @@ _An asterisk denotes that an option is required._
| Option | Description |
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`adminThumbnail`** | Set the way that the [Admin Panel](../admin/overview) will display thumbnails for this Collection. [More](#admin-thumbnails) |
| **`bulkUpload`** | Allow users to upload in bulk from the list view, default is true |
| **`crop`** | Set to `false` to disable the cropping tool in the [Admin Panel](../admin/overview). Crop is enabled by default. [More](#crop-and-focal-point-selector) |
| **`disableLocalStorage`** | Completely disable uploading files to disk locally. [More](#disabling-local-upload-storage) |
| **`displayPreview`** | Enable displaying preview of the uploaded file in Upload fields related to this Collection. Can be locally overridden by `displayPreview` option in Upload field. [More](/docs/fields/upload#config-options). |

View File

@@ -1,6 +1,6 @@
{
"name": "payload-monorepo",
"version": "3.0.0-beta.88",
"version": "3.0.0-beta.90",
"private": true,
"type": "module",
"scripts": {
@@ -16,6 +16,7 @@
"build:db-mongodb": "turbo build --filter db-mongodb",
"build:db-postgres": "turbo build --filter db-postgres",
"build:db-sqlite": "turbo build --filter db-sqlite",
"build:db-vercel-postgres": "turbo build --filter db-vercel-postgres",
"build:drizzle": "turbo build --filter drizzle",
"build:email-nodemailer": "turbo build --filter email-nodemailer",
"build:email-resend": "turbo build --filter email-resend",
@@ -58,6 +59,7 @@
"dev:generate-importmap": "pnpm runts ./test/generateImportMap.ts",
"dev:generate-types": "pnpm runts ./test/generateTypes.ts",
"dev:postgres": "cross-env PAYLOAD_DATABASE=postgres pnpm runts ./test/dev.ts",
"dev:vercel-postgres": "cross-env PAYLOAD_DATABASE=vercel-postgres pnpm runts ./test/dev.ts",
"devsafe": "node ./scripts/delete-recursively.js '**/.next' && pnpm dev",
"docker:restart": "pnpm docker:stop --remove-orphans && pnpm docker:start",
"docker:start": "docker compose -f packages/plugin-cloud-storage/docker-compose.yml up -d",

View File

@@ -1,6 +1,6 @@
{
"name": "create-payload-app",
"version": "3.0.0-beta.88",
"version": "3.0.0-beta.90",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",

View File

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

View File

@@ -182,6 +182,7 @@ export function mongooseAdapter({
init,
migrateFresh,
migrationDir,
packageName: '@payloadcms/db-mongodb',
payload,
prodMigrations,
queryDrafts,

View File

@@ -595,14 +595,77 @@ const fieldToSchemaMap: Record<string, FieldSchemaGenerator> = {
config: SanitizedConfig,
buildSchemaOptions: BuildSchemaOptions,
): void => {
const baseSchema = {
...formatBaseSchema(field, buildSchemaOptions),
type: mongoose.Schema.Types.Mixed,
ref: field.relationTo,
const hasManyRelations = Array.isArray(field.relationTo)
let schemaToReturn: { [key: string]: any } = {}
if (field.localized && config.localization) {
schemaToReturn = {
type: config.localization.localeCodes.reduce((locales, locale) => {
let localeSchema: { [key: string]: any } = {}
if (hasManyRelations) {
localeSchema = {
...formatBaseSchema(field, buildSchemaOptions),
_id: false,
type: mongoose.Schema.Types.Mixed,
relationTo: { type: String, enum: field.relationTo },
value: {
type: mongoose.Schema.Types.Mixed,
refPath: `${field.name}.${locale}.relationTo`,
},
}
} else {
localeSchema = {
...formatBaseSchema(field, buildSchemaOptions),
type: mongoose.Schema.Types.Mixed,
ref: field.relationTo,
}
}
return {
...locales,
[locale]: field.hasMany
? { type: [localeSchema], default: formatDefaultValue(field) }
: localeSchema,
}
}, {}),
localized: true,
}
} else if (hasManyRelations) {
schemaToReturn = {
...formatBaseSchema(field, buildSchemaOptions),
_id: false,
type: mongoose.Schema.Types.Mixed,
relationTo: { type: String, enum: field.relationTo },
value: {
type: mongoose.Schema.Types.Mixed,
refPath: `${field.name}.relationTo`,
},
}
if (field.hasMany) {
schemaToReturn = {
type: [schemaToReturn],
default: formatDefaultValue(field),
}
}
} else {
schemaToReturn = {
...formatBaseSchema(field, buildSchemaOptions),
type: mongoose.Schema.Types.Mixed,
ref: field.relationTo,
}
if (field.hasMany) {
schemaToReturn = {
type: [schemaToReturn],
default: formatDefaultValue(field),
}
}
}
schema.add({
[field.name]: localizeSchema(field, baseSchema, config.localization),
[field.name]: schemaToReturn,
})
},
}

View File

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

View File

@@ -139,6 +139,7 @@ export function postgresAdapter(args: Args): DatabaseAdapterObj<PostgresAdapter>
migrateReset,
migrateStatus,
migrationDir,
packageName: '@payloadcms/db-postgres',
payload,
queryDrafts,
rejectInitializing,

View File

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

View File

@@ -140,6 +140,7 @@ export function sqliteAdapter(args: Args): DatabaseAdapterObj<SQLiteAdapter> {
migrateReset,
migrateStatus,
migrationDir,
packageName: '@payloadcms/db-sqlite',
payload,
queryDrafts,
rejectInitializing,

View File

@@ -717,7 +717,7 @@ export const traverseFields = ({
case 'upload':
if (Array.isArray(field.relationTo)) {
field.relationTo.forEach((relation) => relationships.add(relation))
} else if (field.type === 'relationship' && field.hasMany) {
} else if (field.hasMany) {
relationships.add(field.relationTo)
} else {
// simple relationships get a column on the targetTable with a foreign key to the relationTo table

View File

@@ -0,0 +1 @@
/migrations

View File

@@ -0,0 +1,10 @@
.tmp
**/.git
**/.hg
**/.pnp.*
**/.svn
**/.yarn/**
**/build
**/dist/**
**/node_modules
**/temp

View File

@@ -0,0 +1,15 @@
{
"$schema": "https://json.schemastore.org/swcrc",
"sourceMaps": true,
"jsc": {
"target": "esnext",
"parser": {
"syntax": "typescript",
"tsx": true,
"dts": true
}
},
"module": {
"type": "es6"
}
}

View File

@@ -0,0 +1,43 @@
# Payload Postgres Adapter
[Vercel Postgres](https://vercel.com/docs/storage/vercel-postgres) adapter for [Payload](https://payloadcms.com).
- [Main Repository](https://github.com/payloadcms/payload)
- [Payload Docs](https://payloadcms.com/docs)
## Installation
```bash
npm install @payloadcms/db-vercel-postgres
```
## Usage
### Explicit Connection String
```ts
import { buildConfig } from 'payload'
import { vercelPostgresAdapter } from '@payloadcms/db-vercel-postgres'
export default buildConfig({
db: vercelPostgresAdapter({
pool: {
connectionString: process.env.DATABASE_URI,
},
}),
// ...rest of config
})
```
### Automatic Connection String Detection
Have Vercel automatically detect from environment variable (typically `process.env.POSTGRES_URL`)
```ts
export default buildConfig({
db: postgresAdapter(),
// ...rest of config
})
```
More detailed usage can be found in the [Payload Docs](https://payloadcms.com/docs/configuration/overview).

View File

@@ -0,0 +1,20 @@
import { rootEslintConfig, rootParserOptions } from '../../eslint.config.js'
/** @typedef {import('eslint').Linter.FlatConfig} */
let FlatConfig
/** @type {FlatConfig[]} */
export const index = [
...rootEslintConfig,
{
languageOptions: {
parserOptions: {
project: './tsconfig.json',
tsconfigDirName: import.meta.dirname,
...rootParserOptions,
},
},
},
]
export default index

View File

@@ -0,0 +1,89 @@
{
"name": "@payloadcms/db-vercel-postgres",
"version": "3.0.0-beta.90",
"description": "Vercel Postgres adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",
"url": "https://github.com/payloadcms/payload.git",
"directory": "packages/db-vercel-postgres"
},
"license": "MIT",
"author": "Payload <dev@payloadcms.com> (https://payloadcms.com)",
"type": "module",
"exports": {
".": {
"import": "./src/index.ts",
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./types": {
"import": "./src/types.ts",
"types": "./src/types.ts",
"default": "./src/types.ts"
},
"./migration-utils": {
"import": "./src/exports/migration-utils.ts",
"types": "./src/exports/migration-utils.ts",
"default": "./src/exports/migration-utils.ts"
}
},
"main": "./src/index.ts",
"types": "./src/types.ts",
"files": [
"dist",
"mock.js"
],
"scripts": {
"build": "rimraf .dist && rimraf tsconfig.tsbuildinfo && pnpm build:types && pnpm build:swc && pnpm build:esbuild && pnpm renamePredefinedMigrations",
"build:esbuild": "echo skipping esbuild",
"build:swc": "swc ./src -d ./dist --config-file .swcrc --strip-leading-paths",
"build:types": "tsc --emitDeclarationOnly --outDir dist",
"clean": "rimraf {dist,*.tsbuildinfo}",
"prepack": "pnpm clean && pnpm turbo build",
"prepublishOnly": "pnpm clean && pnpm turbo build",
"renamePredefinedMigrations": "node --no-deprecation --import @swc-node/register/esm-register ./scripts/renamePredefinedMigrations.ts"
},
"dependencies": {
"@payloadcms/drizzle": "workspace:*",
"@vercel/postgres": "^0.9.0",
"console-table-printer": "2.11.2",
"drizzle-kit": "0.23.2-df9e596",
"drizzle-orm": "0.32.1",
"prompts": "2.4.2",
"to-snake-case": "1.0.0",
"uuid": "10.0.0"
},
"devDependencies": {
"@hyrious/esbuild-plugin-commonjs": "^0.2.4",
"@payloadcms/eslint-config": "workspace:*",
"@types/pg": "8.10.2",
"@types/to-snake-case": "1.0.0",
"esbuild": "0.23.0",
"payload": "workspace:*"
},
"peerDependencies": {
"payload": "workspace:*"
},
"publishConfig": {
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./types": {
"import": "./dist/types.js",
"types": "./dist/types.d.ts",
"default": "./dist/types.js"
},
"./migration-utils": {
"import": "./dist/exports/migration-utils.js",
"types": "./dist/exports/migration-utils.d.ts",
"default": "./dist/exports/migration-utils.js"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts"
}
}

View File

@@ -0,0 +1,13 @@
const imports = `import { migratePostgresV2toV3 } from '@payloadcms/migratePostgresV2toV3'`
const up = ` await migratePostgresV2toV3({
// enables logging of changes that will be made to the database
debug: false,
// skips calls that modify schema or data
dryRun: false,
payload,
req,
})
`
export { imports, up }
//# sourceMappingURL=relationships-v2-v3.js.map

View File

@@ -0,0 +1,19 @@
import fs from 'fs'
import path from 'path'
/**
* Changes built .js files to .mjs to for ESM imports
*/
const rename = () => {
fs.readdirSync(path.resolve('./dist/predefinedMigrations'))
.filter((f) => {
return f.endsWith('.js')
})
.forEach((file) => {
const newPath = path.join('./dist/predefinedMigrations', file)
fs.renameSync(newPath, newPath.replace('.js', '.mjs'))
})
console.log('done')
}
rename()

View File

@@ -0,0 +1,61 @@
import type { DrizzleAdapter } from '@payloadcms/drizzle/types'
import type { Connect } from 'payload'
import { pushDevSchema } from '@payloadcms/drizzle'
import { VercelPool, sql } from '@vercel/postgres'
import { drizzle } from 'drizzle-orm/node-postgres'
import type { VercelPostgresAdapter } from './types.js'
export const connect: Connect = async function connect(
this: VercelPostgresAdapter,
options = {
hotReload: false,
},
) {
const { hotReload } = options
this.schema = {
pgSchema: this.pgSchema,
...this.tables,
...this.relations,
...this.enums,
}
try {
const logger = this.logger || false
// Passed the poolOptions if provided,
// else have vercel/postgres detect the connection string from the environment
this.drizzle = drizzle(this.poolOptions ? new VercelPool(this.poolOptions) : sql, {
logger,
schema: this.schema,
})
if (!hotReload) {
if (process.env.PAYLOAD_DROP_DATABASE === 'true') {
this.payload.logger.info(`---- DROPPING TABLES SCHEMA(${this.schemaName || 'public'}) ----`)
await this.dropDatabase({ adapter: this })
this.payload.logger.info('---- DROPPED TABLES ----')
}
}
} catch (err) {
this.payload.logger.error(`Error: cannot connect to Postgres. Details: ${err.message}`, err)
if (typeof this.rejectInitializing === 'function') this.rejectInitializing()
process.exit(1)
}
// Only push schema if not in production
if (
process.env.NODE_ENV !== 'production' &&
process.env.PAYLOAD_MIGRATING !== 'true' &&
this.push !== false
) {
await pushDevSchema(this as unknown as DrizzleAdapter)
}
if (typeof this.resolveInitializing === 'function') this.resolveInitializing()
if (process.env.NODE_ENV === 'production' && this.prodMigrations) {
await this.migrate({ migrations: this.prodMigrations })
}
}

View File

@@ -0,0 +1 @@
export { migratePostgresV2toV3 } from '../predefinedMigrations/v2-v3/index.js'

View File

@@ -0,0 +1,163 @@
import type { DatabaseAdapterObj, Payload } from 'payload'
import {
beginTransaction,
commitTransaction,
count,
create,
createGlobal,
createGlobalVersion,
createVersion,
deleteMany,
deleteOne,
deleteVersions,
destroy,
find,
findGlobal,
findGlobalVersions,
findMigrationDir,
findOne,
findVersions,
migrate,
migrateDown,
migrateFresh,
migrateRefresh,
migrateReset,
migrateStatus,
operatorMap,
queryDrafts,
rollbackTransaction,
updateGlobal,
updateGlobalVersion,
updateOne,
updateVersion,
} from '@payloadcms/drizzle'
import {
convertPathToJSONTraversal,
countDistinct,
createJSONQuery,
createMigration,
defaultDrizzleSnapshot,
deleteWhere,
dropDatabase,
execute,
getMigrationTemplate,
init,
insert,
requireDrizzleKit,
} from '@payloadcms/drizzle/postgres'
import { pgEnum, pgSchema, pgTable } from 'drizzle-orm/pg-core'
import { createDatabaseAdapter } from 'payload'
import type { Args, VercelPostgresAdapter } from './types.js'
import { connect } from './connect.js'
export function vercelPostgresAdapter(args: Args = {}): DatabaseAdapterObj<VercelPostgresAdapter> {
const postgresIDType = args.idType || 'serial'
const payloadIDType = postgresIDType === 'serial' ? 'number' : 'text'
function adapter({ payload }: { payload: Payload }) {
const migrationDir = findMigrationDir(args.migrationDir)
let resolveInitializing
let rejectInitializing
let adapterSchema: VercelPostgresAdapter['pgSchema']
const initializing = new Promise<void>((res, rej) => {
resolveInitializing = res
rejectInitializing = rej
})
if (args.schemaName) {
adapterSchema = pgSchema(args.schemaName)
} else {
adapterSchema = { enum: pgEnum, table: pgTable }
}
return createDatabaseAdapter<VercelPostgresAdapter>({
name: 'postgres',
defaultDrizzleSnapshot,
drizzle: undefined,
enums: {},
features: {
json: true,
},
fieldConstraints: {},
getMigrationTemplate,
idType: postgresIDType,
initializing,
localesSuffix: args.localesSuffix || '_locales',
logger: args.logger,
operators: operatorMap,
pgSchema: adapterSchema,
pool: undefined,
poolOptions: args.pool,
prodMigrations: args.prodMigrations,
push: args.push,
relations: {},
relationshipsSuffix: args.relationshipsSuffix || '_rels',
schema: {},
schemaName: args.schemaName,
sessions: {},
tableNameMap: new Map<string, string>(),
tables: {},
transactionOptions: args.transactionOptions || undefined,
versionsSuffix: args.versionsSuffix || '_v',
// DatabaseAdapter
beginTransaction: args.transactionOptions === false ? undefined : beginTransaction,
commitTransaction,
connect,
convertPathToJSONTraversal,
count,
countDistinct,
create,
createGlobal,
createGlobalVersion,
createJSONQuery,
createMigration,
createVersion,
defaultIDType: payloadIDType,
deleteMany,
deleteOne,
deleteVersions,
deleteWhere,
destroy,
dropDatabase,
execute,
find,
findGlobal,
findGlobalVersions,
findOne,
findVersions,
init,
insert,
migrate,
migrateDown,
migrateFresh,
migrateRefresh,
migrateReset,
migrateStatus,
migrationDir,
packageName: '@payloadcms/db-vercel-postgres',
payload,
queryDrafts,
rejectInitializing,
requireDrizzleKit,
resolveInitializing,
rollbackTransaction,
updateGlobal,
updateGlobalVersion,
updateOne,
updateVersion,
})
}
return {
defaultIDType: payloadIDType,
init: adapter,
}
}
export type { MigrateDownArgs, MigrateUpArgs } from '@payloadcms/drizzle/postgres'
export { sql } from 'drizzle-orm'

View File

@@ -0,0 +1,10 @@
const imports = `import { migratePostgresV2toV3 } from '@payloadcms/db-postgres/migration-utils'`
const upSQL = ` await migratePostgresV2toV3({
// enables logging of changes that will be made to the database
debug: false,
payload,
req,
})
`
export { imports, upSQL }

View File

@@ -0,0 +1,237 @@
import type { TransactionPg } from '@payloadcms/drizzle/types'
import type { Field, Payload, PayloadRequest } from 'payload'
import { upsertRow } from '@payloadcms/drizzle'
import type { VercelPostgresAdapter } from '../../../types.js'
import type { DocsToResave } from '../types.js'
import { traverseFields } from './traverseFields.js'
type Args = {
adapter: VercelPostgresAdapter
collectionSlug?: string
db: TransactionPg
debug: boolean
docsToResave: DocsToResave
fields: Field[]
globalSlug?: string
isVersions: boolean
payload: Payload
req: PayloadRequest
tableName: string
}
export const fetchAndResave = async ({
adapter,
collectionSlug,
db,
debug,
docsToResave,
fields,
globalSlug,
isVersions,
payload,
req,
tableName,
}: Args) => {
for (const [id, rows] of Object.entries(docsToResave)) {
if (collectionSlug) {
const collectionConfig = payload.collections[collectionSlug].config
if (collectionConfig) {
if (isVersions) {
const doc = await payload.findVersionByID({
id,
collection: collectionSlug,
depth: 0,
fallbackLocale: null,
locale: 'all',
req,
showHiddenFields: true,
})
if (debug) {
payload.logger.info(
`The collection "${collectionConfig.slug}" version with ID ${id} will be migrated`,
)
}
traverseFields({
doc,
fields,
path: '',
rows,
})
try {
await upsertRow({
id: doc.id,
adapter,
data: doc,
db,
fields,
ignoreResult: true,
operation: 'update',
req,
tableName,
})
} catch (err) {
payload.logger.error(
`"${collectionConfig.slug}" version with ID ${doc.id} FAILED TO MIGRATE`,
)
throw err
}
if (debug) {
payload.logger.info(
`"${collectionConfig.slug}" version with ID ${doc.id} migrated successfully!`,
)
}
} else {
const doc = await payload.findByID({
id,
collection: collectionSlug,
depth: 0,
fallbackLocale: null,
locale: 'all',
req,
showHiddenFields: true,
})
if (debug) {
payload.logger.info(
`The collection "${collectionConfig.slug}" with ID ${doc.id} will be migrated`,
)
}
traverseFields({
doc,
fields,
path: '',
rows,
})
try {
await upsertRow({
id: doc.id,
adapter,
data: doc,
db,
fields,
ignoreResult: true,
operation: 'update',
req,
tableName,
})
} catch (err) {
payload.logger.error(
`The collection "${collectionConfig.slug}" with ID ${doc.id} has FAILED TO MIGRATE`,
)
throw err
}
if (debug) {
payload.logger.info(
`The collection "${collectionConfig.slug}" with ID ${doc.id} has migrated successfully!`,
)
}
}
}
}
if (globalSlug) {
const globalConfig = payload.config.globals?.find((global) => global.slug === globalSlug)
if (globalConfig) {
if (isVersions) {
const { docs } = await payload.findGlobalVersions({
slug: globalSlug,
depth: 0,
fallbackLocale: null,
limit: 0,
locale: 'all',
req,
showHiddenFields: true,
})
if (debug) {
payload.logger.info(`${docs.length} global "${globalSlug}" versions will be migrated`)
}
for (const doc of docs) {
traverseFields({
doc,
fields,
path: '',
rows,
})
try {
await upsertRow({
id: doc.id,
adapter,
data: doc,
db,
fields,
ignoreResult: true,
operation: 'update',
req,
tableName,
})
} catch (err) {
payload.logger.error(`"${globalSlug}" version with ID ${doc.id} FAILED TO MIGRATE`)
throw err
}
if (debug) {
payload.logger.info(
`"${globalSlug}" version with ID ${doc.id} migrated successfully!`,
)
}
}
} else {
const doc = await payload.findGlobal({
slug: globalSlug,
depth: 0,
fallbackLocale: null,
locale: 'all',
req,
showHiddenFields: true,
})
traverseFields({
doc,
fields,
path: '',
rows,
})
try {
await upsertRow({
adapter,
data: doc,
db,
fields,
ignoreResult: true,
operation: 'update',
req,
tableName,
})
} catch (err) {
payload.logger.error(`The global "${globalSlug}" has FAILED TO MIGRATE`)
throw err
}
if (debug) {
payload.logger.info(`The global "${globalSlug}" has migrated successfully!`)
}
}
}
}
}
}

View File

@@ -0,0 +1,215 @@
import type { Field } from 'payload'
import { tabHasName } from 'payload/shared'
type Args = {
doc: Record<string, unknown>
fields: Field[]
locale?: string
path: string
rows: Record<string, unknown>[]
}
export const traverseFields = ({ doc, fields, locale, path, rows }: Args) => {
fields.forEach((field) => {
switch (field.type) {
case 'group': {
const newPath = `${path ? `${path}.` : ''}${field.name}`
const newDoc = doc?.[field.name]
if (typeof newDoc === 'object' && newDoc !== null) {
if (field.localized) {
Object.entries(newDoc).forEach(([locale, localeDoc]) => {
return traverseFields({
doc: localeDoc,
fields: field.fields,
locale,
path: newPath,
rows,
})
})
} else {
return traverseFields({
doc: newDoc as Record<string, unknown>,
fields: field.fields,
path: newPath,
rows,
})
}
}
break
}
case 'row':
case 'collapsible': {
return traverseFields({
doc,
fields: field.fields,
path,
rows,
})
}
case 'array': {
const rowData = doc?.[field.name]
if (field.localized && typeof rowData === 'object' && rowData !== null) {
Object.entries(rowData).forEach(([locale, localeRows]) => {
if (Array.isArray(localeRows)) {
localeRows.forEach((row, i) => {
return traverseFields({
doc: row as Record<string, unknown>,
fields: field.fields,
locale,
path: `${path ? `${path}.` : ''}${field.name}.${i}`,
rows,
})
})
}
})
}
if (Array.isArray(rowData)) {
rowData.forEach((row, i) => {
return traverseFields({
doc: row as Record<string, unknown>,
fields: field.fields,
path: `${path ? `${path}.` : ''}${field.name}.${i}`,
rows,
})
})
}
break
}
case 'blocks': {
const rowData = doc?.[field.name]
if (field.localized && typeof rowData === 'object' && rowData !== null) {
Object.entries(rowData).forEach(([locale, localeRows]) => {
if (Array.isArray(localeRows)) {
localeRows.forEach((row, i) => {
const matchedBlock = field.blocks.find((block) => block.slug === row.blockType)
if (matchedBlock) {
return traverseFields({
doc: row as Record<string, unknown>,
fields: matchedBlock.fields,
locale,
path: `${path ? `${path}.` : ''}${field.name}.${i}`,
rows,
})
}
})
}
})
}
if (Array.isArray(rowData)) {
rowData.forEach((row, i) => {
const matchedBlock = field.blocks.find((block) => block.slug === row.blockType)
if (matchedBlock) {
return traverseFields({
doc: row as Record<string, unknown>,
fields: matchedBlock.fields,
path: `${path ? `${path}.` : ''}${field.name}.${i}`,
rows,
})
}
})
}
break
}
case 'tabs': {
return field.tabs.forEach((tab) => {
if (tabHasName(tab)) {
const newDoc = doc?.[tab.name]
const newPath = `${path ? `${path}.` : ''}${tab.name}`
if (typeof newDoc === 'object' && newDoc !== null) {
if (tab.localized) {
Object.entries(newDoc).forEach(([locale, localeDoc]) => {
return traverseFields({
doc: localeDoc,
fields: tab.fields,
locale,
path: newPath,
rows,
})
})
} else {
return traverseFields({
doc: newDoc as Record<string, unknown>,
fields: tab.fields,
path: newPath,
rows,
})
}
}
} else {
traverseFields({
doc,
fields: tab.fields,
path,
rows,
})
}
})
}
case 'relationship':
case 'upload': {
if (typeof field.relationTo === 'string') {
if (field.type === 'upload' || !field.hasMany) {
const relationshipPath = `${path ? `${path}.` : ''}${field.name}`
if (field.localized) {
const matchedRelationshipsWithLocales = rows.filter(
(row) => row.path === relationshipPath,
)
if (matchedRelationshipsWithLocales.length && !doc[field.name]) {
doc[field.name] = {}
}
const newDoc = doc[field.name] as Record<string, unknown>
matchedRelationshipsWithLocales.forEach((localeRow) => {
if (typeof localeRow.locale === 'string') {
const [, id] = Object.entries(localeRow).find(
([key, val]) =>
val !== null && !['id', 'locale', 'order', 'parent_id', 'path'].includes(key),
)
newDoc[localeRow.locale] = id
}
})
} else {
const matchedRelationship = rows.find((row) => {
const matchesPath = row.path === relationshipPath
if (locale) return matchesPath && locale === row.locale
return row.path === relationshipPath
})
if (matchedRelationship) {
const [, id] = Object.entries(matchedRelationship).find(
([key, val]) =>
val !== null && !['id', 'locale', 'order', 'parent_id', 'path'].includes(key),
)
doc[field.name] = id
}
}
}
}
}
}
})
}

View File

@@ -0,0 +1,74 @@
export type Groups =
| 'addColumn'
| 'addConstraint'
| 'dropColumn'
| 'dropConstraint'
| 'dropTable'
| 'notNull'
/**
* Convert an "ADD COLUMN" statement to an "ALTER COLUMN" statement
* example: ALTER TABLE "pages_blocks_my_block" ADD COLUMN "person_id" integer NOT NULL;
* to: ALTER TABLE "pages_blocks_my_block" ALTER COLUMN "person_id" SET NOT NULL;
* @param sql
*/
function convertAddColumnToAlterColumn(sql) {
// Regular expression to match the ADD COLUMN statement with its constraints
const regex = /ALTER TABLE ("[^"]+") ADD COLUMN ("[^"]+") [\w\s]+ NOT NULL;/
// Replace the matched part with "ALTER COLUMN ... SET NOT NULL;"
return sql.replace(regex, 'ALTER TABLE $1 ALTER COLUMN $2 SET NOT NULL;')
}
export const groupUpSQLStatements = (list: string[]): Record<Groups, string[]> => {
const groups = {
addColumn: 'ADD COLUMN',
// example: ALTER TABLE "posts" ADD COLUMN "category_id" integer
addConstraint: 'ADD CONSTRAINT',
//example:
// DO $$ BEGIN
// ALTER TABLE "pages_blocks_my_block" ADD CONSTRAINT "pages_blocks_my_block_person_id_users_id_fk" FOREIGN KEY ("person_id") REFERENCES "users"("id") ON DELETE cascade ON UPDATE no action;
// EXCEPTION
// WHEN duplicate_object THEN null;
// END $$;
dropColumn: 'DROP COLUMN',
// example: ALTER TABLE "_posts_v_rels" DROP COLUMN IF EXISTS "posts_id";
dropConstraint: 'DROP CONSTRAINT',
// example: ALTER TABLE "_posts_v_rels" DROP CONSTRAINT "_posts_v_rels_posts_fk";
dropTable: 'DROP TABLE',
// example: DROP TABLE "pages_rels";
notNull: 'NOT NULL',
// example: ALTER TABLE "pages_blocks_my_block" ALTER COLUMN "person_id" SET NOT NULL;
}
const result = Object.keys(groups).reduce((result, group: Groups) => {
result[group] = []
return result
}, {}) as Record<Groups, string[]>
for (const line of list) {
Object.entries(groups).some(([key, value]) => {
if (line.endsWith('NOT NULL;')) {
// split up the ADD COLUMN and ALTER COLUMN NOT NULL statements
// example: ALTER TABLE "pages_blocks_my_block" ADD COLUMN "person_id" integer NOT NULL;
// becomes two separate statements:
// 1. ALTER TABLE "pages_blocks_my_block" ADD COLUMN "person_id" integer;
// 2. ALTER TABLE "pages_blocks_my_block" ALTER COLUMN "person_id" SET NOT NULL;
result.addColumn.push(line.replace(' NOT NULL;', ';'))
result.notNull.push(convertAddColumnToAlterColumn(line))
return true
}
if (line.includes(value)) {
result[key].push(line)
return true
}
})
}
return result
}

View File

@@ -0,0 +1,278 @@
import type { TransactionPg } from '@payloadcms/drizzle/types'
import type { DrizzleSnapshotJSON } from 'drizzle-kit/api'
import type { Payload, PayloadRequest } from 'payload'
import { sql } from 'drizzle-orm'
import fs from 'fs'
import { createRequire } from 'module'
import { buildVersionCollectionFields, buildVersionGlobalFields } from 'payload'
import toSnakeCase from 'to-snake-case'
import type { VercelPostgresAdapter } from '../../types.js'
import type { PathsToQuery } from './types.js'
import { groupUpSQLStatements } from './groupUpSQLStatements.js'
import { migrateRelationships } from './migrateRelationships.js'
import { traverseFields } from './traverseFields.js'
const require = createRequire(import.meta.url)
type Args = {
debug?: boolean
payload: Payload
req?: Partial<PayloadRequest>
}
/**
* Moves upload and relationship columns from the join table and into the tables while moving data
* This is done in the following order:
* ADD COLUMNs
* -- manipulate data to move relationships to new columns
* ADD CONSTRAINTs
* NOT NULLs
* DROP TABLEs
* DROP CONSTRAINTs
* DROP COLUMNs
* @param debug
* @param payload
* @param req
*/
export const migratePostgresV2toV3 = async ({ debug, payload, req }: Args) => {
const adapter = payload.db as unknown as VercelPostgresAdapter
const db = adapter.sessions[await req.transactionID].db as TransactionPg
const dir = payload.db.migrationDir
// get the drizzle migrateUpSQL from drizzle using the last schema
const { generateDrizzleJson, generateMigration } = require('drizzle-kit/api')
const drizzleJsonAfter = generateDrizzleJson(adapter.schema)
// Get the previous migration snapshot
const previousSnapshot = fs
.readdirSync(dir)
.filter((file) => file.endsWith('.json') && !file.endsWith('relationships_v2_v3.json'))
.sort()
.reverse()?.[0]
if (!previousSnapshot) {
throw new Error(
`No previous migration schema file found! A prior migration from v2 is required to migrate to v3.`,
)
}
const drizzleJsonBefore = JSON.parse(
fs.readFileSync(`${dir}/${previousSnapshot}`, 'utf8'),
) as DrizzleSnapshotJSON
const generatedSQL = await generateMigration(drizzleJsonBefore, drizzleJsonAfter)
if (!generatedSQL.length) {
payload.logger.info(`No schema changes needed.`)
process.exit(0)
}
const sqlUpStatements = groupUpSQLStatements(generatedSQL)
const addColumnsStatement = sqlUpStatements.addColumn.join('\n')
if (debug) {
payload.logger.info('CREATING NEW RELATIONSHIP COLUMNS')
payload.logger.info(addColumnsStatement)
}
await db.execute(sql.raw(addColumnsStatement))
for (const collection of payload.config.collections) {
const tableName = adapter.tableNameMap.get(toSnakeCase(collection.slug))
const pathsToQuery: PathsToQuery = new Set()
traverseFields({
adapter,
collectionSlug: collection.slug,
columnPrefix: '',
db,
disableNotNull: false,
fields: collection.fields,
isVersions: false,
newTableName: tableName,
parentTableName: tableName,
path: '',
pathsToQuery,
payload,
rootTableName: tableName,
})
await migrateRelationships({
adapter,
collectionSlug: collection.slug,
db,
debug,
fields: collection.fields,
isVersions: false,
pathsToQuery,
payload,
req,
tableName,
})
if (collection.versions) {
const versionsTableName = adapter.tableNameMap.get(
`_${toSnakeCase(collection.slug)}${adapter.versionsSuffix}`,
)
const versionFields = buildVersionCollectionFields(collection)
const versionPathsToQuery: PathsToQuery = new Set()
traverseFields({
adapter,
collectionSlug: collection.slug,
columnPrefix: '',
db,
disableNotNull: true,
fields: versionFields,
isVersions: true,
newTableName: versionsTableName,
parentTableName: versionsTableName,
path: '',
pathsToQuery: versionPathsToQuery,
payload,
rootTableName: versionsTableName,
})
await migrateRelationships({
adapter,
collectionSlug: collection.slug,
db,
debug,
fields: versionFields,
isVersions: true,
pathsToQuery: versionPathsToQuery,
payload,
req,
tableName: versionsTableName,
})
}
}
for (const global of payload.config.globals) {
const tableName = adapter.tableNameMap.get(toSnakeCase(global.slug))
const pathsToQuery: PathsToQuery = new Set()
traverseFields({
adapter,
columnPrefix: '',
db,
disableNotNull: false,
fields: global.fields,
globalSlug: global.slug,
isVersions: false,
newTableName: tableName,
parentTableName: tableName,
path: '',
pathsToQuery,
payload,
rootTableName: tableName,
})
await migrateRelationships({
adapter,
db,
debug,
fields: global.fields,
globalSlug: global.slug,
isVersions: false,
pathsToQuery,
payload,
req,
tableName,
})
if (global.versions) {
const versionsTableName = adapter.tableNameMap.get(
`_${toSnakeCase(global.slug)}${adapter.versionsSuffix}`,
)
const versionFields = buildVersionGlobalFields(global)
const versionPathsToQuery: PathsToQuery = new Set()
traverseFields({
adapter,
columnPrefix: '',
db,
disableNotNull: true,
fields: versionFields,
globalSlug: global.slug,
isVersions: true,
newTableName: versionsTableName,
parentTableName: versionsTableName,
path: '',
pathsToQuery: versionPathsToQuery,
payload,
rootTableName: versionsTableName,
})
await migrateRelationships({
adapter,
db,
debug,
fields: versionFields,
globalSlug: global.slug,
isVersions: true,
pathsToQuery: versionPathsToQuery,
payload,
req,
tableName: versionsTableName,
})
}
}
// ADD CONSTRAINT
const addConstraintsStatement = sqlUpStatements.addConstraint.join('\n')
if (debug) {
payload.logger.info('ADDING CONSTRAINTS')
payload.logger.info(addConstraintsStatement)
}
await db.execute(sql.raw(addConstraintsStatement))
// NOT NULL
const notNullStatements = sqlUpStatements.notNull.join('\n')
if (debug) {
payload.logger.info('NOT NULL CONSTRAINTS')
payload.logger.info(notNullStatements)
}
await db.execute(sql.raw(notNullStatements))
// DROP TABLE
const dropTablesStatement = sqlUpStatements.dropTable.join('\n')
if (debug) {
payload.logger.info('DROPPING TABLES')
payload.logger.info(dropTablesStatement)
}
await db.execute(sql.raw(dropTablesStatement))
// DROP CONSTRAINT
const dropConstraintsStatement = sqlUpStatements.dropConstraint.join('\n')
if (debug) {
payload.logger.info('DROPPING CONSTRAINTS')
payload.logger.info(dropConstraintsStatement)
}
await db.execute(sql.raw(dropConstraintsStatement))
// DROP COLUMN
const dropColumnsStatement = sqlUpStatements.dropColumn.join('\n')
if (debug) {
payload.logger.info('DROPPING COLUMNS')
payload.logger.info(dropColumnsStatement)
}
await db.execute(sql.raw(dropColumnsStatement))
}

View File

@@ -0,0 +1,103 @@
import type { TransactionPg } from '@payloadcms/drizzle/types'
import type { Field, Payload, PayloadRequest } from 'payload'
import { sql } from 'drizzle-orm'
import type { VercelPostgresAdapter } from '../../types.js'
import type { DocsToResave, PathsToQuery } from './types.js'
import { fetchAndResave } from './fetchAndResave/index.js'
type Args = {
adapter: VercelPostgresAdapter
collectionSlug?: string
db: TransactionPg
debug: boolean
fields: Field[]
globalSlug?: string
isVersions: boolean
pathsToQuery: PathsToQuery
payload: Payload
req?: Partial<PayloadRequest>
tableName: string
}
export const migrateRelationships = async ({
adapter,
collectionSlug,
db,
debug,
fields,
globalSlug,
isVersions,
pathsToQuery,
payload,
req,
tableName,
}: Args) => {
if (pathsToQuery.size === 0) return
let offset = 0
let paginationResult
const where = Array.from(pathsToQuery).reduce((statement, path, i) => {
return (statement += `
"${tableName}${adapter.relationshipsSuffix}"."path" LIKE '${path}'${pathsToQuery.size !== i + 1 ? ' OR' : ''}
`)
}, '')
while (typeof paginationResult === 'undefined' || paginationResult.rows.length > 0) {
const paginationStatement = `SELECT DISTINCT parent_id FROM ${tableName}${adapter.relationshipsSuffix} WHERE
${where} ORDER BY parent_id LIMIT 500 OFFSET ${offset * 500};
`
paginationResult = await adapter.drizzle.execute(sql.raw(`${paginationStatement}`))
if (paginationResult.rows.length === 0) return
offset += 1
const statement = `SELECT * FROM ${tableName}${adapter.relationshipsSuffix} WHERE
(${where}) AND parent_id IN (${paginationResult.rows.map((row) => row.parent_id).join(', ')});
`
if (debug) {
payload.logger.info('FINDING ROWS TO MIGRATE')
payload.logger.info(statement)
}
const result = await adapter.drizzle.execute(sql.raw(`${statement}`))
const docsToResave: DocsToResave = {}
result.rows.forEach((row) => {
const parentID = row.parent_id
if (typeof parentID === 'string' || typeof parentID === 'number') {
if (!docsToResave[parentID]) docsToResave[parentID] = []
docsToResave[parentID].push(row)
}
})
await fetchAndResave({
adapter,
collectionSlug,
db,
debug,
docsToResave,
fields,
globalSlug,
isVersions,
payload,
req: req as unknown as PayloadRequest,
tableName,
})
}
const deleteStatement = `DELETE FROM ${tableName}${adapter.relationshipsSuffix} WHERE ${where}`
if (debug) {
payload.logger.info('DELETING ROWS')
payload.logger.info(deleteStatement)
}
await db.execute(sql.raw(`${deleteStatement}`))
}

View File

@@ -0,0 +1,117 @@
import type { TransactionPg } from '@payloadcms/drizzle/types'
import type { Field, Payload } from 'payload'
import { tabHasName } from 'payload/shared'
import toSnakeCase from 'to-snake-case'
import type { VercelPostgresAdapter } from '../../types.js'
import type { PathsToQuery } from './types.js'
type Args = {
adapter: VercelPostgresAdapter
collectionSlug?: string
columnPrefix: string
db: TransactionPg
disableNotNull: boolean
fields: Field[]
globalSlug?: string
isVersions: boolean
newTableName: string
parentTableName: string
path: string
pathsToQuery: PathsToQuery
payload: Payload
rootTableName: string
}
export const traverseFields = (args: Args) => {
args.fields.forEach((field) => {
switch (field.type) {
case 'group': {
let newTableName = `${args.newTableName}_${toSnakeCase(field.name)}`
if (field.localized && args.payload.config.localization) {
newTableName += args.adapter.localesSuffix
}
return traverseFields({
...args,
columnPrefix: `${args.columnPrefix}${toSnakeCase(field.name)}_`,
fields: field.fields,
newTableName,
path: `${args.path ? `${args.path}.` : ''}${field.name}`,
})
}
case 'row':
case 'collapsible': {
return traverseFields({
...args,
fields: field.fields,
})
}
case 'array': {
const newTableName = args.adapter.tableNameMap.get(
`${args.newTableName}_${toSnakeCase(field.name)}`,
)
return traverseFields({
...args,
columnPrefix: '',
fields: field.fields,
newTableName,
parentTableName: newTableName,
path: `${args.path ? `${args.path}.` : ''}${field.name}.%`,
})
}
case 'blocks': {
return field.blocks.forEach((block) => {
const newTableName = args.adapter.tableNameMap.get(
`${args.rootTableName}_blocks_${toSnakeCase(block.slug)}`,
)
traverseFields({
...args,
columnPrefix: '',
fields: block.fields,
newTableName,
parentTableName: newTableName,
path: `${args.path ? `${args.path}.` : ''}${field.name}.%`,
})
})
}
case 'tabs': {
return field.tabs.forEach((tab) => {
if (tabHasName(tab)) {
args.columnPrefix = `${args.columnPrefix}_${toSnakeCase(tab.name)}_`
args.path = `${args.path ? `${args.path}.` : ''}${tab.name}`
args.newTableName = `${args.newTableName}_${toSnakeCase(tab.name)}`
if (tab.localized && args.payload.config.localization) {
args.newTableName += args.adapter.localesSuffix
}
}
traverseFields({
...args,
fields: tab.fields,
})
})
}
case 'relationship':
case 'upload': {
if (typeof field.relationTo === 'string') {
if (field.type === 'upload' || !field.hasMany) {
args.pathsToQuery.add(`${args.path ? `${args.path}.` : ''}${field.name}`)
}
}
return null
}
}
})
}

View File

@@ -0,0 +1,9 @@
/**
* Set of all paths which should be moved
* This will be built up into one WHERE query
*/
export type PathsToQuery = Set<string>
export type DocsToResave = {
[id: number | string]: Record<string, unknown>[]
}

View File

@@ -0,0 +1,78 @@
import type {
BasePostgresAdapter,
GenericEnum,
MigrateDownArgs,
MigrateUpArgs,
PostgresDB,
} from '@payloadcms/drizzle/postgres'
import type { DrizzleAdapter } from '@payloadcms/drizzle/types'
import type { VercelPool, VercelPostgresPoolConfig } from '@vercel/postgres'
import type { DrizzleConfig } from 'drizzle-orm'
import type { PgSchema, PgTableFn, PgTransactionConfig } from 'drizzle-orm/pg-core'
export type Args = {
connectionString?: string
idType?: 'serial' | 'uuid'
localesSuffix?: string
logger?: DrizzleConfig['logger']
migrationDir?: string
/**
* Optional pool configuration for Vercel Postgres
* If not provided, vercel/postgres will attempt to use the Vercel environment variables
*/
pool?: VercelPostgresPoolConfig
prodMigrations?: {
down: (args: MigrateDownArgs) => Promise<void>
name: string
up: (args: MigrateUpArgs) => Promise<void>
}[]
push?: boolean
relationshipsSuffix?: string
/**
* The schema name to use for the database
* @experimental This only works when there are not other tables or enums of the same name in the database under a different schema. Awaiting fix from Drizzle.
*/
schemaName?: string
transactionOptions?: PgTransactionConfig | false
versionsSuffix?: string
}
export type VercelPostgresAdapter = {
pool?: VercelPool
poolOptions?: Args['pool']
} & BasePostgresAdapter
declare module 'payload' {
export interface DatabaseAdapter
extends Omit<Args, 'idType' | 'logger' | 'migrationDir' | 'pool'>,
DrizzleAdapter {
beginTransaction: (options?: PgTransactionConfig) => Promise<null | number | string>
drizzle: PostgresDB
enums: Record<string, GenericEnum>
/**
* An object keyed on each table, with a key value pair where the constraint name is the key, followed by the dot-notation field name
* Used for returning properly formed errors from unique fields
*/
fieldConstraints: Record<string, Record<string, string>>
idType: Args['idType']
initializing: Promise<void>
localesSuffix?: string
logger: DrizzleConfig['logger']
pgSchema?: { table: PgTableFn } | PgSchema
pool: VercelPool
poolOptions: Args['pool']
prodMigrations?: {
down: (args: MigrateDownArgs) => Promise<void>
name: string
up: (args: MigrateUpArgs) => Promise<void>
}[]
push: boolean
rejectInitializing: () => void
relationshipsSuffix?: string
resolveInitializing: () => void
schema: Record<string, unknown>
schemaName?: Args['schemaName']
tableNameMap: Map<string, string>
versionsSuffix?: string
}
}

View File

@@ -0,0 +1,38 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"composite": true,
"noEmit": false,
"emitDeclarationOnly": true,
"outDir": "./dist",
"rootDir": "./src",
"skipLibCheck": true,
},
"exclude": [
"dist",
"build",
"tests",
"test",
"node_modules",
"eslint.config.js",
"src/**/*.spec.js",
"src/**/*.spec.jsx",
"src/**/*.spec.ts",
"src/**/*.spec.tsx"
],
"include": [
"src",
"src/**/*.ts",
],
"references": [
{
"path": "../payload"
},
{
"path": "../translations"
},
{
"path": "../drizzle"
}
]
}

View File

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

View File

@@ -1,4 +1,3 @@
/* eslint-disable no-param-reassign */
import type { Field } from 'payload'
import { fieldAffectsData, tabHasName } from 'payload/shared'
@@ -34,8 +33,9 @@ export const traverseFields = ({
// handle simple relationship
if (
depth > 0 &&
(field.type === 'upload' ||
(field.type === 'relationship' && !field.hasMany && typeof field.relationTo === 'string'))
(field.type === 'upload' || field.type === 'relationship') &&
!field.hasMany &&
typeof field.relationTo === 'string'
) {
if (field.localized) {
_locales.with[`${path}${field.name}`] = true

View File

@@ -1,4 +1,3 @@
import type { DrizzleSnapshotJSON } from 'drizzle-kit/api'
import type { CreateMigration } from 'payload'
import fs from 'fs'
@@ -112,6 +111,7 @@ export const createMigration: CreateMigration = async function createMigration(
getMigrationTemplate({
downSQL: downSQL || ` // Migration code`,
imports,
packageName: payload.db.packageName,
upSQL: upSQL || ` // Migration code`,
}),
)

View File

@@ -9,8 +9,9 @@ export const indent = (text: string) =>
export const getMigrationTemplate = ({
downSQL,
imports,
packageName,
upSQL,
}: MigrationTemplateArgs): string => `import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
}: MigrationTemplateArgs): string => `import { MigrateUpArgs, MigrateDownArgs, sql } from '${packageName}'
${imports ? `${imports}\n` : ''}
export async function up({ payload, req }: MigrateUpArgs): Promise<void> {
${indent(upSQL)}

View File

@@ -726,7 +726,7 @@ export const traverseFields = ({
case 'upload':
if (Array.isArray(field.relationTo)) {
field.relationTo.forEach((relation) => relationships.add(relation))
} else if (field.type === 'relationship' && field.hasMany) {
} else if (field.hasMany) {
relationships.add(field.relationTo)
} else {
// simple relationships get a column on the targetTable with a foreign key to the relationTo table

View File

@@ -111,8 +111,6 @@ export type BasePostgresAdapter = {
logger: DrizzleConfig['logger']
operators: Operators
pgSchema?: Schema
// pool: Pool
// poolOptions: Args['pool']
prodMigrations?: {
down: (args: MigrateDownArgs) => Promise<void>
name: string

View File

@@ -445,7 +445,7 @@ export const getTableColumnFromPath = ({
case 'relationship':
case 'upload': {
const newCollectionPath = pathSegments.slice(1).join('.')
if (Array.isArray(field.relationTo) || (field.type === 'relationship' && field.hasMany)) {
if (Array.isArray(field.relationTo) || field.hasMany) {
let relationshipFields
const relationTableName = `${rootTableName}${adapter.relationshipsSuffix}`
const {

View File

@@ -351,6 +351,7 @@ export const upsertRow = async <T extends Record<string, unknown> | TypeWithID>(
throw error.code === '23505'
? new ValidationError(
{
id,
errors: [
{
field: adapter.fieldConstraints[tableName][error.constraint],

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env node --no-deprecation
#!/usr/bin/env node
import path from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/graphql",
"version": "3.0.0-beta.88",
"version": "3.0.0-beta.90",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",

View File

@@ -307,10 +307,51 @@ export function buildMutationInputType({
...inputObjectTypeConfig,
[field.name]: { type: withNullableType(field, GraphQLString, forceNullable) },
}),
upload: (inputObjectTypeConfig: InputObjectTypeConfig, field: UploadField) => ({
...inputObjectTypeConfig,
[field.name]: { type: withNullableType(field, GraphQLString, forceNullable) },
}),
upload: (inputObjectTypeConfig: InputObjectTypeConfig, field: UploadField) => {
const { relationTo } = field
type PayloadGraphQLRelationshipType =
| GraphQLInputObjectType
| GraphQLList<GraphQLScalarType>
| GraphQLScalarType
let type: PayloadGraphQLRelationshipType
if (Array.isArray(relationTo)) {
const fullName = `${combineParentName(
parentName,
toWords(field.name, true),
)}RelationshipInput`
type = new GraphQLInputObjectType({
name: fullName,
fields: {
relationTo: {
type: new GraphQLEnumType({
name: `${fullName}RelationTo`,
values: relationTo.reduce(
(values, option) => ({
...values,
[formatName(option)]: {
value: option,
},
}),
{},
),
}),
},
value: { type: GraphQLJSON },
},
})
} else {
type = getCollectionIDType(
config.db.defaultIDType,
graphqlResult.collections[relationTo].config,
)
}
return {
...inputObjectTypeConfig,
[field.name]: { type: field.hasMany ? new GraphQLList(type) : type },
}
},
}
const fieldName = formatName(name)

View File

@@ -594,49 +594,164 @@ export function buildObjectType({
}),
upload: (objectTypeConfig: ObjectTypeConfig, field: UploadField) => {
const { relationTo } = field
const isRelatedToManyCollections = Array.isArray(relationTo)
const hasManyValues = field.hasMany
const relationshipName = combineParentName(parentName, toWords(field.name, true))
const uploadName = combineParentName(parentName, toWords(field.name, true))
let type
let relationToType = null
if (Array.isArray(relationTo)) {
relationToType = new GraphQLEnumType({
name: `${relationshipName}_RelationTo`,
values: relationTo.reduce(
(relations, relation) => ({
...relations,
[formatName(relation)]: {
value: relation,
},
}),
{},
),
})
const types = relationTo.map((relation) => graphqlResult.collections[relation].graphQL.type)
type = new GraphQLObjectType({
name: `${relationshipName}_Relationship`,
fields: {
relationTo: {
type: relationToType,
},
value: {
type: new GraphQLUnionType({
name: relationshipName,
resolveType(data, { req }) {
return graphqlResult.collections[data.collection].graphQL.type.name
},
types,
}),
},
},
})
} else {
;({ type } = graphqlResult.collections[relationTo].graphQL)
}
// If the relationshipType is undefined at this point,
// it can be assumed that this blockType can have a relationship
// to itself. Therefore, we set the relationshipType equal to the blockType
// that is currently being created.
const type = withNullableType(
field,
graphqlResult.collections[relationTo].graphQL.type || newlyCreatedBlockType,
forceNullable,
type = type || newlyCreatedBlockType
const relationshipArgs: {
draft?: unknown
fallbackLocale?: unknown
limit?: unknown
locale?: unknown
page?: unknown
where?: unknown
} = {}
const relationsUseDrafts = (Array.isArray(relationTo) ? relationTo : [relationTo]).some(
(relation) => graphqlResult.collections[relation].config.versions?.drafts,
)
const uploadArgs = {} as LocaleInputType
if (relationsUseDrafts) {
relationshipArgs.draft = {
type: GraphQLBoolean,
}
}
if (config.localization) {
uploadArgs.locale = {
relationshipArgs.locale = {
type: graphqlResult.types.localeInputType,
}
uploadArgs.fallbackLocale = {
relationshipArgs.fallbackLocale = {
type: graphqlResult.types.fallbackLocaleInputType,
}
}
const relatedCollectionSlug = field.relationTo
const upload = {
type,
args: uploadArgs,
extensions: { complexity: 20 },
const relationship = {
type: withNullableType(
field,
hasManyValues ? new GraphQLList(new GraphQLNonNull(type)) : type,
forceNullable,
),
args: relationshipArgs,
extensions: { complexity: 10 },
async resolve(parent, args, context: Context) {
const value = parent[field.name]
const locale = args.locale || context.req.locale
const fallbackLocale = args.fallbackLocale || context.req.fallbackLocale
const id = value
let relatedCollectionSlug = field.relationTo
const draft = Boolean(args.draft ?? context.req.query?.draft)
if (hasManyValues) {
const results = []
const resultPromises = []
const createPopulationPromise = async (relatedDoc, i) => {
let id = relatedDoc
let collectionSlug = field.relationTo
if (isRelatedToManyCollections) {
collectionSlug = relatedDoc.relationTo
id = relatedDoc.value
}
const result = await context.req.payloadDataLoader.load(
createDataloaderCacheKey({
collectionSlug: collectionSlug as string,
currentDepth: 0,
depth: 0,
docID: id,
draft,
fallbackLocale,
locale,
overrideAccess: false,
showHiddenFields: false,
transactionID: context.req.transactionID,
}),
)
if (result) {
if (isRelatedToManyCollections) {
results[i] = {
relationTo: collectionSlug,
value: {
...result,
collection: collectionSlug,
},
}
} else {
results[i] = result
}
}
}
if (value) {
value.forEach((relatedDoc, i) => {
resultPromises.push(createPopulationPromise(relatedDoc, i))
})
}
await Promise.all(resultPromises)
return results
}
let id = value
if (isRelatedToManyCollections && value) {
id = value.value
relatedCollectionSlug = value.relationTo
}
if (id) {
const relatedDocument = await context.req.payloadDataLoader.load(
createDataloaderCacheKey({
collectionSlug: relatedCollectionSlug,
collectionSlug: relatedCollectionSlug as string,
currentDepth: 0,
depth: 0,
docID: id,
@@ -649,26 +764,30 @@ export function buildObjectType({
}),
)
return relatedDocument || null
if (relatedDocument) {
if (isRelatedToManyCollections) {
return {
relationTo: relatedCollectionSlug,
value: {
...relatedDocument,
collection: relatedCollectionSlug,
},
}
}
return relatedDocument
}
return null
}
return null
},
}
const whereFields = graphqlResult.collections[relationTo].config.fields
upload.args.where = {
type: buildWhereInputType({
name: uploadName,
fields: whereFields,
parentName: uploadName,
}),
}
return {
...objectTypeConfig,
[field.name]: upload,
[field.name]: relationship,
}
},
}

View File

@@ -130,9 +130,36 @@ const fieldToSchemaMap = ({ nestedFieldName, parentName }: Args): any => ({
textarea: (field: TextareaField) => ({
type: withOperators(field, parentName),
}),
upload: (field: UploadField) => ({
type: withOperators(field, parentName),
}),
upload: (field: UploadField) => {
if (Array.isArray(field.relationTo)) {
return {
type: new GraphQLInputObjectType({
name: `${combineParentName(parentName, field.name)}_Relation`,
fields: {
relationTo: {
type: new GraphQLEnumType({
name: `${combineParentName(parentName, field.name)}_Relation_RelationTo`,
values: field.relationTo.reduce(
(values, relation) => ({
...values,
[formatName(relation)]: {
value: relation,
},
}),
{},
),
}),
},
value: { type: GraphQLJSON },
},
}),
}
}
return {
type: withOperators(field, parentName),
}
},
})
export default fieldToSchemaMap

View File

@@ -230,9 +230,9 @@ const defaults: DefaultsType = {
},
upload: {
operators: [
...operators.equality.map((operator) => ({
...[...operators.equality, ...operators.contains].map((operator) => ({
name: operator,
type: GraphQLString,
type: GraphQLJSON,
})),
],
},

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/next",
"version": "3.0.0-beta.88",
"version": "3.0.0-beta.90",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",

View File

@@ -11,21 +11,13 @@
}
}
&__header {
display: flex;
align-items: flex-end;
flex-wrap: wrap;
gap: base(0.8);
h1 {
margin: 0;
}
.list-header {
a {
text-decoration: none;
}
.pill {
.btn--withoutPopup,
.btn--withPopup {
position: relative;
margin: 0 0 base(0.2);
}

View File

@@ -4,15 +4,17 @@ import type { ClientCollectionConfig } from 'payload'
import { getTranslation } from '@payloadcms/translations'
import {
BulkUploadDrawer,
Button,
DeleteMany,
EditMany,
Gutter,
ListControls,
ListHeader,
ListSelection,
Pagination,
PerPage,
Pill,
PopupList,
PublishMany,
RelationshipProvider,
RenderComponent,
@@ -22,10 +24,13 @@ import {
Table,
UnpublishMany,
ViewDescription,
bulkUploadDrawerSlug,
useConfig,
useEditDepth,
useListInfo,
useListQuery,
useModal,
useRouteCache,
useSearchParams,
useStepNav,
useTranslation,
@@ -41,9 +46,20 @@ const baseClass = 'collection-list'
const Link = (LinkImport.default || LinkImport) as unknown as typeof LinkImport.default
export const DefaultListView: React.FC = () => {
const { Header, collectionSlug, hasCreatePermission, newDocumentURL } = useListInfo()
const {
Header,
beforeActions,
collectionSlug,
disableBulkDelete,
disableBulkEdit,
hasCreatePermission,
newDocumentURL,
} = useListInfo()
const { data, defaultLimit, handlePageChange, handlePerPageChange } = useListQuery()
const { searchParams } = useSearchParams()
const { openModal } = useModal()
const { clearRouteCache } = useRouteCache()
const { getEntityConfig } = useConfig()
@@ -67,7 +83,7 @@ export const DefaultListView: React.FC = () => {
labels,
} = collectionConfig
const { i18n } = useTranslation()
const { i18n, t } = useTranslation()
const drawerDepth = useEditDepth()
@@ -79,7 +95,9 @@ export const DefaultListView: React.FC = () => {
let docs = data.docs || []
if (collectionConfig.upload) {
const isUploadCollection = Boolean(collectionConfig.upload)
if (isUploadCollection) {
docs = docs?.map((doc) => {
return {
...doc,
@@ -104,35 +122,47 @@ export const DefaultListView: React.FC = () => {
<RenderComponent mappedComponent={beforeList} />
<SelectionProvider docs={data.docs} totalDocs={data.totalDocs}>
<Gutter className={`${baseClass}__wrap`}>
<header className={`${baseClass}__header`}>
{Header || (
<Fragment>
<h1>{getTranslation(labels?.plural, i18n)}</h1>
{hasCreatePermission && (
<Pill
aria-label={i18n.t('general:createNewLabel', {
label: getTranslation(labels?.singular, i18n),
})}
to={newDocumentURL}
>
{i18n.t('general:createNew')}
</Pill>
)}
{!smallBreak && (
<ListSelection label={getTranslation(collectionConfig.labels.plural, i18n)} />
)}
{description && (
<div className={`${baseClass}__sub-header`}>
<RenderComponent
Component={ViewDescription}
clientProps={{ description }}
mappedComponent={Description}
/>
</div>
)}
</Fragment>
)}
</header>
{Header || (
<ListHeader heading={getTranslation(labels?.plural, i18n)}>
{hasCreatePermission && (
<Button
Link={Link}
SubMenuPopupContent={
isUploadCollection && collectionConfig.upload.bulkUpload ? (
<PopupList.ButtonGroup>
<PopupList.Button onClick={() => openModal(bulkUploadDrawerSlug)}>
{t('upload:bulkUpload')}
</PopupList.Button>
</PopupList.ButtonGroup>
) : null
}
aria-label={i18n.t('general:createNewLabel', {
label: getTranslation(labels?.singular, i18n),
})}
buttonStyle="pill"
el="link"
size="small"
to={newDocumentURL}
>
{i18n.t('general:createNew')}
</Button>
)}
{!smallBreak && (
<ListSelection label={getTranslation(collectionConfig.labels.plural, i18n)} />
)}
{(description || Description) && (
<div className={`${baseClass}__sub-header`}>
<ViewDescription Description={Description} description={description} />
</div>
)}
{isUploadCollection && collectionConfig.upload.bulkUpload ? (
<BulkUploadDrawer
collectionSlug={collectionSlug}
onSuccess={() => clearRouteCache()}
/>
) : null}
</ListHeader>
)}
<ListControls collectionConfig={collectionConfig} fields={fields} />
<RenderComponent mappedComponent={beforeListTable} />
{!data.docs && (
@@ -174,7 +204,7 @@ export const DefaultListView: React.FC = () => {
limit={data.limit}
nextPage={data.nextPage}
numberOfNeighbors={1}
onChange={handlePageChange}
onChange={(page) => void handlePageChange(page)}
page={data.page}
prevPage={data.prevPage}
totalPages={data.totalPages}
@@ -189,7 +219,7 @@ export const DefaultListView: React.FC = () => {
{i18n.t('general:of')} {data.totalDocs}
</div>
<PerPage
handleChange={handlePerPageChange}
handleChange={(limit) => void handlePerPageChange(limit)}
limit={
isNumber(searchParams?.limit) ? Number(searchParams.limit) : defaultLimit
}
@@ -200,10 +230,15 @@ export const DefaultListView: React.FC = () => {
<div className={`${baseClass}__list-selection`}>
<ListSelection label={getTranslation(collectionConfig.labels.plural, i18n)} />
<div className={`${baseClass}__list-selection-actions`}>
<EditMany collection={collectionConfig} fields={fields} />
<PublishMany collection={collectionConfig} />
<UnpublishMany collection={collectionConfig} />
<DeleteMany collection={collectionConfig} />
{beforeActions && beforeActions}
{!disableBulkEdit && (
<Fragment>
<EditMany collection={collectionConfig} fields={fields} />
<PublishMany collection={collectionConfig} />
<UnpublishMany collection={collectionConfig} />
</Fragment>
)}
{!disableBulkDelete && <DeleteMany collection={collectionConfig} />}
</div>
</div>
)}

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env -S node --no-deprecation
#!/usr/bin/env node
import path from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'

View File

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

View File

@@ -85,6 +85,7 @@ export async function generateImportMap(
if (shouldLog) {
console.log('Generating import map')
}
const importMap: InternalImportMap = {}
const imports: Imports = {}

View File

@@ -33,8 +33,8 @@ export function iterateCollections({
addToImportMap(collection.admin?.components?.afterListTable)
addToImportMap(collection.admin?.components?.beforeList)
addToImportMap(collection.admin?.components?.beforeListTable)
addToImportMap(collection.admin?.components?.Description)
addToImportMap(collection.admin?.components?.edit?.Description)
addToImportMap(collection.admin?.components?.edit?.PreviewButton)
addToImportMap(collection.admin?.components?.edit?.PublishButton)
addToImportMap(collection.admin?.components?.edit?.SaveButton)

View File

@@ -24,7 +24,6 @@ export type ClientCollectionConfig = {
beforeList: MappedComponent[]
beforeListTable: MappedComponent[]
edit: {
Description: MappedComponent
PreviewButton: MappedComponent
PublishButton: MappedComponent
SaveButton: MappedComponent

View File

@@ -120,6 +120,7 @@ export const sanitizeCollection = async (
// disable duplicate for uploads by default
sanitized.disableDuplicate = sanitized.disableDuplicate || true
sanitized.upload.bulkUpload = sanitized.upload?.bulkUpload ?? true
sanitized.upload.staticDir = sanitized.upload.staticDir || sanitized.slug
sanitized.admin.useAsTitle =
sanitized.admin.useAsTitle && sanitized.admin.useAsTitle !== 'id'

View File

@@ -250,6 +250,7 @@ export type CollectionAdminOptions = {
* Custom admin components
*/
components?: {
Description?: EntityDescriptionComponent
afterList?: CustomComponent[]
afterListTable?: CustomComponent[]
beforeList?: CustomComponent[]
@@ -258,8 +259,6 @@ export type CollectionAdminOptions = {
* Components within the edit view
*/
edit?: {
Description?: EntityDescriptionComponent
/**
* Replaces the "Preview" button
*/

View File

@@ -105,7 +105,10 @@ export async function validateSearchParam({
fieldPath = path.slice(0, -(req.locale.length + 1))
}
// remove ".value" from ends of polymorphic relationship paths
if (field.type === 'relationship' && Array.isArray(field.relationTo)) {
if (
(field.type === 'relationship' || field.type === 'upload') &&
Array.isArray(field.relationTo)
) {
fieldPath = fieldPath.replace('.value', '')
}
const entityType: 'collections' | 'globals' = globalConfig ? 'globals' : 'collections'

View File

@@ -100,6 +100,13 @@ export interface BaseDatabaseAdapter {
* The name of the database adapter
*/
name: string
/**
* Full package name of the database adapter
*
* @example @payloadcms/db-postgres
*/
packageName: string
/**
* reference to the instance of payload
*/
@@ -434,5 +441,6 @@ export type DBIdentifierName =
export type MigrationTemplateArgs = {
downSQL?: string
imports?: string
packageName?: string
upSQL?: string
}

View File

@@ -21,7 +21,12 @@ export class ValidationError extends APIError<{
global?: string
}> {
constructor(
results: { collection?: string; errors: ValidationFieldError[]; global?: string },
results: {
collection?: string
errors: ValidationFieldError[]
global?: string
id?: number | string
},
t?: TFunction,
) {
const message = t

View File

@@ -99,19 +99,26 @@ export const sanitizeFields = async ({
})
}
if (field.type === 'relationship') {
if (field.min && !field.minRows) {
console.warn(
`(payload): The "min" property is deprecated for the Relationship field "${field.name}" and will be removed in a future version. Please use "minRows" instead.`,
)
if (field.min && !field.minRows) {
console.warn(
`(payload): The "min" property is deprecated for the Relationship field "${field.name}" and will be removed in a future version. Please use "minRows" instead.`,
)
}
if (field.max && !field.maxRows) {
console.warn(
`(payload): The "max" property is deprecated for the Relationship field "${field.name}" and will be removed in a future version. Please use "maxRows" instead.`,
)
}
field.minRows = field.minRows || field.min
field.maxRows = field.maxRows || field.max
}
if (field.type === 'upload') {
if (!field.admin || !('isSortable' in field.admin)) {
field.admin = {
isSortable: true,
...field.admin,
}
if (field.max && !field.maxRows) {
console.warn(
`(payload): The "max" property is deprecated for the Relationship field "${field.name}" and will be removed in a future version. Please use "maxRows" instead.`,
)
}
field.minRows = field.minRows || field.min
field.maxRows = field.maxRows || field.max
}
}

View File

@@ -824,35 +824,106 @@ export type UIFieldClient = {
} & Omit<DeepUndefinable<FieldBaseClient>, '_isPresentational' | 'admin'> & // still include FieldBaseClient (even if it's undefinable) so that we don't need constant type checks (e.g. if('xy' in field))
Pick<UIField, 'label' | 'name' | 'type'>
export type UploadField = {
admin?: {
components?: {
Error?: CustomComponent<UploadFieldErrorClientComponent | UploadFieldErrorServerComponent>
Label?: CustomComponent<UploadFieldLabelClientComponent | UploadFieldLabelServerComponent>
} & Admin['components']
}
type SharedUploadProperties = {
/**
* Toggle the preview in the admin interface.
*/
displayPreview?: boolean
filterOptions?: FilterOptions
hasMany?: boolean
/**
* Sets a maximum population depth for this field, regardless of the remaining depth when this field is reached.
*
* {@link https://payloadcms.com/docs/getting-started/concepts#field-level-max-depth}
*/
maxDepth?: number
relationTo: CollectionSlug
type: 'upload'
validate?: Validate<unknown, unknown, unknown, UploadField>
} & FieldBase
validate?: Validate<unknown, unknown, unknown, SharedUploadProperties>
} & (
| {
hasMany: true
/**
* @deprecated Use 'maxRows' instead
*/
max?: number
maxRows?: number
/**
* @deprecated Use 'minRows' instead
*/
min?: number
minRows?: number
}
| {
hasMany?: false | undefined
/**
* @deprecated Use 'maxRows' instead
*/
max?: undefined
maxRows?: undefined
/**
* @deprecated Use 'minRows' instead
*/
min?: undefined
minRows?: undefined
}
) &
FieldBase
export type UploadFieldClient = {
type SharedUploadPropertiesClient = FieldBaseClient &
Pick<
SharedUploadProperties,
'hasMany' | 'max' | 'maxDepth' | 'maxRows' | 'min' | 'minRows' | 'type'
>
type UploadAdmin = {
allowCreate?: boolean
components?: {
Error?: CustomComponent<
RelationshipFieldErrorClientComponent | RelationshipFieldErrorServerComponent
>
Label?: CustomComponent<
RelationshipFieldLabelClientComponent | RelationshipFieldLabelServerComponent
>
} & Admin['components']
isSortable?: boolean
} & Admin
type UploadAdminClient = {
components?: {
Error?: MappedComponent
Label?: MappedComponent
} & AdminClient['components']
} & AdminClient &
Pick<UploadAdmin, 'allowCreate' | 'isSortable'>
export type PolymorphicUploadField = {
admin?: {
components?: {
Error?: MappedComponent
Label?: MappedComponent
} & AdminClient['components']
}
} & FieldBaseClient &
Pick<UploadField, 'displayPreview' | 'maxDepth' | 'relationTo' | 'type'>
sortOptions?: { [collectionSlug: CollectionSlug]: string }
} & UploadAdmin
relationTo: CollectionSlug[]
} & SharedUploadProperties
export type PolymorphicUploadFieldClient = {
admin?: {
sortOptions?: Pick<PolymorphicUploadField['admin'], 'sortOptions'>
} & UploadAdminClient
} & Pick<PolymorphicUploadField, 'displayPreview' | 'maxDepth' | 'relationTo' | 'type'> &
SharedUploadPropertiesClient
export type SingleUploadField = {
admin?: {
sortOptions?: string
} & UploadAdmin
relationTo: CollectionSlug
} & SharedUploadProperties
export type SingleUploadFieldClient = {
admin?: Pick<SingleUploadField['admin'], 'sortOptions'> & UploadAdminClient
} & Pick<SingleUploadField, 'displayPreview' | 'maxDepth' | 'relationTo' | 'type'> &
SharedUploadPropertiesClient
export type UploadField = /* PolymorphicUploadField | */ SingleUploadField
export type UploadFieldClient = /* PolymorphicUploadFieldClient | */ SingleUploadFieldClient
export type CodeField = {
admin?: {

View File

@@ -71,6 +71,7 @@ export const beforeChange = async <T extends JsonObject>({
if (errors.length > 0) {
throw new ValidationError(
{
id,
collection: collection?.slug,
errors,
global: global?.slug,

View File

@@ -138,7 +138,7 @@ export const promise = async <T>({
siblingData[field.name] === 'null' ||
siblingData[field.name] === null
) {
if (field.type === 'relationship' && field.hasMany === true) {
if (field.hasMany === true) {
siblingData[field.name] = []
} else {
siblingData[field.name] = null
@@ -153,32 +153,32 @@ export const promise = async <T>({
const relatedCollection = req.payload.config.collections.find(
(collection) => collection.slug === relatedDoc.relationTo,
)
if (relatedCollection?.fields) {
const relationshipIDField = relatedCollection.fields.find(
(collectionField) =>
fieldAffectsData(collectionField) && collectionField.name === 'id',
)
if (relationshipIDField?.type === 'number') {
siblingData[field.name][i] = {
...relatedDoc,
value: parseFloat(relatedDoc.value as string),
}
}
}
})
}
if (field.hasMany !== true && valueIsValueWithRelation(value)) {
const relatedCollection = req.payload.config.collections.find(
(collection) => collection.slug === value.relationTo,
)
if (relatedCollection?.fields) {
const relationshipIDField = relatedCollection.fields.find(
(collectionField) =>
fieldAffectsData(collectionField) && collectionField.name === 'id',
)
if (relationshipIDField?.type === 'number') {
siblingData[field.name][i] = {
...relatedDoc,
value: parseFloat(relatedDoc.value as string),
}
siblingData[field.name] = { ...value, value: parseFloat(value.value as string) }
}
})
}
if (
field.type === 'relationship' &&
field.hasMany !== true &&
valueIsValueWithRelation(value)
) {
const relatedCollection = req.payload.config.collections.find(
(collection) => collection.slug === value.relationTo,
)
const relationshipIDField = relatedCollection.fields.find(
(collectionField) =>
fieldAffectsData(collectionField) && collectionField.name === 'id',
)
if (relationshipIDField?.type === 'number') {
siblingData[field.name] = { ...value, value: parseFloat(value.value as string) }
}
}
} else {
@@ -187,25 +187,31 @@ export const promise = async <T>({
const relatedCollection = req.payload.config.collections.find(
(collection) => collection.slug === field.relationTo,
)
if (relatedCollection?.fields) {
const relationshipIDField = relatedCollection.fields.find(
(collectionField) =>
fieldAffectsData(collectionField) && collectionField.name === 'id',
)
if (relationshipIDField?.type === 'number') {
siblingData[field.name][i] = parseFloat(relatedDoc as string)
}
}
})
}
if (field.hasMany !== true && value) {
const relatedCollection = req.payload.config.collections.find(
(collection) => collection.slug === field.relationTo,
)
if (relatedCollection?.fields) {
const relationshipIDField = relatedCollection.fields.find(
(collectionField) =>
fieldAffectsData(collectionField) && collectionField.name === 'id',
)
if (relationshipIDField?.type === 'number') {
siblingData[field.name][i] = parseFloat(relatedDoc as string)
siblingData[field.name] = parseFloat(value as string)
}
})
}
if (field.type === 'relationship' && field.hasMany !== true && value) {
const relatedCollection = req.payload.config.collections.find(
(collection) => collection.slug === field.relationTo,
)
const relationshipIDField = relatedCollection.fields.find(
(collectionField) =>
fieldAffectsData(collectionField) && collectionField.name === 'id',
)
if (relationshipIDField?.type === 'number') {
siblingData[field.name] = parseFloat(value as string)
}
}
}

View File

@@ -571,18 +571,76 @@ const validateFilterOptions: Validate<
}
export type UploadFieldValidation = Validate<unknown, unknown, unknown, UploadField>
export const upload: UploadFieldValidation = (value: string, options) => {
if (!value && options.required) {
return options?.req?.t('validation:required')
export const upload: UploadFieldValidation = async (value, options) => {
const {
maxRows,
minRows,
relationTo,
req: { payload, t },
required,
} = options
if (
((!value && typeof value !== 'number') || (Array.isArray(value) && value.length === 0)) &&
required
) {
return t('validation:required')
}
if (Array.isArray(value) && value.length > 0) {
if (minRows && value.length < minRows) {
return t('validation:lessThanMin', {
label: t('general:rows'),
min: minRows,
value: value.length,
})
}
if (maxRows && value.length > maxRows) {
return t('validation:greaterThanMax', {
label: t('general:rows'),
max: maxRows,
value: value.length,
})
}
}
if (typeof value !== 'undefined' && value !== null) {
const idType =
options?.req?.payload?.collections[options.relationTo]?.customIDType ||
options?.req?.payload?.db?.defaultIDType
const values = Array.isArray(value) ? value : [value]
if (!isValidID(value, idType)) {
return options.req?.t('validation:validUploadID')
const invalidRelationships = values.filter((val) => {
let collectionSlug: string
let requestedID
if (typeof relationTo === 'string') {
collectionSlug = relationTo
// custom id
if (val || typeof val === 'number') {
requestedID = val
}
}
if (Array.isArray(relationTo) && typeof val === 'object' && val?.relationTo) {
collectionSlug = val.relationTo
requestedID = val.value
}
if (requestedID === null) return false
const idType =
payload.collections[collectionSlug]?.customIDType || payload?.db?.defaultIDType || 'text'
return !isValidID(requestedID, idType)
})
if (invalidRelationships.length > 0) {
return `This relationship field has the following invalid relationships: ${invalidRelationships
.map((err, invalid) => {
return `${err} ${JSON.stringify(invalid)}`
})
.join(', ')}`
}
}

View File

@@ -95,6 +95,11 @@ export type UploadConfig = {
* - A function that generates a fully qualified URL for the thumbnail, receives the doc as the only argument.
**/
adminThumbnail?: GetAdminThumbnail | string
/**
* Enables bulk upload of files from the list view.
* @default true
*/
bulkUpload?: boolean
/**
* Enables cropping of images.
* @default true

View File

@@ -291,6 +291,7 @@ export function fieldsToJSONSchema(
break
}
case 'upload':
case 'relationship': {
if (Array.isArray(field.relationTo)) {
if (field.hasMany) {
@@ -380,21 +381,6 @@ export function fieldsToJSONSchema(
break
}
case 'upload': {
fieldSchema = {
oneOf: [
{
type: collectionIDFieldTypes[field.relationTo],
},
{
$ref: `#/definitions/${field.relationTo}`,
},
],
}
if (!isRequired) fieldSchema.oneOf.push({ type: 'null' })
break
}
case 'blocks': {
// Check for a case where no blocks are provided.
// We need to generate an empty array for this case, note that JSON schema 4 doesn't support empty arrays

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-cloud-storage",
"version": "3.0.0-beta.88",
"version": "3.0.0-beta.90",
"description": "The official cloud storage plugin for Payload CMS",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-cloud",
"version": "3.0.0-beta.88",
"version": "3.0.0-beta.90",
"description": "The official Payload Cloud plugin",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-form-builder",
"version": "3.0.0-beta.88",
"version": "3.0.0-beta.90",
"description": "Form builder plugin for Payload CMS",
"keywords": [
"payload",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-nested-docs",
"version": "3.0.0-beta.88",
"version": "3.0.0-beta.90",
"description": "The official Nested Docs plugin for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-redirects",
"version": "3.0.0-beta.88",
"version": "3.0.0-beta.90",
"description": "Redirects plugin for Payload",
"keywords": [
"payload",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-relationship-object-ids",
"version": "3.0.0-beta.88",
"version": "3.0.0-beta.90",
"description": "A Payload plugin to store all relationship IDs as ObjectIDs",
"repository": {
"type": "git",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-search",
"version": "3.0.0-beta.88",
"version": "3.0.0-beta.90",
"description": "Search plugin for Payload",
"keywords": [
"payload",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-seo",
"version": "3.0.0-beta.88",
"version": "3.0.0-beta.90",
"description": "SEO plugin for Payload",
"keywords": [
"payload",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-stripe",
"version": "3.0.0-beta.88",
"version": "3.0.0-beta.90",
"description": "Stripe plugin for Payload",
"keywords": [
"payload",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/richtext-lexical",
"version": "3.0.0-beta.88",
"version": "3.0.0-beta.90",
"description": "The officially supported Lexical richtext adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -98,7 +98,12 @@
width: 100%;
overflow: hidden;
gap: calc(var(--base) * 0.375);
justify-content: space-between;
justify-content: flex-start;
pointer-events: none;
& > * {
pointer-events: all;
}
}
&__block-number {

View File

@@ -47,7 +47,7 @@ export function convertLexicalPluginNodesToLexical({
parentNodeType: string
quiet?: boolean
}): SerializedLexicalNode[] {
if (!lexicalPluginNodes?.length) {
if (!lexicalPluginNodes?.length || !converters?.length) {
return []
}
const unknownConverter = converters.find((converter) => converter.nodeTypes.includes('unknown'))

View File

@@ -13,6 +13,7 @@ export type LexicalPluginToLexicalFeatureProps = {
defaultConverters: LexicalPluginNodeConverter[]
}) => LexicalPluginNodeConverter[])
| LexicalPluginNodeConverter[]
disableHooks?: boolean
quiet?: boolean
}
@@ -37,23 +38,25 @@ export const LexicalPluginToLexicalFeature =
return {
ClientFeature: '@payloadcms/richtext-lexical/client#LexicalPluginToLexicalFeatureClient',
hooks: {
afterRead: [
({ value }) => {
if (!value || !('jsonContent' in value)) {
// incomingEditorState null or not from Lexical Plugin
return value
}
hooks: props.disableHooks
? undefined
: {
afterRead: [
({ value }) => {
if (!value || !('jsonContent' in value)) {
// incomingEditorState null or not from Lexical Plugin
return value
}
// Lexical Plugin => convert to lexical
return convertLexicalPluginToLexical({
converters: props.converters as LexicalPluginNodeConverter[],
lexicalPluginData: value as PayloadPluginLexicalData,
quiet: props?.quiet,
})
// Lexical Plugin => convert to lexical
return convertLexicalPluginToLexical({
converters: props.converters as LexicalPluginNodeConverter[],
lexicalPluginData: value as PayloadPluginLexicalData,
quiet: props?.quiet,
})
},
],
},
],
},
nodes: [
{
node: UnknownConvertedNode,

View File

@@ -47,7 +47,7 @@ export function convertSlateNodesToLexical({
parentNodeType: string
slateNodes: SlateNode[]
}): SerializedLexicalNode[] {
if (!converters?.length) {
if (!converters?.length || !slateNodes?.length) {
return []
}
const unknownConverter = converters.find((converter) => converter.nodeTypes.includes('unknown'))

View File

@@ -9,6 +9,7 @@ export type SlateToLexicalFeatureProps = {
converters?:
| (({ defaultConverters }: { defaultConverters: SlateNodeConverter[] }) => SlateNodeConverter[])
| SlateNodeConverter[]
disableHooks?: boolean
}
export const SlateToLexicalFeature = createServerFeature<
@@ -35,22 +36,24 @@ export const SlateToLexicalFeature = createServerFeature<
return {
ClientFeature: '@payloadcms/richtext-lexical/client#SlateToLexicalFeatureClient',
hooks: {
afterRead: [
({ value }) => {
if (!value || !Array.isArray(value) || 'root' in value) {
// incomingEditorState null or not from Slate
return value
}
hooks: props.disableHooks
? undefined
: {
afterRead: [
({ value }) => {
if (!value || !Array.isArray(value) || 'root' in value) {
// incomingEditorState null or not from Slate
return value
}
// Slate => convert to lexical
return convertSlateToLexical({
converters: props.converters as SlateNodeConverter[],
slateData: value,
})
// Slate => convert to lexical
return convertSlateToLexical({
converters: props.converters as SlateNodeConverter[],
slateData: value,
})
},
],
},
],
},
nodes: [
{
node: UnknownConvertedNode,

View File

@@ -64,12 +64,20 @@ async function migrateGlobal({
})
if (found) {
await payload.updateGlobal({
slug: global.slug,
data: document,
depth: 0,
locale: locale || undefined,
})
try {
await payload.updateGlobal({
slug: global.slug,
data: document,
depth: 0,
locale: locale || undefined,
})
// Catch it, because some errors were caused by the user previously (e.g. invalid relationships) and will throw an error now, even though they are not related to the migration
} catch (e) {
console.log('Error updating global', e, {
id: document.id,
slug: global.slug,
})
}
}
}
@@ -136,13 +144,21 @@ async function migrateCollection({
})
if (found) {
await payload.update({
id: document.id,
collection: collection.slug,
data: document,
depth: 0,
locale: locale || undefined,
})
try {
await payload.update({
id: document.id,
collection: collection.slug,
data: document,
depth: 0,
locale: locale || undefined,
})
// Catch it, because some errors were caused by the user previously (e.g. invalid relationships) and will throw an error now, even though they are not related to the migration
} catch (e) {
console.log('Error updating collection', e, {
id: document.id,
slug: collection.slug,
})
}
}
}
page++

View File

@@ -20,15 +20,24 @@ export const richTextValidateHOC = ({
} = options
if (required) {
const hasChildren = value?.root?.children?.length
const hasChildren = !!value?.root?.children?.length
const hasOnlyEmptyParagraph =
(value?.root?.children?.length === 1 &&
value?.root?.children[0]?.type === 'paragraph' &&
(value?.root?.children[0] as SerializedParagraphNode)?.children?.length === 0) ||
((value?.root?.children[0] as SerializedParagraphNode)?.children?.length === 1 &&
(value?.root?.children[0] as SerializedParagraphNode)?.children[0]?.type === 'text' &&
(value?.root?.children[0] as SerializedParagraphNode)?.children[0]?.['text'] === '')
let hasOnlyEmptyParagraph = false
if (value?.root?.children?.length === 1) {
if (value?.root?.children[0]?.type === 'paragraph') {
const paragraphNode = value?.root?.children[0] as SerializedParagraphNode
if (paragraphNode?.children?.length === 0) {
hasOnlyEmptyParagraph = true
} else if (paragraphNode?.children?.length === 1) {
const paragraphNodeChild = paragraphNode?.children[0]
if (paragraphNodeChild.type === 'text') {
if (!paragraphNodeChild?.['text']?.length) {
hasOnlyEmptyParagraph = true
}
}
}
}
}
if (!hasChildren || hasOnlyEmptyParagraph) {
return t('validation:required')

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/richtext-slate",
"version": "3.0.0-beta.88",
"version": "3.0.0-beta.90",
"description": "The officially supported Slate richtext adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/storage-azure",
"version": "3.0.0-beta.88",
"version": "3.0.0-beta.90",
"description": "Payload storage adapter for Azure Blob Storage",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/storage-gcs",
"version": "3.0.0-beta.88",
"version": "3.0.0-beta.90",
"description": "Payload storage adapter for Google Cloud Storage",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/storage-s3",
"version": "3.0.0-beta.88",
"version": "3.0.0-beta.90",
"description": "Payload storage adapter for Amazon S3",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/storage-uploadthing",
"version": "3.0.0-beta.88",
"version": "3.0.0-beta.90",
"description": "Payload storage adapter for uploadthing",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/storage-vercel-blob",
"version": "3.0.0-beta.88",
"version": "3.0.0-beta.90",
"description": "Payload storage adapter for Vercel Blob Storage",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/translations",
"version": "3.0.0-beta.88",
"version": "3.0.0-beta.90",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",

View File

@@ -188,6 +188,7 @@ export const clientTranslationKeys = createClientTranslationKeys([
'general:menu',
'general:moveDown',
'general:moveUp',
'general:next',
'general:noFiltersSet',
'general:noLabel',
'general:none',
@@ -204,6 +205,7 @@ export const clientTranslationKeys = createClientTranslationKeys([
'general:password',
'general:payloadSettings',
'general:perPage',
'general:previous',
'general:remove',
'general:reset',
'general:row',
@@ -239,6 +241,7 @@ export const clientTranslationKeys = createClientTranslationKeys([
'general:updatedCountSuccessfully',
'general:updatedSuccessfully',
'general:updating',
'general:uploading',
'general:welcome',
'operators:equals',
@@ -256,11 +259,15 @@ export const clientTranslationKeys = createClientTranslationKeys([
'operators:within',
'operators:intersects',
'upload:addFile',
'upload:addFiles',
'upload:bulkUpload',
'upload:crop',
'upload:cropToolDescription',
'upload:dragAndDrop',
'upload:addFile',
'upload:editImage',
'upload:fileToUpload',
'upload:filesToUpload',
'upload:focalPoint',
'upload:focalPointDescription',
'upload:height',

View File

@@ -244,6 +244,7 @@ export const arTranslations: DefaultTranslationsObject = {
moveDown: 'التّحريك إلى الأسفل',
moveUp: 'التّحريك إلى الأعلى',
newPassword: 'كلمة مرور جديدة',
next: 'التالي',
noFiltersSet: 'لم يتم تعيين أي عوامل تصفية',
noLabel: '<لا {{label}}>',
noOptions: 'لا خيارات',
@@ -261,6 +262,7 @@ export const arTranslations: DefaultTranslationsObject = {
password: 'كلمة المرور',
payloadSettings: 'الإعدادات',
perPage: 'لكلّ صفحة: {{limit}}',
previous: 'سابق',
remove: 'إزالة',
reset: 'إعادة تعيين',
row: 'سطر',
@@ -318,6 +320,8 @@ export const arTranslations: DefaultTranslationsObject = {
},
upload: {
addFile: 'إضافة ملف',
addFiles: 'أضف ملفات',
bulkUpload: 'تحميل بالجملة',
crop: 'محصول',
cropToolDescription: 'اسحب الزوايا المحددة للمنطقة، رسم منطقة جديدة أو قم بضبط القيم أدناه.',
dragAndDrop: 'قم بسحب وإسقاط ملفّ',
@@ -325,6 +329,8 @@ export const arTranslations: DefaultTranslationsObject = {
editImage: 'تعديل الصورة',
fileName: 'اسم الملفّ',
fileSize: 'حجم الملفّ',
fileToUpload: 'ملف للتحميل',
filesToUpload: 'ملفات للتحميل',
focalPoint: 'نقطة التركيز',
focalPointDescription: 'اسحب النقطة المركزية مباشرة على المعاينة أو قم بضبط القيم أدناه.',
height: 'الطّول',

View File

@@ -246,6 +246,7 @@ export const azTranslations: DefaultTranslationsObject = {
moveDown: 'Aşağı hərəkət et',
moveUp: 'Yuxarı hərəkət et',
newPassword: 'Yeni şifrə',
next: 'Növbəti',
noFiltersSet: 'Filter təyin edilməyib',
noLabel: '<Heç bir {{label}}>',
noOptions: 'Heç bir seçim yoxdur',
@@ -263,6 +264,7 @@ export const azTranslations: DefaultTranslationsObject = {
password: 'Şifrə',
payloadSettings: 'Payload Parametrləri',
perPage: 'Hər səhifədə: {{limit}}',
previous: 'Əvvəlki',
remove: 'Sil',
reset: 'Yenidən başlat',
row: 'Sətir',
@@ -321,6 +323,8 @@ export const azTranslations: DefaultTranslationsObject = {
},
upload: {
addFile: 'Fayl əlavə et',
addFiles: 'Faylları Əlavə Edin',
bulkUpload: 'Kütləvi Yükləmə',
crop: 'Məhsul',
cropToolDescription:
'Seçilmiş sahənin köşələrini sürükləyin, yeni bir sahə çəkin və ya aşağıdakı dəyərləri düzəltin.',
@@ -329,6 +333,8 @@ export const azTranslations: DefaultTranslationsObject = {
editImage: 'Şəkili Redaktə Et',
fileName: 'Faylın Adı',
fileSize: 'Faylım Ölçüsü',
fileToUpload: 'Yükləmək üçün Fayl',
filesToUpload: 'Yükləmək üçün fayllar',
focalPoint: 'Mərkəzi Nöqtə',
focalPointDescription:
'Fokus nöqtəsini birbaşa önizləməyə sürükləyin və ya aşağıdakı dəyərləri düzəltin.',

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