Merge branch 'main' into feat/next-poc
This commit is contained in:
50
.github/CODEOWNERS
vendored
Normal file
50
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
# Order matters. The last matching pattern takes precedence.
|
||||
|
||||
### Catch-all ###
|
||||
* @denolfe @jmikrut @DanRibbens
|
||||
.* @denolfe @jmikrut @DanRibbens
|
||||
|
||||
### Core ###
|
||||
/packages/payload/ @denolfe @jmikrut @DanRibbens
|
||||
/packages/payload/src/uploads/ @denolfe
|
||||
/packages/payload/src/admin/ @jmikrut @jacobsfletch @JarrodMFlesch
|
||||
|
||||
### Adapters ###
|
||||
/packages/bundler-*/ @denolfe @jmikrut @DanRibbens @JarrodMFlesch
|
||||
/packages/db-*/ @denolfe @jmikrut @DanRibbens
|
||||
/packages/richtext-*/ @denolfe @jmikrut @DanRibbens @AlessioGr
|
||||
|
||||
### Plugins ###
|
||||
/packages/plugin-*/ @denolfe @jmikrut @DanRibbens @jacobsfletch @JarrodMFlesch @AlessioGr
|
||||
/packages/plugin-cloud*/ @denolfe
|
||||
/packages/plugin-form-builder/ @jacobsfletch
|
||||
/packages/plugin-live-preview*/ @jacobsfletch
|
||||
/packages/plugin-nested-docs/ @jacobsfletch
|
||||
/packages/plugin-password-protection/ @jmikrut
|
||||
/packages/plugin-redirects/ @jacobsfletch
|
||||
/packages/plugin-search/ @jacobsfletch
|
||||
/packages/plugin-sentry/ @JessChowdhury
|
||||
/packages/plugin-seo/ @jacobsfletch
|
||||
/packages/plugin-stripe/ @jacobsfletch
|
||||
/packages/plugin-zapier/ @JarrodMFlesch
|
||||
|
||||
### Examples ###
|
||||
/examples/ @jacobsfletch
|
||||
/examples/testing/ @JarrodMFlesch
|
||||
/examples/email/ @JessChowdhury
|
||||
/examples/whitelabel/ @JessChowdhury
|
||||
|
||||
### Templates ###
|
||||
/templates/ @jacobsfletch
|
||||
/templates/blank/ @denolfe
|
||||
|
||||
### Misc ###
|
||||
/packages/create-payload-app/ @denolfe
|
||||
/packages/eslint-config-payload/ @denolfe
|
||||
/packages/payload-admin-bar/ @jacobsfletch
|
||||
|
||||
### Root ###
|
||||
/package.json @denolfe
|
||||
/scripts/ @denolfe
|
||||
/.github/ @denolfe
|
||||
/.github/CODEOWNERS @denolfe
|
||||
4
.github/PULL_REQUEST_TEMPLATE.md
vendored
4
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -12,8 +12,8 @@
|
||||
- [ ] Bug fix (non-breaking change which fixes an issue)
|
||||
- [ ] New feature (non-breaking change which adds functionality)
|
||||
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
||||
- [ ] Change to the [templates](../templates/) directory (does not affect core functionality)
|
||||
- [ ] Change to the [examples](../examples/) directory (does not affect core functionality)
|
||||
- [ ] Change to the [templates](https://github.com/payloadcms/payload/tree/main/templates) directory (does not affect core functionality)
|
||||
- [ ] Change to the [examples](https://github.com/payloadcms/payload/tree/main/examples) directory (does not affect core functionality)
|
||||
- [ ] This change requires a documentation update
|
||||
|
||||
## Checklist:
|
||||
|
||||
5
.github/workflows/main.yml
vendored
5
.github/workflows/main.yml
vendored
@@ -23,6 +23,7 @@ jobs:
|
||||
with:
|
||||
filters: |
|
||||
needs_build:
|
||||
- '.github/workflows/**'
|
||||
- 'packages/**'
|
||||
- 'test/**'
|
||||
- 'pnpm-lock.yaml'
|
||||
@@ -131,6 +132,7 @@ jobs:
|
||||
- name: Integration Tests
|
||||
run: pnpm test:int
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8096
|
||||
PAYLOAD_DATABASE: ${{ matrix.database }}
|
||||
POSTGRES_URL: ${{ env.POSTGRES_URL }}
|
||||
|
||||
@@ -140,7 +142,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
part: [1/4, 2/4, 3/4, 4/4]
|
||||
part: [1/8, 2/8, 3/8, 4/8, 5/8, 6/8, 7/8, 8/8]
|
||||
|
||||
steps:
|
||||
- name: Use Node.js 18
|
||||
@@ -253,6 +255,7 @@ jobs:
|
||||
- plugin-form-builder
|
||||
- plugin-nested-docs
|
||||
- plugin-search
|
||||
- plugin-sentry
|
||||
|
||||
steps:
|
||||
- name: Use Node.js 18
|
||||
|
||||
14
.vscode/launch.json
vendored
14
.vscode/launch.json
vendored
@@ -47,6 +47,13 @@
|
||||
"request": "launch",
|
||||
"type": "node-terminal"
|
||||
},
|
||||
{
|
||||
"command": "pnpm run dev uploads",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"name": "Run Dev Uploads",
|
||||
"request": "launch",
|
||||
"type": "node-terminal"
|
||||
},
|
||||
{
|
||||
"command": "PAYLOAD_BUNDLER=vite pnpm run dev fields",
|
||||
"cwd": "${workspaceFolder}",
|
||||
@@ -57,6 +64,13 @@
|
||||
"NODE_ENV": "production"
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "pnpm run test:int live-preview",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"name": "Live Preview Integration",
|
||||
"request": "launch",
|
||||
"type": "node-terminal"
|
||||
},
|
||||
{
|
||||
"command": "ts-node ./packages/payload/src/bin/index.ts build",
|
||||
"env": {
|
||||
|
||||
177
CHANGELOG.md
177
CHANGELOG.md
@@ -1,3 +1,180 @@
|
||||
## [2.3.1](https://github.com/payloadcms/payload/compare/v2.3.0...v2.3.1) (2023-12-01)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* ensure doc controls are not hidden behind lexical field ([#4345](https://github.com/payloadcms/payload/issues/4345)) ([bea79fe](https://github.com/payloadcms/payload/commit/bea79feaeaee18bf94dd04262f134483f1468494))
|
||||
* query validation on relationship fields ([#4353](https://github.com/payloadcms/payload/issues/4353)) ([fe888b5](https://github.com/payloadcms/payload/commit/fe888b5f6ceaa3969eac759cbdfb109b106dae05))
|
||||
* **richtext-lexical:** blocks content may be hidden behind components outside of the editor ([#4325](https://github.com/payloadcms/payload/issues/4325)) ([3e745e9](https://github.com/payloadcms/payload/commit/3e745e91da620a00e3f0f91892ee3ec66ba72bc0))
|
||||
* **richtext-lexical:** Blocks node: incorrect conversion from v1 node to v2 node ([ef84a2c](https://github.com/payloadcms/payload/commit/ef84a2cfffbb1be52dd948e59eeec0ce324e9046))
|
||||
|
||||
## [2.3.0](https://github.com/payloadcms/payload/compare/v2.2.2...v2.3.0) (2023-11-30)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add serbian (latin and cyrillic) translations ([#4268](https://github.com/payloadcms/payload/issues/4268)) ([40c8909](https://github.com/payloadcms/payload/commit/40c8909ee0003d45a1afa4524ade557268d01067))
|
||||
* **db-mongodb:** search for migrations dir intelligently ([530c825](https://github.com/payloadcms/payload/commit/530c825f806708df8672e66c8e9c559c5e625e5e))
|
||||
* **db-postgres:** search for migrations dir intelligently ([308979f](https://github.com/payloadcms/payload/commit/308979f31d27979955a52f32be4ea33849b48f30))
|
||||
* **live-preview:** batches api requests ([#4315](https://github.com/payloadcms/payload/issues/4315)) ([d49bb43](https://github.com/payloadcms/payload/commit/d49bb4351f22f17f68477c3145594abbb60fab05))
|
||||
* relationship sortOptions property ([#4301](https://github.com/payloadcms/payload/issues/4301)) ([224cddd](https://github.com/payloadcms/payload/commit/224cddd04573eff578ea3fa9ea5419f28b66c613))
|
||||
* support migrations with js files ([2122242](https://github.com/payloadcms/payload/commit/21222421929ae19728c31bdccc84995ce3951224))
|
||||
* support OAuth 2.0 format Authorization: Bearer tokens in headers ([c1eb9d1](https://github.com/payloadcms/payload/commit/c1eb9d1727daf96375e73943882621127b78e593))
|
||||
* useDocumentEvents ([#4284](https://github.com/payloadcms/payload/issues/4284)) ([9bb7a88](https://github.com/payloadcms/payload/commit/9bb7a88526569a726de468de6b2010d52169ea77))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **db-postgres:** allow for nested block fields to be queried ([#4237](https://github.com/payloadcms/payload/issues/4237)) ([cd07873](https://github.com/payloadcms/payload/commit/cd07873fc544766b4aeeff873dfb8d6e3e97e9dc))
|
||||
* **db-postgres:** error saving nested arrays with versions ([#4302](https://github.com/payloadcms/payload/issues/4302)) ([3514bfb](https://github.com/payloadcms/payload/commit/3514bfbdaee99341ae739d03591cb63bd9415fe3))
|
||||
* duplicate documents with required localized fields ([#4236](https://github.com/payloadcms/payload/issues/4236)) ([9da9b1f](https://github.com/payloadcms/payload/commit/9da9b1fc5050d4f29bcf6dce2f22027834aaf698))
|
||||
* incorrect key property in Tabs field component ([#4311](https://github.com/payloadcms/payload/issues/4311)) ([3502ce7](https://github.com/payloadcms/payload/commit/3502ce720b3020eed5fc733884b525303faa4c15)), closes [#4282](https://github.com/payloadcms/payload/issues/4282)
|
||||
* **live-preview-react:** removes payload from peer deps ([7e1052f](https://github.com/payloadcms/payload/commit/7e1052fd98c88a4d68af08f98ccc8936edb8ebf6))
|
||||
* **live-preview:** compounds merge results ([#4306](https://github.com/payloadcms/payload/issues/4306)) ([381c158](https://github.com/payloadcms/payload/commit/381c158b0303b515164ae487b0ce7e555ae1a08d))
|
||||
* **live-preview:** property resets rte nodes ([#4313](https://github.com/payloadcms/payload/issues/4313)) ([66679fb](https://github.com/payloadcms/payload/commit/66679fbdd6f804bff8a58d9504c226c9fb8a57a0))
|
||||
* **live-preview:** re-populates externally updated relationships ([#4287](https://github.com/payloadcms/payload/issues/4287)) ([57fc211](https://github.com/payloadcms/payload/commit/57fc2116749059bc55161897cf139031926035ec))
|
||||
* **live-preview:** removes payload from peer deps ([b4af95f](https://github.com/payloadcms/payload/commit/b4af95f894b5f6614bace38ef79e7148e084bd3b))
|
||||
* properly exports useDocumentsEvents hook ([#4314](https://github.com/payloadcms/payload/issues/4314)) ([5420963](https://github.com/payloadcms/payload/commit/542096361f0c13aed9c6a7d971e2c47047d8e2d2))
|
||||
* properly sets tabs key in fieldSchemaToJSON ([#4317](https://github.com/payloadcms/payload/issues/4317)) ([9cc88bb](https://github.com/payloadcms/payload/commit/9cc88bb47443ecdf525f4c99d9f13d81c141c471))
|
||||
* **richtext-lexical:** HTML Converter field not working inside of tabs ([#4293](https://github.com/payloadcms/payload/issues/4293)) ([5bf6493](https://github.com/payloadcms/payload/commit/5bf64933b4b99a0ac8ef7d1d91d0165a16636a9f))
|
||||
* **richtext-lexical:** re-use payload population logic to fix population-related issues ([#4291](https://github.com/payloadcms/payload/issues/4291)) ([094d02c](https://github.com/payloadcms/payload/commit/094d02ce1d85106470a1a8c6ffe9050873f2e57a))
|
||||
* **richtext-slate:** add use client to top of tsx files importing from payload core ([#4312](https://github.com/payloadcms/payload/issues/4312)) ([d4f28b1](https://github.com/payloadcms/payload/commit/d4f28b16b4d42f224e9c5e4254f9ec55107a2f97))
|
||||
|
||||
|
||||
### BREAKING CHANGES
|
||||
|
||||
### ⚠️ @payloadcms/richtext-lexical
|
||||
|
||||
The `SlashMenuGroup` and `SlashMenuOption` classes have changed. If you have any custom lexical Features which are adding new slash menu entries, this will be a breaking change for you. If not, no action is required from your side.
|
||||
|
||||
Here are the breaking changes and how to migrate:
|
||||
|
||||
1. The `SlashMenuOption`'s first argument is now used as a `key` and not as a display name. Additionally, a new, optional `displayName` property is added which will serve as the display name. Make sure your `key` does not contain any spaces or special characters.
|
||||
2. The `title` property of `SlashMenuGroup` has been replaced by a new, mandatory `key` and an optional `displayName` property. To migrate, you will have to remove the `title` property and add a `key` property instead - make sure you do not use spaces or special characters in the `key`.
|
||||
3. Additionally, if you have custom styles targeting elements inside of slash or floating-select-toolbar menus, you will have to adjust those too, as the CSS classes changed
|
||||
|
||||
[This is an example of performing these updates](
|
||||
https://github.com/payloadcms/payload/pull/4257/files#diff-dc2e7f503dd7076dff1d810da7ec77b8fc6a9e41127df4a417dece1b6e1587a0L61)
|
||||
|
||||
## [2.2.2](https://github.com/payloadcms/payload/compare/v2.2.1...v2.2.2) (2023-11-27)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **i18n:** adds Korean translation ([#4258](https://github.com/payloadcms/payload/issues/4258)) ([1401718](https://github.com/payloadcms/payload/commit/1401718b3b549ce1454389a982474dbe159eb61f))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* number field validation ([#4233](https://github.com/payloadcms/payload/issues/4233)) ([19fcfc2](https://github.com/payloadcms/payload/commit/19fcfc27af2ecb68ff989dcaed19b7b7d041a322))
|
||||
* passes date options to the react-datepicker in filter UI, removes duplicate options from operators select ([#4225](https://github.com/payloadcms/payload/issues/4225)) ([3d2b62b](https://github.com/payloadcms/payload/commit/3d2b62b2100e36a54adc6a675257a4d671fdd469))
|
||||
* prevent json data getting reset when switching tabs ([#4123](https://github.com/payloadcms/payload/issues/4123)) ([1dcd3a2](https://github.com/payloadcms/payload/commit/1dcd3a27825ed9d276b997a66f84bb2c05e87955))
|
||||
* transactions broken within doc access ([443847e](https://github.com/payloadcms/payload/commit/443847ec716a3b87032d9d1904b6c90aadd47197))
|
||||
* typo in polish translations ([#4234](https://github.com/payloadcms/payload/issues/4234)) ([56a4692](https://github.com/payloadcms/payload/commit/56a469266207ef83053b0c9176d1be4fc26087e6))
|
||||
|
||||
## [2.2.1](https://github.com/payloadcms/payload/compare/v2.2.0...v2.2.1) (2023-11-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* make outputSchema optional on richtext config ([#4230](https://github.com/payloadcms/payload/issues/4230)) ([3a784a0](https://github.com/payloadcms/payload/commit/3a784a06cc6c42c96b8d6cf023d942e6661be7b5))
|
||||
|
||||
## [2.2.0](https://github.com/payloadcms/payload/compare/v2.1.1...v2.2.0) (2023-11-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* allow richtext adapters to control type generation, improve generated lexical types ([#4036](https://github.com/payloadcms/payload/issues/4036)) ([989c10e](https://github.com/payloadcms/payload/commit/989c10e0e0b36a8c34822263b19f5cb4b9ed6e72))
|
||||
* hide publish button based on permissions ([#4203](https://github.com/payloadcms/payload/issues/4203)) ([de02490](https://github.com/payloadcms/payload/commit/de02490231fbc8936973c1b81ac87add39878d8b))
|
||||
* **richtext-lexical:** Add new position: 'top' property for plugins ([eed4f43](https://github.com/payloadcms/payload/commit/eed4f4361cd012adf4e777820adbe7ad330ffef6))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* fully define the define property for esbuild string replacement ([#4099](https://github.com/payloadcms/payload/issues/4099)) ([e22b95b](https://github.com/payloadcms/payload/commit/e22b95bdf3b2911ae67a07a76ec109c76416ea56))
|
||||
* **i18n:** polish translations ([#4134](https://github.com/payloadcms/payload/issues/4134)) ([782e118](https://github.com/payloadcms/payload/commit/782e1185698abb2fff3556052fd16d2b725611b9))
|
||||
* improves live preview breakpoints and zoom options in dark mode ([#4090](https://github.com/payloadcms/payload/issues/4090)) ([b91711a](https://github.com/payloadcms/payload/commit/b91711a74ad9379ed820b6675060209626b1c2d0))
|
||||
* **plugin-nested-docs:** await populate breadcrumbs on resaveChildren ([#4226](https://github.com/payloadcms/payload/issues/4226)) ([4e41dd1](https://github.com/payloadcms/payload/commit/4e41dd1bf2706001fa03130adb1c69403795ac96))
|
||||
* rename tab button classname to prevent unintentional styling ([#4121](https://github.com/payloadcms/payload/issues/4121)) ([967eff1](https://github.com/payloadcms/payload/commit/967eff1aabcc9ba7f29573fc2706538d691edfdd))
|
||||
* **richtext-lexical:** add missing 'use client' to TestRecorder feature plugin ([fc26275](https://github.com/payloadcms/payload/commit/fc26275b7a85fd34f424f7693b8383ad4efe0121))
|
||||
* **richtext-lexical:** Blocks: Array row data is not removed ([#4209](https://github.com/payloadcms/payload/issues/4209)) ([0af9c4d](https://github.com/payloadcms/payload/commit/0af9c4d3985a6c46a071ef5ac28c8359cb320571))
|
||||
* **richtext-lexical:** Blocks: fields without fulfilled condition are now skipped for validation ([50fab90](https://github.com/payloadcms/payload/commit/50fab902bd7baa1702ae0d995b4f58c1f5fca374))
|
||||
* **richtext-lexical:** Blocks: make sure fields are wrapped in a uniquely-named group, change block node data format, fix react key error ([#3995](https://github.com/payloadcms/payload/issues/3995)) ([c068a87](https://github.com/payloadcms/payload/commit/c068a8784ec5780dbdca5416b25ba654afd05458))
|
||||
* **richtext-lexical:** Blocks: z-index issue, e.g. select field dropdown in blocks hidden behind blocks below, or slash menu inside nested editor hidden behind blocks below ([09f17f4](https://github.com/payloadcms/payload/commit/09f17f44508539cfcb8722f7f462ef40d9ed54fd))
|
||||
* **richtext-lexical:** Floating Select Toolbar: Buttons and Dropdown Buttons not clickable in nested editors ([615702b](https://github.com/payloadcms/payload/commit/615702b858e76994a174159cb69f034ef811e016)), closes [#4025](https://github.com/payloadcms/payload/issues/4025)
|
||||
* **richtext-lexical:** HTMLConverter: cannot find nested lexical fields ([#4103](https://github.com/payloadcms/payload/issues/4103)) ([a6d5f2e](https://github.com/payloadcms/payload/commit/a6d5f2e3dea178e1fbde90c0d6a5ce254a8db0d1)), closes [#4034](https://github.com/payloadcms/payload/issues/4034)
|
||||
* **richtext-lexical:** incorrect caret positioning when selecting second line of multi-line paragraph ([#4165](https://github.com/payloadcms/payload/issues/4165)) ([b210af4](https://github.com/payloadcms/payload/commit/b210af46968b77d96ffd6ef60adc3b8d8bdc9376))
|
||||
* **richtext-lexical:** make lexicalHTML() function work for globals ([dbfc835](https://github.com/payloadcms/payload/commit/dbfc83520ca8b5e55198a3c4b517ae3a80f9cac6))
|
||||
* **richtext-lexical:** nested editor may lose focus when writing ([#4139](https://github.com/payloadcms/payload/issues/4139)) ([859c2f4](https://github.com/payloadcms/payload/commit/859c2f4a6d299a42e572133502b3841a74a11002))
|
||||
* **richtext-lexical:** remove optional chaining after `this` as transpilers are not handling it well ([#4145](https://github.com/payloadcms/payload/issues/4145)) ([2c8d34d](https://github.com/payloadcms/payload/commit/2c8d34d2aadf2fcaf0655c0abef233f341d9945f))
|
||||
* **richtext-lexical:** visual bug after rearranging blocks ([a6b4860](https://github.com/payloadcms/payload/commit/a6b486007dc26195adc5d576d937e35471c2868f))
|
||||
* simplifies block/array/hasMany-number field validations ([#4052](https://github.com/payloadcms/payload/issues/4052)) ([803a37e](https://github.com/payloadcms/payload/commit/803a37eaa947397fa0a93b9f4f7d702c6b94ceaa))
|
||||
* synchronous transaction errors ([#4164](https://github.com/payloadcms/payload/issues/4164)) ([1510baf](https://github.com/payloadcms/payload/commit/1510baf46e33540c72784f2d3f98330a8ff90923))
|
||||
* thread locale through to access routes from admin panel ([#4183](https://github.com/payloadcms/payload/issues/4183)) ([05f3169](https://github.com/payloadcms/payload/commit/05f3169a75b3b62962e7fe7842fbb6df6699433d))
|
||||
* transactionID isolation for GraphQL ([#4095](https://github.com/payloadcms/payload/issues/4095)) ([195a952](https://github.com/payloadcms/payload/commit/195a952c4314e0d53fd579517035373b49d6ccae))
|
||||
* upload fit not accounted for when editing focal point or crop ([#4142](https://github.com/payloadcms/payload/issues/4142)) ([45e9a55](https://github.com/payloadcms/payload/commit/45e9a559bbb16b2171465c8a439044011cebf102))
|
||||
|
||||
## [2.1.1](https://github.com/payloadcms/payload/compare/v2.1.0...v2.1.1) (2023-11-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* conditionally hide dot menu in DocumentControls ([#4075](https://github.com/payloadcms/payload/issues/4075)) ([cef4cbb](https://github.com/payloadcms/payload/commit/cef4cbb0ee59e1b0b806808d79b402dce114755f))
|
||||
* disable editing option for svg image types ([#4071](https://github.com/payloadcms/payload/issues/4071)) ([949e265](https://github.com/payloadcms/payload/commit/949e265cd9c95b7d4063336dde86177008d54839))
|
||||
* fixes creation of related documents within a transaction if filterOptions is used ([#4087](https://github.com/payloadcms/payload/issues/4087)) ([acad288](https://github.com/payloadcms/payload/commit/acad2888cd9a13d5fb9e4c686b2267ea69454eaf))
|
||||
* hide empty image sizes from the preview drawer ([#3946](https://github.com/payloadcms/payload/issues/3946)) ([687f485](https://github.com/payloadcms/payload/commit/687f4850acf073df0a649ef6182bfc8387857173))
|
||||
* **live-preview:** ensures field schema exists before traversing fields ([#4074](https://github.com/payloadcms/payload/issues/4074)) ([7059a71](https://github.com/payloadcms/payload/commit/7059a71243a8f98dcc89af0bfe502247db9e4123))
|
||||
* **live-preview:** field recursion and relationship population ([#4045](https://github.com/payloadcms/payload/issues/4045)) ([2ad7340](https://github.com/payloadcms/payload/commit/2ad73401546ef6608fd67d1f00b537f149640d6a))
|
||||
* **live-preview:** properly handles apiRoute ([#4076](https://github.com/payloadcms/payload/issues/4076)) ([1f851f2](https://github.com/payloadcms/payload/commit/1f851f21b18c9a5076d9afc9a31abc7a97fcb0df))
|
||||
* **plugin-nested-docs:** sync write transaction errors ([#4084](https://github.com/payloadcms/payload/issues/4084)) ([47efd3b](https://github.com/payloadcms/payload/commit/47efd3b92e99594dd5b61f0017f4eb76e1d36eb7))
|
||||
* possible issue with access control not using req ([#4086](https://github.com/payloadcms/payload/issues/4086)) ([348a70c](https://github.com/payloadcms/payload/commit/348a70cc33409b0b48aff3acd2b94c2df5d88f3b))
|
||||
* **richtext-lexical:** Blocks: unnecessary saving node value when initially opening a document & new lexical tests ([#4059](https://github.com/payloadcms/payload/issues/4059)) ([fff377a](https://github.com/payloadcms/payload/commit/fff377ad22cce3b26142cde8f4125fcee95aa072))
|
||||
* **richtext-lexical:** floating select toolbar caret not positioned correctly if first line is selected ([#4062](https://github.com/payloadcms/payload/issues/4062)) ([c462df3](https://github.com/payloadcms/payload/commit/c462df38f65b155e131e6a7b46b2bb16cd090e45))
|
||||
|
||||
## [2.1.0](https://github.com/payloadcms/payload/compare/v2.0.15...v2.1.0) (2023-11-08)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add internationalization (i18n) to locales ([#4005](https://github.com/payloadcms/payload/issues/4005)) ([6a0a859](https://github.com/payloadcms/payload/commit/6a0a859563ed9e742260ea51a1839a1ef0f61fce))
|
||||
* Custom Error, Label, and before/after field components ([#3747](https://github.com/payloadcms/payload/issues/3747)) ([266c327](https://github.com/payloadcms/payload/commit/266c3274d03e4fd52c692eeef1ee9248dcf66189))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* error on graphql multiple queries ([#3985](https://github.com/payloadcms/payload/issues/3985)) ([57da3c9](https://github.com/payloadcms/payload/commit/57da3c99a7e4ce5d3d1e17315e3691815f363704))
|
||||
* focal and cropping issues, adds test ([#4039](https://github.com/payloadcms/payload/issues/4039)) ([acba5e4](https://github.com/payloadcms/payload/commit/acba5e482b7ddc6e3dc6ba9b7736022770d69a55))
|
||||
* handle invalid tokens in refresh token operation ([#3647](https://github.com/payloadcms/payload/issues/3647)) ([131d89c](https://github.com/payloadcms/payload/commit/131d89c3f50c237e1ab2d7cd32d7a8226a9f8ce3))
|
||||
* hasMany number and select fields unable to save within arrays ([#4047](https://github.com/payloadcms/payload/issues/4047)) ([182c57b](https://github.com/payloadcms/payload/commit/182c57b191010ce3dcf659f39c1dc2f7cf80662e))
|
||||
* injects array and block ids into fieldSchemaToJSON ([#4043](https://github.com/payloadcms/payload/issues/4043)) ([d068ef7](https://github.com/payloadcms/payload/commit/d068ef7e2483d49dc41bdd7735042ddcaa0a684c))
|
||||
* parse predefined migrations via file arg or name prefix ([#4001](https://github.com/payloadcms/payload/issues/4001)) ([eb42c03](https://github.com/payloadcms/payload/commit/eb42c031ef980558ed051d4163925aa28d6ab090))
|
||||
* polymorphic hasMany relationships missing in postgres admin ([#4053](https://github.com/payloadcms/payload/issues/4053)) ([7a9af44](https://github.com/payloadcms/payload/commit/7a9af4417a56c621f01195f9a2904b9adffaad7a))
|
||||
* resets list filter row when the filter on field is changed ([#3956](https://github.com/payloadcms/payload/issues/3956)) ([8d14c21](https://github.com/payloadcms/payload/commit/8d14c213c878a1afda2b3bf03431fed5aa2a44e3))
|
||||
* Update API Views ([b008b6c](https://github.com/payloadcms/payload/commit/b008b6c6463c9dc3d8e61eaa0a9210aa1a189442))
|
||||
* vite not replacing env vars correctly when building ([67b3baa](https://github.com/payloadcms/payload/commit/67b3baaa445a13246be8178d57eaeba92888bef1))
|
||||
|
||||
## [2.0.15](https://github.com/payloadcms/payload/compare/v2.0.14...v2.0.15) (2023-11-03)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* autosave updating data in unrelated docs ([b722f20](https://github.com/payloadcms/payload/commit/b722f202af39a1429298b700cac686ecbbd4b46b))
|
||||
* better error handling within parseCookies ([#3720](https://github.com/payloadcms/payload/issues/3720)) ([6b1b4ff](https://github.com/payloadcms/payload/commit/6b1b4ffd27cc9a84e22ef2f3a8e389e5b72d41bc))
|
||||
* block row removal w/ db-postgres adapter ([#3951](https://github.com/payloadcms/payload/issues/3951)) ([5ea88bb](https://github.com/payloadcms/payload/commit/5ea88bb47d9ed6457331ceab7d7c82b0face8311))
|
||||
* deeply merges view configs ([#3954](https://github.com/payloadcms/payload/issues/3954)) ([a5b2333](https://github.com/payloadcms/payload/commit/a5b2333140447b12dbafd68592108ac342af4ea7))
|
||||
* do not display field if read permission is false - admin panel ui ([#3949](https://github.com/payloadcms/payload/issues/3949)) ([cdc10be](https://github.com/payloadcms/payload/commit/cdc10be1a241c6a9ac09feab77bcd58d23ff3dd9))
|
||||
* ensures dataloader does not run requests in parallel ([4607dbf](https://github.com/payloadcms/payload/commit/4607dbf97694bc899e597e9c7df50b6c878874f5))
|
||||
* exclude files from dev bundle if aliased ([#3957](https://github.com/payloadcms/payload/issues/3957)) ([7966692](https://github.com/payloadcms/payload/commit/796669279afb8fe23723ce36e6e47a44b7088b09))
|
||||
* field paths being mutated if they ended with the req.locale ([#3936](https://github.com/payloadcms/payload/issues/3936)) ([36576f1](https://github.com/payloadcms/payload/commit/36576f152ace41edd8b353703db2598d04deae44))
|
||||
* findVersions pagination ([#3906](https://github.com/payloadcms/payload/issues/3906)) ([1f8f173](https://github.com/payloadcms/payload/commit/1f8f173741fd524c7c2f11cc104672854f625da9))
|
||||
* global autosave and relevant e2e test ([a9d96b1](https://github.com/payloadcms/payload/commit/a9d96b10376fe1a4731b2ddb4d26ce38e333d5cb))
|
||||
* **i18n:** polish translations ([#3934](https://github.com/payloadcms/payload/issues/3934)) ([e4881bb](https://github.com/payloadcms/payload/commit/e4881bb02f7c1e8f96d5b405c57e0fdc01a2e7fe))
|
||||
* passes correct data to buildStateFromSchema on account page ([#3984](https://github.com/payloadcms/payload/issues/3984)) ([c7a315a](https://github.com/payloadcms/payload/commit/c7a315a7d1075361a7ee432a449769397c12185e))
|
||||
* prevent sort from saving a new version in version list view ([#3944](https://github.com/payloadcms/payload/issues/3944)) ([900a9ea](https://github.com/payloadcms/payload/commit/900a9eafeb51b1e5130518d4f71034a2bf9e4c5b))
|
||||
* properly load temp files into buffer ([#3996](https://github.com/payloadcms/payload/issues/3996)) ([d1a0822](https://github.com/payloadcms/payload/commit/d1a0822f8044a3f65416f4fe608e91a4ceea6b56))
|
||||
* sort document tabs by order ([#3968](https://github.com/payloadcms/payload/issues/3968)) ([06cd52b](https://github.com/payloadcms/payload/commit/06cd52b622723503896af6262907d31b258d0a5e))
|
||||
* vertical alignment in step nav when using larger logos ([#3955](https://github.com/payloadcms/payload/issues/3955)) ([b6d9a20](https://github.com/payloadcms/payload/commit/b6d9a2021fafea594353329fd304553bf7f2d091))
|
||||
|
||||
## [2.0.14](https://github.com/payloadcms/payload/compare/v2.0.13...v2.0.14) (2023-10-30)
|
||||
|
||||
|
||||
|
||||
24
README.md
24
README.md
@@ -1,24 +1,14 @@
|
||||
<a href="https://payloadcms.com">
|
||||
<img width="100%" src="https://github.com/payloadcms/payload/blob/main/packages/payload/src/admin/assets/images/github-banner-alt.jpg?raw=true" alt="Payload headless CMS Admin panel built with React" />
|
||||
</a>
|
||||
<a href="https://payloadcms.com"><img width="100%" src="https://github.com/payloadcms/payload/blob/main/packages/payload/src/admin/assets/images/github-banner-alt.jpg?raw=true" alt="Payload headless CMS Admin panel built with React" /></a>
|
||||
<br />
|
||||
<br />
|
||||
<p align="left">
|
||||
<a href="https://github.com/payloadcms/payload/actions">
|
||||
<img alt="GitHub Workflow Status" src="https://img.shields.io/github/actions/workflow/status/payloadcms/payload/main.yml?style=flat-square">
|
||||
</a>
|
||||
<a href="https://github.com/payloadcms/payload/actions"><img alt="GitHub Workflow Status" src="https://img.shields.io/github/actions/workflow/status/payloadcms/payload/main.yml?style=flat-square"></a>
|
||||
|
||||
<a href="https://discord.gg/payload">
|
||||
<img alt="Discord" src="https://img.shields.io/discord/967097582721572934?label=Discord&color=7289da&style=flat-square" />
|
||||
</a>
|
||||
<a href="https://discord.gg/payload"><img alt="Discord" src="https://img.shields.io/discord/967097582721572934?label=Discord&color=7289da&style=flat-square" /></a>
|
||||
|
||||
<a href="https://www.npmjs.com/package/payload">
|
||||
<img alt="npm" src="https://img.shields.io/npm/v/payload?style=flat-square" />
|
||||
</a>
|
||||
<a href="https://www.npmjs.com/package/payload"><img alt="npm" src="https://img.shields.io/npm/v/payload?style=flat-square" /></a>
|
||||
|
||||
<a href="https://twitter.com/payloadcms">
|
||||
<img src="https://img.shields.io/badge/follow-payloadcms-1DA1F2?logo=twitter&style=flat-square" alt="Payload Twitter" />
|
||||
</a>
|
||||
<a href="https://twitter.com/payloadcms"><img src="https://img.shields.io/badge/follow-payloadcms-1DA1F2?logo=twitter&style=flat-square" alt="Payload Twitter" /></a>
|
||||
</p>
|
||||
<hr/>
|
||||
<h4>
|
||||
@@ -27,7 +17,7 @@
|
||||
<hr/>
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 🎉 <strong>Payload 2.0 is now available!<strong> Read more in the <a target="_blank" href="https://payloadcms.com/blog/payload-2-0" rel="dofollow"><strong>announcement post</strong></a>.
|
||||
> 🎉 <strong>Payload 2.0 is now available!</strong> Read more in the <a target="_blank" href="https://payloadcms.com/blog/payload-2-0" rel="dofollow"><strong>announcement post</strong></a>.
|
||||
|
||||
<h3>Benefits over a regular CMS</h3>
|
||||
<ul>
|
||||
@@ -51,7 +41,7 @@ Create a cloud account, connect your GitHub, and [deploy in minutes](https://pay
|
||||
Before beginning to work with Payload, make sure you have all of the [required software](https://payloadcms.com/docs/getting-started/installation).
|
||||
|
||||
```text
|
||||
npx create-payload-app
|
||||
npx create-payload-app@latest
|
||||
```
|
||||
|
||||
Alternatively, it only takes about five minutes to [create an app from scratch](https://payloadcms.com/docs/getting-started/installation#from-scratch).
|
||||
|
||||
@@ -96,7 +96,7 @@ To swap out any of these views, simply pass in your custom component to the `adm
|
||||
}
|
||||
```
|
||||
|
||||
For more granular control, pass a configuration object instead. Payload exposes all of the properties of `<Route />` component in [React Router v5](https://v5.reactrouter.com):
|
||||
For more granular control, pass a configuration object instead. Each view corresponds to its own `<Route />` component in [React Router v5](https://v5.reactrouter.com). Payload exposes all of the properties of React Router:
|
||||
|
||||
| Property | Description |
|
||||
| ------------------ | ---------------------------------------------------------------------------------------------------------------------------- |
|
||||
@@ -129,6 +129,12 @@ To add a _new_ view to the Admin Panel, simply add another key to the `views` ob
|
||||
}
|
||||
```
|
||||
|
||||
<Banner type="warning">
|
||||
<strong>Note:</strong>
|
||||
<br />
|
||||
Routes are cascading. This means that unless explicitly given the `exact` property, they will match on URLs that simply _start_ with the route's path. This is helpful when creating catch-all routes in your application. Alternatively, you could define your nested route _before_ your parent route.
|
||||
</Banner>
|
||||
|
||||
_For more examples regarding how to customize components, look at the following [examples](https://github.com/payloadcms/payload/tree/main/test/admin/components)._
|
||||
|
||||
For help on how to build your own custom view components, see [building a custom view component](#building-a-custom-view-component).
|
||||
@@ -426,6 +432,15 @@ All Payload fields support the ability to swap in your own React components. So,
|
||||
| **`Cell`** | Used in the `List` view's table to represent a table-based preview of the data stored in the field. [More](#cell-component) |
|
||||
| **`Field`** | Swap out the field itself within all `Edit` views. [More](#field-component) |
|
||||
|
||||
As an alternative to replacing the entire Field component, you may want to keep the majority of the default Field component and only swap components within. This allows you to replace the **`Label`** or **`Error`** within a field component or add additional components inside the field with **`beforeInput`** or **`afterInput`**. **`beforeInput`** and **`afterInput`** are allowed in any fields that don't contain other fields, except [UI](/docs/fields/ui) and [Rich Text](/docs/fields/rich-text).
|
||||
|
||||
| Component | Description |
|
||||
| ----------------- | --------------------------------------------------------------------------------------------------------------- |
|
||||
| **`Label`** | Override the default Label in the Field Component. [More](#label-component) |
|
||||
| **`Error`** | Override the default Label in the Field Component. [More](#error-component) |
|
||||
| **`beforeInput`** | An array of elements that will be added before `input`/`textarea` elements. [More](#afterinput-and-beforeinput) |
|
||||
| **`afterInput`** | An array of elements that will be added after `input`/`textarea` elements. [More](#afterinput-and-beforeinput) |
|
||||
|
||||
## Cell Component
|
||||
|
||||
These are the props that will be passed to your custom Cell to use in your own components.
|
||||
@@ -481,6 +496,101 @@ const CustomTextField: React.FC<Props> = ({ path }) => {
|
||||
components, including the <strong>useField</strong> hook, [click here](/docs/admin/hooks).
|
||||
</Banner>
|
||||
|
||||
## Label Component
|
||||
|
||||
These are the props that will be passed to your custom Label.
|
||||
|
||||
| Property | Description |
|
||||
| ---------------- | ---------------------------------------------------------------- |
|
||||
| **`htmlFor`** | Property used to set `for` attribute for label. |
|
||||
| **`label`** | Label value provided in field, it can be used with i18n. |
|
||||
| **`required`** | A boolean value that represents if the field is required or not. |
|
||||
|
||||
#### Example
|
||||
|
||||
```tsx
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { getTranslation } from 'payload/utilities/getTranslation'
|
||||
|
||||
type Props = {
|
||||
htmlFor?: string
|
||||
label?: Record<string, string> | false | string
|
||||
required?: boolean
|
||||
}
|
||||
|
||||
const CustomLabel: React.FC<Props> = (props) => {
|
||||
const { htmlFor, label, required = false } = props
|
||||
|
||||
const { i18n } = useTranslation()
|
||||
|
||||
if (label) {
|
||||
return (<span>
|
||||
{getTranslation(label, i18n)}
|
||||
{required && <span className="required">*</span>}
|
||||
</span>);
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
```
|
||||
|
||||
## Error Component
|
||||
|
||||
These are the props that will be passed to your custom Error.
|
||||
|
||||
| Property | Description |
|
||||
| ---------------- | ------------------------------------------------------------- |
|
||||
| **`message`** | The error message. |
|
||||
| **`showError`** | A boolean value that represents if the error should be shown. |
|
||||
|
||||
#### Example
|
||||
|
||||
```tsx
|
||||
import React from 'react'
|
||||
|
||||
type Props = {
|
||||
message: string
|
||||
showError?: boolean
|
||||
}
|
||||
|
||||
const CustomError: React.FC<Props> = (props) => {
|
||||
const { message, showError } = props
|
||||
|
||||
if (showError) {
|
||||
return <p style={{color: 'red'}}>{message}</p>
|
||||
} else return null;
|
||||
}
|
||||
```
|
||||
|
||||
## afterInput and beforeInput
|
||||
|
||||
With these properties you can add multiple components before and after the input element. For example, you can add an absolutely positioned button to clear the current field value.
|
||||
|
||||
#### Example
|
||||
|
||||
```tsx
|
||||
import React from 'react'
|
||||
import './style.scss'
|
||||
|
||||
const ClearButton: React.FC = () => {
|
||||
return <button onClick={() => {/* ... */}}>X</button>
|
||||
}
|
||||
|
||||
const fieldField: Field = {
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [ClearButton]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default titleField;
|
||||
```
|
||||
|
||||
## Custom providers
|
||||
|
||||
As your admin customizations gets more complex you may want to share state between fields or other components. You can add custom providers to do add your own context to any Payload app for use in other custom components within the admin panel. Within your config add `admin.components.providers`, these can be used to share context or provide other custom functionality. Read the [React context](https://reactjs.org/docs/context.html) docs to learn more.
|
||||
|
||||
@@ -172,20 +172,34 @@ export default buildConfig({
|
||||
collections: [Subscriptions],
|
||||
admin: {
|
||||
bundler: viteBundler(),
|
||||
vite: (config) => {
|
||||
return {
|
||||
...config,
|
||||
resolve: {
|
||||
...config.resolve,
|
||||
// highlight-start
|
||||
alias: {
|
||||
...config.resolve.alias,
|
||||
// remember, vite aliases are exact-match only
|
||||
'./hooks/createStripeSubscription': mockModulePath,
|
||||
},
|
||||
// highlight-end
|
||||
},
|
||||
vite: (incomingViteConfig) => {
|
||||
const existingAliases = incomingViteConfig?.resolve?.alias || {};
|
||||
let aliasArray: { find: string | RegExp; replacement: string; }[] = [];
|
||||
|
||||
// Pass the existing Vite aliases
|
||||
if (Array.isArray(existingAliases)) {
|
||||
aliasArray = existingAliases;
|
||||
} else {
|
||||
aliasArray = Object.values(existingAliases);
|
||||
}
|
||||
|
||||
|
||||
// highlight-start
|
||||
// Add your own aliases using the find and replacement keys
|
||||
// remember, vite aliases are exact-match only
|
||||
aliasArray.push({
|
||||
find: '../server-only-module',
|
||||
replacement: path.resolve(__dirname, './path/to/browser-safe-module.js')
|
||||
});
|
||||
// highlight-end
|
||||
|
||||
return {
|
||||
...incomingViteConfig,
|
||||
resolve: {
|
||||
...(incomingViteConfig?.resolve || {}),
|
||||
alias: aliasArray,
|
||||
}
|
||||
};
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -758,3 +758,59 @@ const MyComponent: React.FC = () => {
|
||||
### usePreferences
|
||||
|
||||
Returns methods to set and get user preferences. More info can be found [here](https://payloadcms.com/docs/admin/preferences).
|
||||
|
||||
### useTableColumns
|
||||
|
||||
Returns methods to manipulate table columns
|
||||
|
||||
```tsx
|
||||
import { useTableColumns } from 'payload/components/hooks'
|
||||
|
||||
const MyComponent: React.FC = () => {
|
||||
// highlight-start
|
||||
const { setActiveColumns } = useTableColumns()
|
||||
|
||||
const resetColumns = () => {
|
||||
setActiveColumns(['id', 'createdAt', 'updatedAt'])
|
||||
}
|
||||
// highlight-end
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetColumns}
|
||||
>
|
||||
Reset columns
|
||||
</button>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### useDocumentEvents
|
||||
|
||||
The `useDocumentEvents` hook provides a way of subscribing to cross-document events, such as updates made to nested documents within a drawer. This hook will report document events that are outside the scope of the document currently being edited. This hook provides the following:
|
||||
|
||||
| Property | Description |
|
||||
|---------------------------|-------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| **`mostRecentUpdate`** | An object containing the most recently updated document. It contains the `entitySlug`, `id` (if collection), and `updatedAt` properties |
|
||||
| **`reportUpdate`** | A method used to report updates to documents. It accepts the same arguments as the `mostRecentUpdate` property. |
|
||||
|
||||
**Example:**
|
||||
|
||||
```tsx
|
||||
import { useDocumentEvents } from 'payload/components/hooks'
|
||||
|
||||
const ListenForUpdates: React.FC = () => {
|
||||
const { mostRecentUpdate } = useDocumentEvents()
|
||||
|
||||
return (
|
||||
<span>
|
||||
{JSON.stringify(mostRecentUpdate)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
<Banner type="info">
|
||||
Right now the `useDocumentEvents` hook only tracks recently updated documents, but in the future it will track more document-related events as needed, such as document creation, deletion, etc.
|
||||
</Banner>
|
||||
|
||||
@@ -21,7 +21,7 @@ Then you will need to add the [bundler](/docs/admin/bundlers) to your Payload co
|
||||
|
||||
```ts
|
||||
import { buildConfig } from '@payloadcms/config'
|
||||
import viteBundler from '@payloadcms/bundler-vite'
|
||||
import { viteBundler } from '@payloadcms/bundler-vite'
|
||||
|
||||
export default buildConfig({
|
||||
collections: [],
|
||||
@@ -60,17 +60,33 @@ export const buildConfig({
|
||||
collections: [],
|
||||
admin: {
|
||||
bundler: viteBundler(),
|
||||
vite: (incomingViteConfig) => ({
|
||||
...incomingViteConfig,
|
||||
resolve: {
|
||||
...(incomingViteConfig?.resolve || {}),
|
||||
alias: {
|
||||
...(incomingViteConfig?.resolve?.alias || {}),
|
||||
'../server-only-module': path.resolve(__dirname, './path/to/browser-safe-module.js'),
|
||||
'../../server-only-module': path.resolve(__dirname, './path/to/browser-safe-module.js'),
|
||||
}
|
||||
vite: (incomingViteConfig) => {
|
||||
const existingAliases = incomingViteConfig?.resolve?.alias || {};
|
||||
let aliasArray: { find: string | RegExp; replacement: string; }[] = [];
|
||||
|
||||
// Pass the existing Vite aliases
|
||||
if (Array.isArray(existingAliases)) {
|
||||
aliasArray = existingAliases;
|
||||
} else {
|
||||
aliasArray = Object.values(existingAliases);
|
||||
}
|
||||
})
|
||||
|
||||
// Add your own aliases using the find and replacement keys
|
||||
aliasArray.push({
|
||||
find: '../server-only-module',
|
||||
replacement: path.resolve(__dirname, './path/to/browser-safe-module.js')
|
||||
find: '../../server-only-module',
|
||||
replacement: path.resolve(__dirname, './path/to/browser-safe-module.js')
|
||||
});
|
||||
|
||||
return {
|
||||
...incomingViteConfig,
|
||||
resolve: {
|
||||
...(incomingViteConfig?.resolve || {}),
|
||||
alias: aliasArray,
|
||||
}
|
||||
};
|
||||
},
|
||||
}
|
||||
})
|
||||
```
|
||||
@@ -90,7 +106,8 @@ That plugin should create an alias to support Vite as follows:
|
||||
```ts
|
||||
{
|
||||
// aliases go here
|
||||
'payload-plugin-cool': path.resolve(__dirname, './my-admin-plugin.js')
|
||||
find: 'payload-plugin-cool',
|
||||
replacement: path.resolve(__dirname, './my-admin-plugin.js')
|
||||
}
|
||||
|
||||
```
|
||||
@@ -108,22 +125,36 @@ export const buildConfig({
|
||||
collections: [],
|
||||
admin: {
|
||||
bundler: viteBundler(),
|
||||
vite: (incomingViteConfig) => ({
|
||||
...incomingViteConfig,
|
||||
resolve: {
|
||||
...(incomingViteConfig?.resolve || {}),
|
||||
alias: {
|
||||
...(incomingViteConfig?.resolve?.alias || {}),
|
||||
// custom aliases go here
|
||||
'../server-only-module': path.resolve(__dirname, './path/to/browser-safe-module.js'),
|
||||
}
|
||||
vite: (incomingViteConfig) => {
|
||||
const existingAliases = incomingViteConfig?.resolve?.alias || {};
|
||||
let aliasArray: { find: string | RegExp; replacement: string; }[] = [];
|
||||
|
||||
// Pass the existing Vite aliases
|
||||
if (Array.isArray(existingAliases)) {
|
||||
aliasArray = existingAliases;
|
||||
} else {
|
||||
aliasArray = Object.values(existingAliases);
|
||||
}
|
||||
})
|
||||
|
||||
// Add your own aliases using the find and replacement keys
|
||||
aliasArray.push({
|
||||
find: '../server-only-module',
|
||||
replacement: path.resolve(__dirname, './path/to/browser-safe-module.js')
|
||||
});
|
||||
|
||||
return {
|
||||
...incomingViteConfig,
|
||||
resolve: {
|
||||
...(incomingViteConfig?.resolve || {}),
|
||||
alias: aliasArray,
|
||||
}
|
||||
};
|
||||
},
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
Learn more about [aliasing server-only modules](http://localhost:3000/docs/admin/excluding-server-code#aliasing-server-only-modules).
|
||||
Learn more about [aliasing server-only modules](https://payloadcms.com/docs/admin/excluding-server-code#aliasing-server-only-modules).
|
||||
|
||||
Even though there is a new property for Vite configs specifically, we have implemented some "compatibility" between Webpack and Vite out-of-the-box.
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ Once enabled, each document that is created within the Collection can be thought
|
||||
|
||||
### Token-based auth
|
||||
|
||||
Successfully logging in returns a `JWT` (JSON web token) which is how a user will identify themselves to Payload. By providing this JWT via either an HTTP-only cookie or an `Authorization` header, Payload will automatically identify the user and add its user JWT data to the Express `req`, which is available throughout Payload including within access control, hooks, and more.
|
||||
Successfully logging in returns a `JWT` (JSON web token) which is how a user will identify themselves to Payload. By providing this JWT via either an HTTP-only cookie or an `Authorization: JWT` or `Authorization: Bearer` header, Payload will automatically identify the user and add its user JWT data to the Express `req`, which is available throughout Payload including within access control, hooks, and more.
|
||||
|
||||
You can specify what data gets encoded to the JWT token by setting `saveToJWT` to true in your auth collection fields. If you wish to use a different key other than the field `name`, you can provide it to `saveToJWT` as a string. It is also possible to use `saveToJWT` on fields that are nested in inside groups and tabs. If a group has a `saveToJWT` set it will include the object with all sub-fields in the token. You can set `saveToJWT: false` for any fields you wish to omit. If a field inside a group has `saveToJWT` set, but the group does not, the field will be included at the top level of the token.
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ export const Orders: CollectionConfig = {
|
||||
#### More collection config examples
|
||||
|
||||
You can find an assortment
|
||||
of [example collection configs](https://github.com/payloadcms/public-demo/tree/master/src/collections) in the Public
|
||||
of [example collection configs](https://github.com/payloadcms/public-demo/tree/master/src/payload/collections) in the Public
|
||||
Demo source code on GitHub.
|
||||
|
||||
### Admin options
|
||||
|
||||
@@ -59,7 +59,7 @@ export default Nav
|
||||
|
||||
#### Global config example
|
||||
|
||||
You can find an [example Global config](https://github.com/payloadcms/public-demo/blob/master/src/globals/MainMenu.ts) in the Public Demo source code on GitHub.
|
||||
You can find an [example Global config](https://github.com/payloadcms/public-demo/blob/master/src/payload/globals/MainMenu.ts) in the Public Demo source code on GitHub.
|
||||
|
||||
### Admin options
|
||||
|
||||
|
||||
@@ -57,6 +57,38 @@ export default buildConfig({
|
||||
})
|
||||
```
|
||||
|
||||
**Example Payload config set up for localization with full locales objects (including [internationalization](/docs/configuration/i18n) support):**
|
||||
|
||||
```ts
|
||||
import { buildConfig } from 'payload/config'
|
||||
|
||||
export default buildConfig({
|
||||
collections: [
|
||||
// collections go here
|
||||
],
|
||||
localization: {
|
||||
locales: [
|
||||
{
|
||||
label: {
|
||||
en: 'English', // English label
|
||||
nb: 'Engelsk', // Norwegian label
|
||||
},
|
||||
code: 'en',
|
||||
},
|
||||
{
|
||||
label: {
|
||||
en: 'Norwegian', // English label
|
||||
nb: 'Norsk', // Norwegian label
|
||||
},
|
||||
code: 'nb',
|
||||
},
|
||||
],
|
||||
defaultLocale: 'en',
|
||||
fallback: true,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
**Here is a brief explanation of each of the options available within the `localization` property:**
|
||||
|
||||
**`locales`**
|
||||
|
||||
@@ -108,7 +108,7 @@ export default buildConfig({
|
||||
|
||||
#### Full example config
|
||||
|
||||
You can see a full [example config](https://github.com/payloadcms/public-demo/blob/master/src/payload.config.ts) in the Public Demo source code on GitHub.
|
||||
You can see a full [example config](https://github.com/payloadcms/public-demo/blob/master/src/payload/payload.config.ts) in the Public Demo source code on GitHub.
|
||||
|
||||
### Using environment variables in your config
|
||||
|
||||
|
||||
@@ -46,8 +46,14 @@ export async function down({ payload }: MigrateDownArgs): Promise<void> {
|
||||
};
|
||||
```
|
||||
|
||||
### Migrations Directory
|
||||
|
||||
Each DB adapter has an optional property `migrationDir` where you can override where you want your migrations to be stored/read. If this is not specified, Payload will check the default and possibly make a best effort to find your migrations directory by searching in common locations ie. `./src/migrations`, `./dist/igrations`, `./migrations`, etc.
|
||||
|
||||
All database adapters should implement similar migration patterns, but there will be small differences based on the adapter and its specific needs. Below is a list of all migration commands that should be supported by your database adapter.
|
||||
|
||||
## Commands
|
||||
|
||||
### Migrate
|
||||
|
||||
The `migrate` command will run any migrations that have not yet been run.
|
||||
|
||||
@@ -33,6 +33,7 @@ export default buildConfig({
|
||||
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `autoPluralization` | Tell Mongoose to auto-pluralize any collection names if it encounters any singular words used as collection `slug`s. |
|
||||
| `connectOptions` | Customize MongoDB connection options. Payload will connect to your MongoDB database using default options which you can override and extend to include all the [options](https://mongoosejs.com/docs/connections.html#options) available to mongoose. |
|
||||
| `disableIndexHints` | Set to true to disable hinting to MongoDB to use 'id' as index. This is currently done when counting documents for pagination, as it increases the speed of the count function used in that query. Disabling this optimization might fix some problems with AWS DocumentDB. Defaults to false |
|
||||
| `migrationDir` | Customize the directory that migrations are stored. |
|
||||
|
||||
### Access to Mongoose models
|
||||
@@ -43,4 +44,4 @@ You can access Mongoose models as follows:
|
||||
|
||||
- Collection models - `payload.db.collections[myCollectionSlug]`
|
||||
- Globals model - `payload.db.globals`
|
||||
- Versions model (both collections and globals) - `payload.db.versions[myEntitySlug]`
|
||||
- Versions model (both collections and globals) - `payload.db.versions[myEntitySlug]`
|
||||
|
||||
@@ -55,6 +55,7 @@ In addition to the default [field admin config](/docs/fields/overview#admin-conf
|
||||
| **`date.maxDate`** \* | Max date value to allow. |
|
||||
| **`date.minTime`** \* | Min time value to allow. |
|
||||
| **`date.maxTime`** \* | Max date value to allow. |
|
||||
| **`date.overrides`** \* | Pass any valid props directly to the [react-datepicker](https://github.com/Hacker0x01/react-datepicker/blob/master/docs/datepicker.md) |
|
||||
| **`date.timeIntervals`** \* | Time intervals to display. Defaults to 30 minutes. |
|
||||
| **`date.timeFormat`** \* | Determines time format. Defaults to `'h:mm aa'`. |
|
||||
|
||||
|
||||
@@ -70,6 +70,43 @@ Set to `true` if you'd like this field to be sortable within the Admin UI using
|
||||
|
||||
Set to `false` if you'd like to disable the ability to create new documents from within the relationship field (hides the "Add new" button in the admin UI).
|
||||
|
||||
**`sortOptions`**
|
||||
|
||||
The `sortOptions` property allows you to define a default sorting order for the options within a Relationship field's dropdown. This can be particularly useful for ensuring that the most relevant options are presented first to the user.
|
||||
|
||||
You can specify `sortOptions` in two ways:
|
||||
|
||||
**As a string:**
|
||||
|
||||
Provide a string to define a global default sort field for all relationship field dropdowns across different collections. You can prefix the field name with a minus symbol ("-") to sort in descending order.
|
||||
|
||||
Example:
|
||||
|
||||
```ts
|
||||
sortOptions: 'fieldName',
|
||||
```
|
||||
This configuration will sort all relationship field dropdowns by `"fieldName"` in ascending order.
|
||||
|
||||
**As an object :**
|
||||
|
||||
Specify an object where keys are collection slugs and values are strings representing the field names to sort by. This allows for different sorting fields for each collection's relationship dropdown.
|
||||
|
||||
Example:
|
||||
|
||||
```ts
|
||||
sortOptions: {
|
||||
"pages": "fieldName1",
|
||||
"posts": "-fieldName2",
|
||||
"categories": "fieldName3"
|
||||
}
|
||||
```
|
||||
In this configuration:
|
||||
- Dropdowns related to `pages` will be sorted by `"fieldName1"` in ascending order.
|
||||
- Dropdowns for `posts` will use `"fieldName2"` for sorting in descending order (noted by the "-" prefix).
|
||||
- Dropdowns associated with `categories` will sort based on `"fieldName3"` in ascending order.
|
||||
|
||||
Note: If `sortOptions` is not defined, the default sorting behavior of the Relationship field dropdown will be used.
|
||||
|
||||
### Filtering relationship options
|
||||
|
||||
Options can be dynamically limited by supplying a [query constraint](/docs/queries/overview), which will be used both for validating input and filtering available relationships in the UI.
|
||||
@@ -267,3 +304,27 @@ Relationship fields with `hasMany` set to more than one kind of collections save
|
||||
Querying is done in the same way as the earlier Polymorphic example:
|
||||
|
||||
`?where[owners.value][equals]=6031ac9e1289176380734024`.
|
||||
|
||||
|
||||
#### Querying and Filtering Polymorphic Relationships
|
||||
|
||||
Polymorphic and non-polymorphic relationships must be queried differently because of how the related data is stored and may be inconsistent across different collections. Because of this, filtering polymorphic relationship fields from the Collection List admin UI is limited to the `id` value.
|
||||
|
||||
For a polymorphic relationship, the response will always be an array of objects. Each object will contain the `relationTo` and `value` properties.
|
||||
|
||||
The data can be queried by the related document ID:
|
||||
|
||||
`?where[field.value][equals]=6031ac9e1289176380734024`.
|
||||
|
||||
Or by the related document Collection slug:
|
||||
|
||||
`?where[field.relationTo][equals]=your-collection-slug`.
|
||||
|
||||
However, you **cannot** query on any field values within the related document.
|
||||
Since we are referencing multiple collections, the field you are querying on may not exist and break the query.
|
||||
|
||||
<Banner type="warning">
|
||||
<strong>Note:</strong>
|
||||
<br />
|
||||
You <strong>cannot</strong> query on a field within a polymorphic relationship as you would with a non-polymorphic relationship.
|
||||
</Banner>
|
||||
|
||||
@@ -10,7 +10,7 @@ keywords: documentation, getting started, guide, Content Management System, cms,
|
||||
|
||||
Payload requires the following software:
|
||||
|
||||
- Yarn or NPM
|
||||
- Any JavaScript package manager (Yarn, NPM, or pnpm)
|
||||
- Node.js version 16+
|
||||
- Any [compatible database](/docs/database/overview) (MongoDB or Postgres)
|
||||
|
||||
@@ -23,7 +23,7 @@ Payload requires the following software:
|
||||
To quickly scaffold a new Payload app in the fastest way possible, you can use [create-payload-app](https://npmjs.com/package/create-payload-app). To do so, run the following command:
|
||||
|
||||
```
|
||||
npx create-payload-app
|
||||
npx create-payload-app@latest
|
||||
```
|
||||
|
||||
Then just follow the prompts! You'll get set up with a new folder and a functioning Payload app inside.
|
||||
|
||||
@@ -10,13 +10,14 @@ While using Live Preview, the Admin panel emits a new `window.postMessage` event
|
||||
|
||||
Wiring your front-end into Live Preview is easy. If your front-end application is built with React or Next.js, use the [`useLivePreview`](#react) React hook that Payload provides. In the future, all other major frameworks like Vue, Svelte, etc will be officially supported. If you are using any of these frameworks today, you can still integrate with Live Preview yourself using the underlying tooling that Payload provides. See [building your own hook](#building-your-own-hook) for more information.
|
||||
|
||||
By default, all hooks require the following args:
|
||||
By default, all hooks accept the following args:
|
||||
|
||||
| Path | Description |
|
||||
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **`serverURL`** \* | The URL of your Payload server. |
|
||||
| **`initialData`** | The initial data of the document. The live data will be merged in as changes are made. |
|
||||
| **`depth`** | The depth of the relationships to fetch. Defaults to `0`. |
|
||||
| **`apiRoute`** | The path of your API route as defined in `routes.api`. Defaults to `/api`. |
|
||||
|
||||
_\* An asterisk denotes that a property is required._
|
||||
|
||||
@@ -186,3 +187,55 @@ For a working demonstration of this, check out the official [Live Preview Exampl
|
||||
- [Next.js App Router](https://github.com/payloadcms/payload/tree/main/examples/live-preview/next-app)
|
||||
- [Next.js Pages Router](https://github.com/payloadcms/payload/tree/main/examples/live-preview/next-pages)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Relationships and/or uploads are not populating
|
||||
|
||||
If you are using relationships or uploads in your front-end application, and your front-end application runs on a different domain than your Payload server, you may need to configure [CORS](../configuration/overview) to allow requests to be made between the two domains. This includes sites that are running on a different port or subdomain. Similarly, if you are protecting resources behind user authentication, you may also need to configure [CSRF](../authentication/overview#csrf-protection) to allow cookies to be sent between the two domains. For example:
|
||||
|
||||
```ts
|
||||
// payload.config.ts
|
||||
{
|
||||
// ...
|
||||
// If your site is running on a different domain than your Payload server,
|
||||
// This will allows requests to be made between the two domains
|
||||
cors: {
|
||||
[
|
||||
'http://localhost:3001' // Your front-end application
|
||||
],
|
||||
},
|
||||
// If you are protecting resources behind user authentication,
|
||||
// This will allow cookies to be sent between the two domains
|
||||
csrf: {
|
||||
[
|
||||
'http://localhost:3001' // Your front-end application
|
||||
],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Relationships and/or uploads disappear after editing a document
|
||||
|
||||
It is possible that either you are setting an improper [`depth`](../getting-started/concepts#depth) in your initial request and/or your `useLivePreview` hook, or they're mismatched. Ensure that the `depth` parameter is set to the correct value, and that it matches exactly in both places. For example:
|
||||
|
||||
```tsx
|
||||
// Your initial request
|
||||
const { docs } = await payload.find({
|
||||
collection: 'pages',
|
||||
depth: 1, // Ensure this is set to the proper depth for your application
|
||||
where: {
|
||||
slug: {
|
||||
equals: 'home',
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
```tsx
|
||||
// Your hook
|
||||
const { data } = useLivePreview<PageType>({
|
||||
initialData: initialPage,
|
||||
serverURL: PAYLOAD_SERVER_URL,
|
||||
depth: 1, // Ensure this matches the depth of your initial request
|
||||
})
|
||||
```
|
||||
|
||||
@@ -12,6 +12,10 @@ Live Preview works by rendering an iframe on the page that loads your front-end
|
||||
|
||||
{/* IMAGE OF LIVE PREVIEW HERE */}
|
||||
|
||||
<Banner type="warning">
|
||||
Live Preview is currently in beta. You may use this feature in production, but please be aware that it is subject to change and may not be fully stable for all use cases. If you encounter any issues, please [report them](https://github.com/payloadcms/payload/issues/new?assignees=jacobsfletch&labels=possible-bug&projects=&title=Live%20Preview&template=1.bug_report.yml) with as much detail as possible.
|
||||
</Banner>
|
||||
|
||||
## Setup
|
||||
|
||||
Setting up Live Preview is easy. You first need to enable it through the `admin.livePreview` property of your Payload config. It takes the following options:
|
||||
|
||||
@@ -6,7 +6,9 @@ desc: The Payload Local API allows you to interact with your database and execut
|
||||
keywords: local api, config, configuration, documentation, Content Management System, cms, headless, javascript, node, react, express
|
||||
---
|
||||
|
||||
The Payload Local API gives you the ability to execute the same operations that are available through REST and GraphQL within Node, directly on your server. Here, you don't need to deal with server latency or network speed whatsoever and can interact directly with your database.
|
||||
The Payload Local API gives you the ability to execute the same operations that are available through REST and GraphQL
|
||||
within Node, directly on your server. Here, you don't need to deal with server latency or network speed whatsoever and
|
||||
can interact directly with your database.
|
||||
|
||||
<Banner type="success">
|
||||
<strong>Tip:</strong>
|
||||
@@ -30,7 +32,9 @@ You can gain access to the currently running `payload` object via two ways:
|
||||
|
||||
##### Importing it
|
||||
|
||||
You can import or require `payload` into your own files after it's been initialized, but you need to make sure that your `import` / `require` statements come **after** you call `payload.init()`—otherwise Payload won't have been initialized yet. That might be obvious. To us, it's usually not.
|
||||
You can import or require `payload` into your own files after it's been initialized, but you need to make sure that
|
||||
your `import` / `require` statements come **after** you call `payload.init()`—otherwise Payload won't have been
|
||||
initialized yet. That might be obvious. To us, it's usually not.
|
||||
|
||||
Example:
|
||||
|
||||
@@ -47,7 +51,8 @@ const afterChangeHook: CollectionAfterChangeHook = async () => {
|
||||
|
||||
##### Accessing from the `req`
|
||||
|
||||
Payload is available anywhere you have access to the Express `req` - including within your access control and hook functions.
|
||||
Payload is available anywhere you have access to the Express `req` - including within your access control and hook
|
||||
functions.
|
||||
|
||||
Example:
|
||||
|
||||
@@ -61,10 +66,11 @@ const afterChangeHook: CollectionAfterChangeHook = async ({ req: { payload } })
|
||||
|
||||
### Local options available
|
||||
|
||||
You can specify more options within the Local API vs. REST or GraphQL due to the server-only context that they are executed in.
|
||||
You can specify more options within the Local API vs. REST or GraphQL due to the server-only context that they are
|
||||
executed in.
|
||||
|
||||
| Local Option | Description |
|
||||
| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
|--------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `collection` | Required for Collection operations. Specifies the Collection slug to operate against. |
|
||||
| `data` | The data to use within the operation. Required for `create`, `update`. |
|
||||
| `depth` | [Control auto-population](/docs/getting-started/concepts#depth) of nested relationship and upload fields. |
|
||||
@@ -268,7 +274,8 @@ const result = await payload.delete({
|
||||
|
||||
## Auth Operations
|
||||
|
||||
If a collection has [`Authentication`](/docs/authentication/overview) enabled, additional Local API operations will be available:
|
||||
If a collection has [`Authentication`](/docs/authentication/overview) enabled, additional Local API operations will be
|
||||
available:
|
||||
|
||||
#### Login
|
||||
|
||||
@@ -319,10 +326,11 @@ const token = await payload.forgotPassword({
|
||||
// token: 'o38jf0q34jfij43f3f...', // JWT used for auth
|
||||
// user: { ... } // the user document that just logged in
|
||||
// }
|
||||
const result = await payload.forgotPassword({
|
||||
const result = await payload.resetPassword({
|
||||
collection: 'users', // required
|
||||
data: {
|
||||
// required
|
||||
password: req.body.password, // the new password to set
|
||||
token: 'afh3o2jf2p3f...', // the token generated from the forgotPassword operation
|
||||
},
|
||||
req: req, // pass an Express `req` which will be provided to all hooks
|
||||
@@ -402,7 +410,9 @@ const result = await payload.updateGlobal({
|
||||
|
||||
## Next.js Conflict with Local API
|
||||
|
||||
There is a known issue when using the Local API with Next.js version `13.4.13` and higher. Next.js executes within a separate child process, and Payload has not been initalized yet in these instances. That means that unless you explicitly initialize Payload within your operation, it will not be running and return no data / an empty object.
|
||||
There is a known issue when using the Local API with Next.js version `13.4.13` and higher. Next.js executes within a
|
||||
separate child process, and Payload has not been initalized yet in these instances. That means that unless you
|
||||
explicitly initialize Payload within your operation, it will not be running and return no data / an empty object.
|
||||
|
||||
As a workaround, we recommend leveraging the following pattern to determine and ensure Payload is initalized:
|
||||
|
||||
@@ -462,7 +472,8 @@ export const getPayloadClient = async ({ initOptions, seed }: Args = {}): Promis
|
||||
}
|
||||
```
|
||||
|
||||
To checkout how this works in a project, take a look at our [custom server example](https://github.com/payloadcms/payload/blob/master/examples/custom-server/src/getPayload.ts).
|
||||
To checkout how this works in a project, take a look at
|
||||
our [custom server example](https://github.com/payloadcms/payload/blob/master/examples/custom-server/src/getPayload.ts).
|
||||
|
||||
## Example Script using Local API
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ If you're starting from scratch, you can easily setup a dev environment lik
|
||||
```
|
||||
mkdir dev
|
||||
cd dev
|
||||
npx create-payload-app
|
||||
npx create-payload-app@latest
|
||||
```
|
||||
|
||||
If you're using the plugin template, the dev folder is built out for you and the `samplePlugin` has already been installed in `dev/payload.config()`.
|
||||
|
||||
@@ -11,7 +11,8 @@ keywords: deployment, production, config, configuration, documentation, Content
|
||||
launch. <strong>Awesome! Great work!</strong> Now, what's next?
|
||||
</Banner>
|
||||
|
||||
There are many ways to deploy Payload to a production environment. When evaluating how you will deploy Payload, you need to consider these main aspects:
|
||||
There are many ways to deploy Payload to a production environment. When evaluating how you will deploy Payload, you need
|
||||
to consider these main aspects:
|
||||
|
||||
1. [Basics](#basics)
|
||||
1. [Security](#security)
|
||||
@@ -21,19 +22,26 @@ There are many ways to deploy Payload to a production environment. When evaluati
|
||||
|
||||
## Basics
|
||||
|
||||
In order for Payload to run, it requires both the server code and the built admin panel. These will be the `dist` and `build` directories by default. If you've used `create-payload-app` to create your project, executing the `build` npm script will build both and output these directories.
|
||||
In order for Payload to run, it requires both the server code and the built admin panel. These will be the `dist`
|
||||
and `build` directories by default. If you've used `create-payload-app` to create your project, executing the `build`
|
||||
npm script will build both and output these directories.
|
||||
|
||||
## Security
|
||||
|
||||
Payload features a suite of security features that you can rely on to strengthen your application's security. When deploying to Production, it's a good idea to double-check that you are making proper use of each of them.
|
||||
Payload features a suite of security features that you can rely on to strengthen your application's security. When
|
||||
deploying to Production, it's a good idea to double-check that you are making proper use of each of them.
|
||||
|
||||
##### The Secret Key
|
||||
|
||||
When you initialize Payload, you provide it with a `secret` property. This property should be impossible to guess and extremely difficult for brute-force attacks to crack. Make sure your Production `secret` is a long, complex string. It's often best practice to store it in an `env` file which is not checked into your Git repository, using `dotenv` to supply it to your `payload.init` call.
|
||||
When you initialize Payload, you provide it with a `secret` property. This property should be impossible to guess and
|
||||
extremely difficult for brute-force attacks to crack. Make sure your Production `secret` is a long, complex string. It's
|
||||
often best practice to store it in an `env` file which is not checked into your Git repository, using `dotenv` to supply
|
||||
it to your `payload.init` call.
|
||||
|
||||
##### Double-check and thoroughly test all Access Control
|
||||
|
||||
Because _**you**_ are in complete control of who can do what with your data, you should double and triple-check that you wield that power responsibly before deploying to Production.
|
||||
Because _**you**_ are in complete control of who can do what with your data, you should double and triple-check that you
|
||||
wield that power responsibly before deploying to Production.
|
||||
|
||||
<Banner type="error">
|
||||
<strong>By default, all Access Control functions require that a user is successfully logged in to Payload to create, read, update, or delete data.</strong>{' '}
|
||||
@@ -44,7 +52,8 @@ Because _**you**_ are in complete control of who can do what with your data, you
|
||||
|
||||
##### Building the Admin panel
|
||||
|
||||
Before running in Production, you need to have built a production-ready copy of the Payload Admin panel. To do this, Payload provides the `build` NPM script. You can use it by adding a `script` to your `package.json` file like this:
|
||||
Before running in Production, you need to have built a production-ready copy of the Payload Admin panel. To do this,
|
||||
Payload provides the `build` NPM script. You can use it by adding a `script` to your `package.json` file like this:
|
||||
|
||||
`package.json`:
|
||||
|
||||
@@ -60,19 +69,26 @@ Before running in Production, you need to have built a production-ready copy of
|
||||
}
|
||||
```
|
||||
|
||||
Then, to build Payload, you would run `npm run build` in your project folder. A production-ready Admin bundle will be created in the `build` directory.
|
||||
Then, to build Payload, you would run `npm run build` in your project folder. A production-ready Admin bundle will be
|
||||
created in the `build` directory.
|
||||
|
||||
##### Setting Node to Production
|
||||
|
||||
Make sure you set the environment variable `NODE_ENV` to `production`. Based on this variable, many Node packages automatically optimize themselves. In production, Payload automatically disables the [GraphQL Playground](/docs/graphql/overview#graphql-playground), serves a production-ready version of the Admin panel, and other changes.
|
||||
Make sure you set the environment variable `NODE_ENV` to `production`. Based on this variable, many Node packages
|
||||
automatically optimize themselves. In production, Payload automatically disables
|
||||
the [GraphQL Playground](/docs/graphql/overview#graphql-playground), serves a production-ready version of the Admin
|
||||
panel, and other changes.
|
||||
|
||||
##### Secure Cookie Settings
|
||||
|
||||
You should be using an SSL certificate for production Payload instances, which means you can [enable secure cookies](/docs/authentication/config) in your Authentication-enabled Collection configs.
|
||||
You should be using an SSL certificate for production Payload instances, which means you
|
||||
can [enable secure cookies](/docs/authentication/config) in your Authentication-enabled Collection configs.
|
||||
|
||||
##### Preventing API Abuse
|
||||
|
||||
Payload comes with a robust set of built-in anti-abuse measures, such as locking out users after X amount of failed login attempts, request rate limiting, GraphQL query complexity limits, max `depth` settings, and more. [Click here to learn more](/docs/production/preventing-abuse).
|
||||
Payload comes with a robust set of built-in anti-abuse measures, such as locking out users after X amount of failed
|
||||
login attempts, request rate limiting, GraphQL query complexity limits, max `depth` settings, and
|
||||
more. [Click here to learn more](/docs/production/preventing-abuse).
|
||||
|
||||
## MongoDB
|
||||
|
||||
@@ -80,11 +96,18 @@ Payload can be used with any MongoDB compatible database including AWS DocumentD
|
||||
|
||||
##### Managing MongoDB yourself
|
||||
|
||||
If you are using a [persistent filesystem-based cloud host](#persistent-vs-ephemeral-filesystems) such as a [DigitalOcean Droplet](https://www.digitalocean.com/products/droplets/) or an [Amazon EC2](https://aws.amazon.com/ec2/?ec2-whats-new.sort-by=item.additionalFields.postDateTime&ec2-whats-new.sort-order=desc) server, you might opt to install MongoDB directly on that server itself so that Node can communicate with it locally. With this approach, you can benefit from faster response times, but scaling can become more involved as your app's user base grows.
|
||||
If you are using a [persistent filesystem-based cloud host](#persistent-vs-ephemeral-filesystems) such as
|
||||
a [DigitalOcean Droplet](https://www.digitalocean.com/products/droplets/) or
|
||||
an [Amazon EC2](https://aws.amazon.com/ec2/?ec2-whats-new.sort-by=item.additionalFields.postDateTime&ec2-whats-new.sort-order=desc)
|
||||
server, you might opt to install MongoDB directly on that server itself so that Node can communicate with it locally.
|
||||
With this approach, you can benefit from faster response times, but scaling can become more involved as your app's user
|
||||
base grows.
|
||||
|
||||
##### Letting someone else do it
|
||||
|
||||
Alternatively, you can rely on a third-party MongoDB host such as [MongoDB Atlas](https://www.mongodb.com/). With Atlas or a similar cloud provider, you can trust them to take care of your database's availability, security, redundancy, and backups.
|
||||
Alternatively, you can rely on a third-party MongoDB host such as [MongoDB Atlas](https://www.mongodb.com/). With Atlas
|
||||
or a similar cloud provider, you can trust them to take care of your database's availability, security, redundancy, and
|
||||
backups.
|
||||
|
||||
<Banner type="warning">
|
||||
<strong>Note:</strong>
|
||||
@@ -98,21 +121,31 @@ Alternatively, you can rely on a third-party MongoDB host such as [MongoDB Atlas
|
||||
|
||||
##### DocumentDB
|
||||
|
||||
When using AWS DocumentDB, you will need to configure connection options for authentication in the `mongoOptions` passed to `payload.init`. You also need to set `mongoOptions.useFacet` to `false` to disable use of the unsupported `$facet` aggregation.
|
||||
When using AWS DocumentDB, you will need to configure connection options for authentication in the `connectOptions`
|
||||
passed to the `mongooseAdapter` . You also need to set `connectOptions.useFacet` to `false` to disable use of the
|
||||
unsupported `$facet` aggregation.
|
||||
|
||||
##### CosmosDB
|
||||
|
||||
When using Azure Cosmos DB, an index is needed for any field you may want to sort on. To add the sort index for all fields that may be sorted in the admin UI use the <a href="/docs/configuration/overview">indexSortableFields</a> configuration option.
|
||||
When using Azure Cosmos DB, an index is needed for any field you may want to sort on. To add the sort index for all
|
||||
fields that may be sorted in the admin UI use the <a href="/docs/configuration/overview">indexSortableFields</a>
|
||||
configuration option.
|
||||
|
||||
## File storage
|
||||
|
||||
If you are using Payload to [manage file uploads](/docs/upload/overview), you need to consider where your uploaded files will be permanently stored. If you do not use Payload for file uploads, then this section does not impact your app whatsoever.
|
||||
If you are using Payload to [manage file uploads](/docs/upload/overview), you need to consider where your uploaded files
|
||||
will be permanently stored. If you do not use Payload for file uploads, then this section does not impact your app
|
||||
whatsoever.
|
||||
|
||||
#### Persistent vs Ephemeral Filesystems
|
||||
|
||||
Some cloud app hosts such as [Heroku](https://heroku.com) use `ephemeral` file systems, which means that any files uploaded to your server only last until the server restarts or shuts down. Heroku and similar providers schedule restarts and shutdowns without your control, meaning your uploads will accidentally disappear without any way to get them back.
|
||||
Some cloud app hosts such as [Heroku](https://heroku.com) use `ephemeral` file systems, which means that any files
|
||||
uploaded to your server only last until the server restarts or shuts down. Heroku and similar providers schedule
|
||||
restarts and shutdowns without your control, meaning your uploads will accidentally disappear without any way to get
|
||||
them back.
|
||||
|
||||
Alternatively, persistent filesystems will never delete your files and can be trusted to reliably host uploads perpetually.
|
||||
Alternatively, persistent filesystems will never delete your files and can be trusted to reliably host uploads
|
||||
perpetually.
|
||||
|
||||
**Popular cloud providers with ephemeral filesystems:**
|
||||
|
||||
@@ -135,21 +168,26 @@ Alternatively, persistent filesystems will never delete your files and can be tr
|
||||
|
||||
##### Using ephemeral filesystem providers like Heroku
|
||||
|
||||
If you don't use Payload's `upload` functionality, you can go ahead and use Heroku or similar platform easily. Everything will work exactly as you want it to.
|
||||
If you don't use Payload's `upload` functionality, you can go ahead and use Heroku or similar platform easily.
|
||||
Everything will work exactly as you want it to.
|
||||
|
||||
But, if you do, and you still want to use an ephemeral filesystem provider, you can write a hook-based solution to _copy_ the files your users upload to a more permanent storage solution like Amazon S3 or DigitalOcean Spaces.
|
||||
But, if you do, and you still want to use an ephemeral filesystem provider, you can write a hook-based solution to
|
||||
_copy_ the files your users upload to a more permanent storage solution like Amazon S3 or DigitalOcean Spaces.
|
||||
|
||||
**To automatically send uploaded files to S3 or similar, you could:**
|
||||
|
||||
- Write an asynchronous `beforeChange` hook for all Collections that support Uploads, which takes any uploaded `file` from the Express `req` and sends it to an S3 bucket
|
||||
- Write an `afterRead` hook to save a `s3URL` field that automatically takes the `filename` stored and formats a full S3 URL
|
||||
- Write an asynchronous `beforeChange` hook for all Collections that support Uploads, which takes any uploaded `file`
|
||||
from the Express `req` and sends it to an S3 bucket
|
||||
- Write an `afterRead` hook to save a `s3URL` field that automatically takes the `filename` stored and formats a full S3
|
||||
URL
|
||||
- Write an `afterDelete` hook that automatically deletes files from the S3 bucket
|
||||
|
||||
With the above configuration, deploying to Heroku or similar becomes no problem.
|
||||
|
||||
## DigitalOcean Tutorials
|
||||
|
||||
DigitalOcean provides extremely helpful documentation that can walk you through the entire process of creating a production-ready Droplet to host your Payload app:
|
||||
DigitalOcean provides extremely helpful documentation that can walk you through the entire process of creating a
|
||||
production-ready Droplet to host your Payload app:
|
||||
|
||||
1. Create a new Ubuntu 20.04 droplet on [DigitalOcean](https://digitalocean.com)
|
||||
1. [Initial server setup](https://www.digitalocean.com/community/tutorials/initial-server-setup-with-ubuntu-20-04)
|
||||
@@ -160,18 +198,25 @@ DigitalOcean provides extremely helpful documentation that can walk you through
|
||||
|
||||
### Swap Space
|
||||
|
||||
Swap refers to a section of storage on the hard drive that is reserved to temporarily store data that can no longer fit within RAM. This allows for the expansion of your server's working memory, with some limitations. Swap space comes into play when available RAM can no longer accommodate actively used application data, enabling the system to continue functioning.
|
||||
Swap refers to a section of storage on the hard drive that is reserved to temporarily store data that can no longer fit
|
||||
within RAM. This allows for the expansion of your server's working memory, with some limitations. Swap space comes into
|
||||
play when available RAM can no longer accommodate actively used application data, enabling the system to continue
|
||||
functioning.
|
||||
|
||||
Insufficient space can lead to deployment errors and memory-related issues, resulting in application crashes, sluggish performance, or an unresponsive server.
|
||||
Insufficient space can lead to deployment errors and memory-related issues, resulting in application crashes, sluggish
|
||||
performance, or an unresponsive server.
|
||||
|
||||
Common deployment error due to **space limitations** (as reported by users):
|
||||
|
||||
- `Error: Command failed with exit code 1`
|
||||
|
||||
To configure swap, we recommend following this tutorial on [How To Add Swap Space](https://www.digitalocean.com/community/tutorials/how-to-add-swap-space-on-ubuntu-22-04).
|
||||
To configure swap, we recommend following this tutorial
|
||||
on [How To Add Swap Space](https://www.digitalocean.com/community/tutorials/how-to-add-swap-space-on-ubuntu-22-04).
|
||||
|
||||
## Docker
|
||||
|
||||
This is an example of a multi-stage docker build of Payload for production. Ensure you are setting your environment variables on deployment, like `PAYLOAD_SECRET`, `PAYLOAD_CONFIG_PATH`, and `DATABASE_URI` if needed.
|
||||
This is an example of a multi-stage docker build of Payload for production. Ensure you are setting your environment
|
||||
variables on deployment, like `PAYLOAD_SECRET`, `PAYLOAD_CONFIG_PATH`, and `DATABASE_URI` if needed.
|
||||
|
||||
```dockerfile
|
||||
FROM node:18-alpine as base
|
||||
|
||||
@@ -153,8 +153,8 @@ Here's an overview of all the included features:
|
||||
| **`HeadingFeature`** | Yes | Adds Heading Nodes (by default, H1 - H6, but that can be customized) |
|
||||
| **`AlignFeature`** | Yes | Allows you to align text left, centered and right |
|
||||
| **`IndentFeature`** | Yes | Allows you to indent text with the tab key |
|
||||
| **`UnoderedListFeature`** | Yes | Adds unordered lists (ol) |
|
||||
| **`OrderedListFeature`** | Yes | Adds ordered lists (ul) |
|
||||
| **`UnorderedListFeature`** | Yes | Adds unordered lists (ul) |
|
||||
| **`OrderedListFeature`** | Yes | Adds ordered lists (ol) |
|
||||
| **`CheckListFeature`** | Yes | Adds checklists |
|
||||
| **`LinkFeature`** | Yes | Allows you to create internal and external links |
|
||||
| **`RelationshipFeature`** | Yes | Allows you to create block-level (not inline) relationships to other documents |
|
||||
@@ -314,13 +314,31 @@ import {
|
||||
|
||||
const yourEditorConfig; // <= your editor config here
|
||||
|
||||
const headlessEditor = await createHeadlessEditor({
|
||||
const headlessEditor = createHeadlessEditor({
|
||||
nodes: getEnabledNodes({
|
||||
editorConfig: sanitizeEditorConfig(yourEditorConfig),
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
### Getting the editor config
|
||||
|
||||
As you can see, you need to provide an editor config in order to create a headless editor. This is because the editor config is used to determine which nodes & features are enabled, and which converters are used.
|
||||
|
||||
To get the editor config, simply import the default editor config and adjust it - just like you did inside of the `editor: lexicalEditor({})` property:
|
||||
|
||||
```ts
|
||||
import { defaultEditorConfig, defaultEditorFeatures } from '@payloadcms/richtext-lexical' // <= make sure this package is installed
|
||||
|
||||
const yourEditorConfig = defaultEditorConfig
|
||||
|
||||
// If you made changes to the features of the field's editor config, you should also make those changes here:
|
||||
yourEditorConfig.features = [
|
||||
...defaultEditorFeatures,
|
||||
// Add your custom features here
|
||||
]
|
||||
```
|
||||
|
||||
### HTML => Lexical
|
||||
|
||||
Once you have your headless editor instance, you can use it to convert HTML to Lexical:
|
||||
@@ -328,13 +346,14 @@ Once you have your headless editor instance, you can use it to convert HTML to L
|
||||
```ts
|
||||
import { $generateNodesFromDOM } from '@lexical/html'
|
||||
import { $getRoot,$getSelection } from 'lexical'
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
headlessEditor.update(() => {
|
||||
// In a headless environment you can use a package such as JSDom to parse the HTML string.
|
||||
const dom = new JSDOM(htmlString)
|
||||
|
||||
// Once you have the DOM instance it's easy to generate LexicalNodes.
|
||||
const nodes = $generateNodesFromDOM(editor, dom.window.document)
|
||||
const nodes = $generateNodesFromDOM(headlessEditor, dom.window.document)
|
||||
|
||||
// Select the root
|
||||
$getRoot().select()
|
||||
@@ -348,6 +367,8 @@ headlessEditor.update(() => {
|
||||
const editorJSON = headlessEditor.getEditorState().toJSON()
|
||||
```
|
||||
|
||||
Functions prefixed with a `$` can only be run inside of an `editor.update()` or `editorState.read()` callback.
|
||||
|
||||
This has been taken from the [lexical serialization & deserialization docs](https://lexical.dev/docs/concepts/serialization#html---lexical).
|
||||
|
||||
<Banner type="success">
|
||||
@@ -395,7 +416,6 @@ try {
|
||||
headlessEditor.setEditorState(headlessEditor.parseEditorState(yourEditorState)) // This should commit the editor state immediately
|
||||
} catch (e) {
|
||||
logger.error({ err: e }, 'ERROR parsing editor state')
|
||||
return ''
|
||||
}
|
||||
|
||||
// Export to markdown
|
||||
@@ -407,6 +427,35 @@ headlessEditor.getEditorState().read(() => {
|
||||
|
||||
The `.setEditorState()` function immediately updates your editor state. Thus, there's no need for the `discrete: true` flag when reading the state afterward.
|
||||
|
||||
|
||||
### Lexical => Plain Text
|
||||
|
||||
Export content from the Lexical editor into plain text using these steps:
|
||||
|
||||
1. Import your current editor state into the headless editor.
|
||||
2. Convert and fetch the resulting plain text string.
|
||||
|
||||
Here's the code for it:
|
||||
|
||||
```ts
|
||||
import type { SerializedEditorState } from "lexical"
|
||||
import { $getRoot } from "lexical"
|
||||
|
||||
const yourEditorState: SerializedEditorState // <= your current editor state here
|
||||
|
||||
// Import editor state into your headless editor
|
||||
try {
|
||||
headlessEditor.setEditorState(headlessEditor.parseEditorState(yourEditorState)) // This should commit the editor state immediately
|
||||
} catch (e) {
|
||||
logger.error({ err: e }, 'ERROR parsing editor state')
|
||||
}
|
||||
|
||||
// Export to plain text
|
||||
const plainTextContent = headlessEditor.getEditorState().read(() => {
|
||||
return $getRoot().getTextContent()
|
||||
}) || ''
|
||||
```
|
||||
|
||||
## Migrating from Slate
|
||||
|
||||
While both Slate and Lexical save the editor state in JSON, the structure of the JSON is different.
|
||||
|
||||
@@ -9,7 +9,7 @@ keywords: headless cms, typescript, documentation, Content Management System, cm
|
||||
Payload supports TypeScript natively, and not only that, the entirety of the CMS is built with TypeScript. To get started developing with Payload and TypeScript, you can use one of Payload's built-in boilerplates in one line via `create-payload-app`:
|
||||
|
||||
```
|
||||
npx create-payload-app
|
||||
npx create-payload-app@latest
|
||||
```
|
||||
|
||||
Pick a TypeScript project type to get started easily.
|
||||
|
||||
@@ -38,7 +38,7 @@ Every Payload Collection can opt-in to supporting Uploads by specifying the `upl
|
||||
</strong> on that collection.
|
||||
</Banner>
|
||||
|
||||
#### Collection Upload Options
|
||||
### Collection Upload Options
|
||||
|
||||
| Option | Description |
|
||||
| ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
@@ -47,14 +47,14 @@ Every Payload Collection can opt-in to supporting Uploads by specifying the `upl
|
||||
| **`adminThumbnail`** | Set the way that the Admin panel will display thumbnails for this Collection. [More](#admin-thumbnails) |
|
||||
| **`crop`** | Set to `false` to disable the cropping tool in the Admin panel. Crop is enabled by default. [More](#crop-and-focal-point-selector) |
|
||||
| **`disableLocalStorage`** | Completely disable uploading files to disk locally. [More](#disabling-local-upload-storage) |
|
||||
| **`focalPoint`** | Set to `false` to disable the focal point selection tool in the Admin panel. The focal point selector is only available when `imageSizes` or `resizeOptions` are defined. [More](#crop-and-focal-point-selector) |
|
||||
| **`focalPoint`** | Set to `false` to disable the focal point selection tool in the Admin panel. The focal point selector is only available when `imageSizes` or `resizeOptions` are defined. [More](#crop-and-focal-point-selector) |
|
||||
| **`formatOptions`** | An object with `format` and `options` that are used with the Sharp image library to format the upload file. [More](https://sharp.pixelplumbing.com/api-output#toformat) |
|
||||
| **`handlers`** | Array of Express request handlers to execute before the built-in Payload static middleware executes. |
|
||||
| **`imageSizes`** | If specified, image uploads will be automatically resized in accordance to these image sizes. [More](#image-sizes) |
|
||||
| **`mimeTypes`** | Restrict mimeTypes in the file picker. Array of valid mimetypes or mimetype wildcards [More](#mimetypes) |
|
||||
| **`staticOptions`** | Set options for `express.static` to use while serving your static files. [More](http://expressjs.com/en/resources/middleware/serve-static.html) format) |
|
||||
| **`resizeOptions`** | An object passed to the the Sharp image library to resize the uploaded file. [More](https://sharp.pixelplumbing.com/api-resize) |
|
||||
| **`filesRequiredOnCreate`** | Mandate file data on creation, default is true. |
|
||||
| **`staticOptions`** | Set options for `express.static` to use while serving your static files. [More](http://expressjs.com/en/resources/middleware/serve-static.html) |
|
||||
| **`resizeOptions`** | An object passed to the the Sharp image library to resize the uploaded file. [More](https://sharp.pixelplumbing.com/api-resize) |
|
||||
| **`filesRequiredOnCreate`** | Mandate file data on creation, default is true. |
|
||||
|
||||
_An asterisk denotes that a property above is required._
|
||||
|
||||
@@ -148,6 +148,23 @@ All auto-resized images are exposed to be re-used in hooks and similar via an ob
|
||||
|
||||
The object will have keys for each size generated, and each key will be set equal to a buffer containing the file data.
|
||||
|
||||
##### Handling Image Enlargement
|
||||
|
||||
When an uploaded image is smaller than the defined image size, we have 3 options:
|
||||
|
||||
`withoutEnlargement: undefined | false | true`
|
||||
|
||||
1.`undefined` [default]: uploading images with smaller width AND height than the image size will return null
|
||||
2. `false`: always enlarge images to the image size
|
||||
3. `true`: if the image is smaller than the image size, return the original image
|
||||
|
||||
<Banner type="error">
|
||||
<strong>Note:</strong>
|
||||
<br />
|
||||
By default, the image size will return NULL when the uploaded image is smaller than the defined image size.
|
||||
Use the `withoutEnlargement` prop to change this.
|
||||
</Banner>
|
||||
|
||||
### Crop and Focal Point Selector
|
||||
|
||||
This feature is only available for image file types.
|
||||
|
||||
@@ -21,7 +21,7 @@ Collections and Globals both support the same options for configuring autosave.
|
||||
|
||||
| Drafts Autosave Options | Description |
|
||||
| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `interval` | Define an `interval` in milliseconds to automatically save progress while documents are edited. Document updates are "debounced" at this interval. Defaults to `2000`. |
|
||||
| `interval` | Define an `interval` in milliseconds to automatically save progress while documents are edited. Document updates are "debounced" at this interval. Defaults to `800`. |
|
||||
|
||||
**Example config with versions, drafts, and autosave enabled:**
|
||||
|
||||
@@ -66,7 +66,7 @@ When `autosave` is enabled, all `update` operations within Payload expose a new
|
||||
|
||||
#### How autosaves are stored
|
||||
|
||||
If we created a new version for each autosave, you'd quickly find a ton of autosaves that clutter up your `_versions` collection within the database. That would be messy quick because `autosave` is typically set to save a document every ~2000ms or so.
|
||||
If we created a new version for each autosave, you'd quickly find a ton of autosaves that clutter up your `_versions` collection within the database. That would be messy quick because `autosave` is typically set to save a document at ~800ms intervals.
|
||||
|
||||
<Banner type="success">
|
||||
Instead of creating a new version each time a document is autosaved, Payload smartly only creates{' '}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
module.exports = {
|
||||
config: () => null,
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -21,7 +21,7 @@ export const Country: React.FC<
|
||||
return (
|
||||
<Width width={width}>
|
||||
<div className={classes.select}>
|
||||
<label htmlFor="name" className={classes.label}>
|
||||
<label htmlFor={name} className={classes.label}>
|
||||
{label}
|
||||
</label>
|
||||
<Controller
|
||||
@@ -37,6 +37,7 @@ export const Country: React.FC<
|
||||
onChange={val => onChange(val.value)}
|
||||
className={classes.reactSelect}
|
||||
classNamePrefix="rs"
|
||||
inputId={name}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -19,13 +19,14 @@ export const Email: React.FC<
|
||||
return (
|
||||
<Width width={width}>
|
||||
<div className={classes.wrap}>
|
||||
<label htmlFor="name" className={classes.label}>
|
||||
<label htmlFor={name} className={classes.label}>
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Email"
|
||||
className={classes.input}
|
||||
id={name}
|
||||
{...register(name, { required: requiredFromProps, pattern: /^\S+@\S+$/i })}
|
||||
/>
|
||||
{requiredFromProps && errors[name] && <Error />}
|
||||
|
||||
@@ -19,12 +19,13 @@ export const Number: React.FC<
|
||||
return (
|
||||
<Width width={width}>
|
||||
<div className={classes.wrap}>
|
||||
<label htmlFor="name" className={classes.label}>
|
||||
<label htmlFor={name} className={classes.label}>
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
className={classes.input}
|
||||
id={name}
|
||||
{...register(name, { required: requiredFromProps })}
|
||||
/>
|
||||
{requiredFromProps && errors[name] && <Error />}
|
||||
|
||||
@@ -20,7 +20,7 @@ export const Select: React.FC<
|
||||
return (
|
||||
<Width width={width}>
|
||||
<div className={classes.select}>
|
||||
<label htmlFor="name" className={classes.label}>
|
||||
<label htmlFor={name} className={classes.label}>
|
||||
{label}
|
||||
</label>
|
||||
<Controller
|
||||
@@ -36,6 +36,7 @@ export const Select: React.FC<
|
||||
onChange={val => onChange(val.value)}
|
||||
className={classes.reactSelect}
|
||||
classNamePrefix="rs"
|
||||
inputId={name}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -21,7 +21,7 @@ export const State: React.FC<
|
||||
return (
|
||||
<Width width={width}>
|
||||
<div className={classes.select}>
|
||||
<label htmlFor="name" className={classes.label}>
|
||||
<label htmlFor={name} className={classes.label}>
|
||||
{label}
|
||||
</label>
|
||||
<Controller
|
||||
@@ -37,6 +37,7 @@ export const State: React.FC<
|
||||
onChange={val => onChange(val.value)}
|
||||
className={classes.reactSelect}
|
||||
classNamePrefix="rs"
|
||||
id={name}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -19,12 +19,13 @@ export const Text: React.FC<
|
||||
return (
|
||||
<Width width={width}>
|
||||
<div className={classes.wrap}>
|
||||
<label htmlFor="name" className={classes.label}>
|
||||
<label htmlFor={name} className={classes.label}>
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className={classes.input}
|
||||
id={name}
|
||||
{...register(name, { required: requiredFromProps })}
|
||||
/>
|
||||
{requiredFromProps && errors[name] && <Error />}
|
||||
|
||||
@@ -20,12 +20,13 @@ export const Textarea: React.FC<
|
||||
return (
|
||||
<Width width={width}>
|
||||
<div className={classes.wrap}>
|
||||
<label htmlFor="name" className={classes.label}>
|
||||
<label htmlFor={name} className={classes.label}>
|
||||
{label}
|
||||
</label>
|
||||
<textarea
|
||||
rows={rows}
|
||||
className={classes.textarea}
|
||||
id={name}
|
||||
{...register(name, { required: requiredFromProps })}
|
||||
/>
|
||||
{requiredFromProps && errors[name] && <Error />}
|
||||
|
||||
15
package.json
15
package.json
@@ -24,7 +24,7 @@
|
||||
"script:release": "tsx ./scripts/release.ts",
|
||||
"test": "pnpm test:int && pnpm test:components && pnpm test:e2e",
|
||||
"test:components": "cross-env jest --config=jest.components.config.js",
|
||||
"test:e2e": "npx playwright install --with-deps && ts-node -T ./test/runE2E.ts",
|
||||
"test:e2e": "npx playwright install --with-deps chromium && ts-node -T ./test/runE2E.ts",
|
||||
"test:e2e:debug": "cross-env PWDEBUG=1 DISABLE_LOGGING=true playwright test",
|
||||
"test:e2e:headed": "cross-env DISABLE_LOGGING=true playwright test --headed",
|
||||
"test:int:postgres": "cross-env PAYLOAD_DATABASE=postgres DISABLE_LOGGING=true jest --forceExit --detectOpenHandles",
|
||||
@@ -34,7 +34,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@payloadcms/eslint-config": "workspace:*",
|
||||
"@playwright/test": "1.38.1",
|
||||
"@playwright/test": "1.39.0",
|
||||
"@swc/cli": "^0.1.62",
|
||||
"@swc/jest": "0.2.29",
|
||||
"@swc/register": "0.1.10",
|
||||
@@ -45,7 +45,7 @@
|
||||
"@types/conventional-changelog-core": "^4.2.5",
|
||||
"@types/conventional-changelog-preset-loader": "^2.3.4",
|
||||
"@types/fs-extra": "^11.0.2",
|
||||
"@types/jest": "29.5.4",
|
||||
"@types/jest": "29.5.7",
|
||||
"@types/minimist": "1.2.2",
|
||||
"@types/node": "20.5.7",
|
||||
"@types/prompts": "^2.4.5",
|
||||
@@ -64,6 +64,7 @@
|
||||
"copyfiles": "2.4.1",
|
||||
"cross-env": "7.0.3",
|
||||
"dotenv": "8.6.0",
|
||||
"drizzle-orm": "0.28.5",
|
||||
"express": "4.18.2",
|
||||
"form-data": "3.0.1",
|
||||
"fs-extra": "10.1.0",
|
||||
@@ -73,9 +74,10 @@
|
||||
"graphql-request": "6.1.0",
|
||||
"husky": "^8.0.3",
|
||||
"isomorphic-fetch": "3.0.0",
|
||||
"jest": "29.6.4",
|
||||
"jest-environment-jsdom": "29.6.4",
|
||||
"jest": "29.7.0",
|
||||
"jest-environment-jsdom": "29.7.0",
|
||||
"jwt-decode": "3.1.2",
|
||||
"lexical": "0.12.2",
|
||||
"lint-staged": "^14.0.1",
|
||||
"minimist": "1.2.8",
|
||||
"mongodb-memory-server": "8.12.2",
|
||||
@@ -112,5 +114,8 @@
|
||||
"*.{js,jsx,ts,tsx}": [
|
||||
"prettier --write"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@sentry/react": "^7.77.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/bundler-vite",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.5",
|
||||
"description": "The officially supported Vite bundler adapter for Payload",
|
||||
"repository": "https://github.com/payloadcms/payload",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -63,13 +63,14 @@ export const getViteConfig = async (payloadConfig: SanitizedConfig): Promise<Inl
|
||||
'module.hot': 'undefined',
|
||||
'process.argv': '[]',
|
||||
'process.cwd': 'function () { return "/" }',
|
||||
'process.env': '{}',
|
||||
'process?.cwd': 'function () { return "/" }',
|
||||
}
|
||||
|
||||
Object.entries(process.env).forEach(([key, val]) => {
|
||||
if (key.indexOf('PAYLOAD_PUBLIC_') === 0) {
|
||||
define[`process.env.${key}`] = `'${val}'`
|
||||
} else {
|
||||
define[`process.env.${key}`] = `''`
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ CLI for easily starting new Payload project
|
||||
|
||||
$ npx create-payload-app
|
||||
$ npx create-payload-app my-project
|
||||
$ npx create-payload-app -n my-project -t blog
|
||||
$ npx create-payload-app -n my-project -t website
|
||||
|
||||
OPTIONS
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/db-mongodb",
|
||||
"version": "1.0.5",
|
||||
"version": "1.1.0",
|
||||
"description": "The officially supported MongoDB database adapter for Payload",
|
||||
"repository": "https://github.com/payloadcms/payload",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -26,8 +26,11 @@ export const createMigration = async function createMigration({ file, migrationN
|
||||
|
||||
let migrationFileContent: string | undefined
|
||||
|
||||
// Check for predefined migration
|
||||
if (file) {
|
||||
// Check for predefined migration.
|
||||
// Either passed in via --file or prefixed with @payloadcms/db-mongodb/
|
||||
if (file || migrationName.startsWith('@payloadcms/db-mongodb/')) {
|
||||
if (!file) file = migrationName
|
||||
|
||||
const predefinedMigrationName = file.replace('@payloadcms/db-mongodb/', '')
|
||||
migrationName = predefinedMigrationName
|
||||
const cleanPath = path.join(__dirname, `../predefinedMigrations/${predefinedMigrationName}.js`)
|
||||
|
||||
@@ -55,7 +55,7 @@ export const find: Find = async function find(
|
||||
useEstimatedCount,
|
||||
}
|
||||
|
||||
if (!useEstimatedCount) {
|
||||
if (!useEstimatedCount && this.disableIndexHints !== true) {
|
||||
// Improve the performance of the countDocuments query which is used if useEstimatedCount is set to false by adding a hint.
|
||||
paginationOptions.useCustomCountFn = () => {
|
||||
return Promise.resolve(
|
||||
|
||||
@@ -74,7 +74,7 @@ export const findGlobalVersions: FindGlobalVersions = async function findGlobalV
|
||||
useEstimatedCount,
|
||||
}
|
||||
|
||||
if (!useEstimatedCount) {
|
||||
if (!useEstimatedCount && this.disableIndexHints !== true) {
|
||||
// Improve the performance of the countDocuments query which is used if useEstimatedCount is set to false by adding a hint.
|
||||
paginationOptions.useCustomCountFn = () => {
|
||||
return Promise.resolve(
|
||||
|
||||
@@ -71,7 +71,7 @@ export const findVersions: FindVersions = async function findVersions(
|
||||
useEstimatedCount,
|
||||
}
|
||||
|
||||
if (!useEstimatedCount) {
|
||||
if (!useEstimatedCount && this.disableIndexHints !== true) {
|
||||
// Improve the performance of the countDocuments query which is used if useEstimatedCount is set to false by adding a hint.
|
||||
paginationOptions.useCustomCountFn = () => {
|
||||
return Promise.resolve(
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ClientSession, ConnectOptions, Connection } from 'mongoose'
|
||||
import type { Payload } from 'payload'
|
||||
import type { BaseDatabaseAdapter } from 'payload/database'
|
||||
|
||||
import fs from 'fs'
|
||||
import mongoose from 'mongoose'
|
||||
import path from 'path'
|
||||
import { createDatabaseAdapter } from 'payload/database'
|
||||
@@ -46,6 +47,8 @@ export interface Args {
|
||||
/** Set false to disable $facet aggregation in non-supporting databases, Defaults to true */
|
||||
useFacet?: boolean
|
||||
}
|
||||
/** Set to true to disable hinting to MongoDB to use 'id' as index. This is currently done when counting documents for pagination. Disabling this optimization might fix some problems with AWS DocumentDB. Defaults to false */
|
||||
disableIndexHints?: boolean
|
||||
migrationDir?: string
|
||||
/** The URL to connect to MongoDB or false to start payload and prevent connecting */
|
||||
url: false | string
|
||||
@@ -87,11 +90,12 @@ declare module 'payload' {
|
||||
export function mongooseAdapter({
|
||||
autoPluralization = true,
|
||||
connectOptions,
|
||||
disableIndexHints = false,
|
||||
migrationDir: migrationDirArg,
|
||||
url,
|
||||
}: Args): MongooseAdapterResult {
|
||||
function adapter({ payload }: { payload: Payload }) {
|
||||
const migrationDir = migrationDirArg || path.resolve(process.cwd(), 'src/migrations')
|
||||
const migrationDir = findMigrationDir(migrationDirArg)
|
||||
mongoose.set('strictQuery', false)
|
||||
|
||||
extendWebpackConfig(payload.config)
|
||||
@@ -105,6 +109,7 @@ export function mongooseAdapter({
|
||||
collections: {},
|
||||
connectOptions: connectOptions || {},
|
||||
connection: undefined,
|
||||
disableIndexHints,
|
||||
globals: undefined,
|
||||
mongoMemoryServer: undefined,
|
||||
sessions: {},
|
||||
@@ -145,3 +150,42 @@ export function mongooseAdapter({
|
||||
|
||||
return adapter
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to find migrations directory.
|
||||
*
|
||||
* Checks for the following directories in order:
|
||||
* - `migrationDir` argument from Payload config
|
||||
* - `src/migrations`
|
||||
* - `dist/migrations`
|
||||
* - `migrations`
|
||||
*
|
||||
* Defaults to `src/migrations`
|
||||
*
|
||||
* @param migrationDir
|
||||
* @returns
|
||||
*/
|
||||
function findMigrationDir(migrationDir?: string): string {
|
||||
const cwd = process.cwd()
|
||||
const srcDir = path.resolve(cwd, 'src/migrations')
|
||||
const distDir = path.resolve(cwd, 'dist/migrations')
|
||||
const relativeMigrations = path.resolve(cwd, 'migrations')
|
||||
|
||||
// Use arg if provided
|
||||
if (migrationDir) return migrationDir
|
||||
|
||||
// Check other common locations
|
||||
if (fs.existsSync(srcDir)) {
|
||||
return srcDir
|
||||
}
|
||||
|
||||
if (fs.existsSync(distDir)) {
|
||||
return distDir
|
||||
}
|
||||
|
||||
if (fs.existsSync(relativeMigrations)) {
|
||||
return relativeMigrations
|
||||
}
|
||||
|
||||
return srcDir
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ export const queryDrafts: QueryDrafts = async function queryDrafts(
|
||||
useEstimatedCount,
|
||||
}
|
||||
|
||||
if (!useEstimatedCount) {
|
||||
if (!useEstimatedCount && this.disableIndexHints !== true) {
|
||||
// Improve the performance of the countDocuments query which is used if useEstimatedCount is set to false by adding a hint.
|
||||
paginationOptions.useCustomCountFn = () => {
|
||||
return Promise.resolve(
|
||||
|
||||
@@ -4,6 +4,7 @@ export const commitTransaction: CommitTransaction = async function commitTransac
|
||||
if (!this.sessions[id]?.inTransaction()) {
|
||||
return
|
||||
}
|
||||
|
||||
await this.sessions[id].commitTransaction()
|
||||
await this.sessions[id].endSession()
|
||||
delete this.sessions[id]
|
||||
|
||||
@@ -3,10 +3,20 @@ import type { RollbackTransaction } from 'payload/database'
|
||||
export const rollbackTransaction: RollbackTransaction = async function rollbackTransaction(
|
||||
id = '',
|
||||
) {
|
||||
if (!this.sessions[id]?.inTransaction()) {
|
||||
this.payload.logger.warn('rollbackTransaction called when no transaction exists')
|
||||
// if multiple operations are using the same transaction, the first will flow through and delete the session.
|
||||
// subsequent calls should be ignored.
|
||||
if (!this.sessions[id]) {
|
||||
return
|
||||
}
|
||||
|
||||
// when session exists but is not inTransaction something unexpected is happening to the session
|
||||
if (!this.sessions[id].inTransaction()) {
|
||||
this.payload.logger.warn('rollbackTransaction called when no transaction exists')
|
||||
delete this.sessions[id]
|
||||
return
|
||||
}
|
||||
|
||||
// the first call for rollback should be aborted and deleted causing any other operations with the same transaction to fail
|
||||
await this.sessions[id].abortTransaction()
|
||||
await this.sessions[id].endSession()
|
||||
delete this.sessions[id]
|
||||
|
||||
@@ -7,9 +7,17 @@ import { withSession } from './withSession'
|
||||
|
||||
export async function updateGlobalVersion<T extends TypeWithID>(
|
||||
this: MongooseAdapter,
|
||||
{ global, locale, req = {} as PayloadRequest, versionData, where }: UpdateGlobalVersionArgs<T>,
|
||||
{
|
||||
id,
|
||||
global,
|
||||
locale,
|
||||
req = {} as PayloadRequest,
|
||||
versionData,
|
||||
where,
|
||||
}: UpdateGlobalVersionArgs<T>,
|
||||
) {
|
||||
const VersionModel = this.versions[global]
|
||||
const whereToUse = where || { id: { equals: id } }
|
||||
const options = {
|
||||
...withSession(this, req.transactionID),
|
||||
lean: true,
|
||||
@@ -19,7 +27,7 @@ export async function updateGlobalVersion<T extends TypeWithID>(
|
||||
const query = await VersionModel.buildQuery({
|
||||
locale,
|
||||
payload: this.payload,
|
||||
where,
|
||||
where: whereToUse,
|
||||
})
|
||||
|
||||
const doc = await VersionModel.findOneAndUpdate(query, versionData, options)
|
||||
|
||||
@@ -7,9 +7,10 @@ import { withSession } from './withSession'
|
||||
|
||||
export const updateVersion: UpdateVersion = async function updateVersion(
|
||||
this: MongooseAdapter,
|
||||
{ collection, locale, req = {} as PayloadRequest, versionData, where },
|
||||
{ id, collection, locale, req = {} as PayloadRequest, versionData, where },
|
||||
) {
|
||||
const VersionModel = this.versions[collection]
|
||||
const whereToUse = where || { id: { equals: id } }
|
||||
const options = {
|
||||
...withSession(this, req.transactionID),
|
||||
lean: true,
|
||||
@@ -19,7 +20,7 @@ export const updateVersion: UpdateVersion = async function updateVersion(
|
||||
const query = await VersionModel.buildQuery({
|
||||
locale,
|
||||
payload: this.payload,
|
||||
where,
|
||||
where: whereToUse,
|
||||
})
|
||||
|
||||
const doc = await VersionModel.findOneAndUpdate(query, versionData, options)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/db-postgres",
|
||||
"version": "0.1.11",
|
||||
"version": "0.2.0",
|
||||
"description": "The officially supported Postgres database adapter for Payload",
|
||||
"repository": "https://github.com/payloadcms/payload",
|
||||
"license": "MIT",
|
||||
@@ -26,7 +26,8 @@
|
||||
"drizzle-orm": "0.28.5",
|
||||
"pg": "8.11.3",
|
||||
"prompts": "2.4.2",
|
||||
"to-snake-case": "1.0.0"
|
||||
"to-snake-case": "1.0.0",
|
||||
"uuid": "9.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@payloadcms/eslint-config": "workspace:*",
|
||||
|
||||
@@ -33,7 +33,7 @@ export const findMany = async function find({
|
||||
const db = adapter.sessions[req.transactionID]?.db || adapter.drizzle
|
||||
const table = adapter.tables[tableName]
|
||||
|
||||
let limit = limitArg ?? 10
|
||||
const limit = limitArg ?? 10
|
||||
let totalDocs: number
|
||||
let totalPages: number
|
||||
let hasPrevPage: boolean
|
||||
@@ -51,6 +51,7 @@ export const findMany = async function find({
|
||||
})
|
||||
|
||||
const orderedIDMap: Record<number | string, number> = {}
|
||||
let orderedIDs: (number | string)[]
|
||||
|
||||
const selectDistinctMethods: ChainedMethods = []
|
||||
|
||||
@@ -116,7 +117,8 @@ export const findMany = async function find({
|
||||
selectDistinctResult.forEach(({ id }, i) => {
|
||||
orderedIDMap[id as number | string] = i
|
||||
})
|
||||
findManyArgs.where = inArray(adapter.tables[tableName].id, Object.keys(orderedIDMap))
|
||||
orderedIDs = Object.keys(orderedIDMap)
|
||||
findManyArgs.where = inArray(adapter.tables[tableName].id, orderedIDs)
|
||||
} else {
|
||||
findManyArgs.limit = limitArg === 0 ? undefined : limitArg
|
||||
|
||||
@@ -132,7 +134,7 @@ export const findMany = async function find({
|
||||
|
||||
const findPromise = db.query[tableName].findMany(findManyArgs)
|
||||
|
||||
if (pagination !== false || selectDistinctResult?.length > limit) {
|
||||
if (pagination !== false && (orderedIDs ? orderedIDs?.length >= limit : true)) {
|
||||
const selectCountMethods: ChainedMethods = []
|
||||
|
||||
joinAliases.forEach(({ condition, table }) => {
|
||||
@@ -174,9 +176,8 @@ export const findMany = async function find({
|
||||
rawDocs.sort((a, b) => orderedIDMap[a.id] - orderedIDMap[b.id])
|
||||
}
|
||||
|
||||
if (pagination === false) {
|
||||
if (pagination === false || !totalDocs) {
|
||||
totalDocs = rawDocs.length
|
||||
limit = totalDocs
|
||||
totalPages = 1
|
||||
pagingCounter = 1
|
||||
hasPrevPage = false
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { createDatabaseAdapter } from 'payload/database'
|
||||
|
||||
@@ -42,7 +43,7 @@ export type { MigrateDownArgs, MigrateUpArgs } from './types'
|
||||
|
||||
export function postgresAdapter(args: Args): PostgresAdapterResult {
|
||||
function adapter({ payload }: { payload: Payload }) {
|
||||
const migrationDir = args.migrationDir || path.resolve(process.cwd(), 'src/migrations')
|
||||
const migrationDir = findMigrationDir(args.migrationDir)
|
||||
|
||||
extendWebpackConfig(payload.config)
|
||||
extendViteConfig(payload.config)
|
||||
@@ -53,6 +54,7 @@ export function postgresAdapter(args: Args): PostgresAdapterResult {
|
||||
// Postgres-specific
|
||||
drizzle: undefined,
|
||||
enums: {},
|
||||
fieldConstraints: {},
|
||||
pool: undefined,
|
||||
poolOptions: args.pool,
|
||||
push: args.push,
|
||||
@@ -60,7 +62,6 @@ export function postgresAdapter(args: Args): PostgresAdapterResult {
|
||||
schema: {},
|
||||
sessions: {},
|
||||
tables: {},
|
||||
fieldConstraints: {},
|
||||
|
||||
// DatabaseAdapter
|
||||
beginTransaction,
|
||||
@@ -101,3 +102,42 @@ export function postgresAdapter(args: Args): PostgresAdapterResult {
|
||||
|
||||
return adapter
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to find migrations directory.
|
||||
*
|
||||
* Checks for the following directories in order:
|
||||
* - `migrationDir` argument from Payload config
|
||||
* - `src/migrations`
|
||||
* - `dist/migrations`
|
||||
* - `migrations`
|
||||
*
|
||||
* Defaults to `src/migrations`
|
||||
*
|
||||
* @param migrationDir
|
||||
* @returns
|
||||
*/
|
||||
function findMigrationDir(migrationDir?: string): string {
|
||||
const cwd = process.cwd()
|
||||
const srcDir = path.resolve(cwd, 'src/migrations')
|
||||
const distDir = path.resolve(cwd, 'dist/migrations')
|
||||
const relativeMigrations = path.resolve(cwd, 'migrations')
|
||||
|
||||
// Use arg if provided
|
||||
if (migrationDir) return migrationDir
|
||||
|
||||
// Check other common locations
|
||||
if (fs.existsSync(srcDir)) {
|
||||
return srcDir
|
||||
}
|
||||
|
||||
if (fs.existsSync(distDir)) {
|
||||
return distDir
|
||||
}
|
||||
|
||||
if (fs.existsSync(relativeMigrations)) {
|
||||
return relativeMigrations
|
||||
}
|
||||
|
||||
return srcDir
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ export const init: Init = async function init(this: PostgresAdapter) {
|
||||
|
||||
buildTable({
|
||||
adapter: this,
|
||||
buildNumbers: true,
|
||||
buildRelationships: true,
|
||||
disableNotNull: !!collection?.versions?.drafts,
|
||||
disableUnique: false,
|
||||
@@ -37,6 +38,7 @@ export const init: Init = async function init(this: PostgresAdapter) {
|
||||
|
||||
buildTable({
|
||||
adapter: this,
|
||||
buildNumbers: true,
|
||||
buildRelationships: true,
|
||||
disableNotNull: !!collection.versions?.drafts,
|
||||
disableUnique: true,
|
||||
@@ -52,6 +54,7 @@ export const init: Init = async function init(this: PostgresAdapter) {
|
||||
|
||||
buildTable({
|
||||
adapter: this,
|
||||
buildNumbers: true,
|
||||
buildRelationships: true,
|
||||
disableNotNull: !!global?.versions?.drafts,
|
||||
disableUnique: false,
|
||||
@@ -66,6 +69,7 @@ export const init: Init = async function init(this: PostgresAdapter) {
|
||||
|
||||
buildTable({
|
||||
adapter: this,
|
||||
buildNumbers: true,
|
||||
buildRelationships: true,
|
||||
disableNotNull: !!global.versions?.drafts,
|
||||
disableUnique: true,
|
||||
|
||||
@@ -297,6 +297,9 @@ export const getTableColumnFromPath = ({
|
||||
table: adapter.tables[newTableName],
|
||||
}
|
||||
}
|
||||
if (pathSegments[1] === 'blockType') {
|
||||
throw new APIError('Querying on blockType is not supported')
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ type Args = {
|
||||
adapter: PostgresAdapter
|
||||
baseColumns?: Record<string, PgColumnBuilder>
|
||||
baseExtraConfig?: Record<string, (cols: GenericColumns) => IndexBuilder | UniqueConstraintBuilder>
|
||||
buildNumbers?: boolean
|
||||
buildRelationships?: boolean
|
||||
disableNotNull: boolean
|
||||
disableUnique: boolean
|
||||
@@ -39,6 +40,7 @@ type Args = {
|
||||
}
|
||||
|
||||
type Result = {
|
||||
hasManyNumberField: 'index' | boolean
|
||||
relationsToBuild: Map<string, string>
|
||||
}
|
||||
|
||||
@@ -46,6 +48,7 @@ export const buildTable = ({
|
||||
adapter,
|
||||
baseColumns = {},
|
||||
baseExtraConfig = {},
|
||||
buildNumbers,
|
||||
buildRelationships,
|
||||
disableNotNull,
|
||||
disableUnique = false,
|
||||
@@ -53,10 +56,11 @@ export const buildTable = ({
|
||||
rootRelationsToBuild,
|
||||
rootRelationships,
|
||||
rootTableIDColType,
|
||||
rootTableName,
|
||||
rootTableName: incomingRootTableName,
|
||||
tableName,
|
||||
timestamps,
|
||||
}: Args): Result => {
|
||||
const rootTableName = incomingRootTableName || tableName
|
||||
const columns: Record<string, PgColumnBuilder> = baseColumns
|
||||
const indexes: Record<string, (cols: GenericColumns) => IndexBuilder> = {}
|
||||
|
||||
@@ -102,6 +106,7 @@ export const buildTable = ({
|
||||
hasManyNumberField,
|
||||
} = traverseFields({
|
||||
adapter,
|
||||
buildNumbers,
|
||||
buildRelationships,
|
||||
columns,
|
||||
disableNotNull,
|
||||
@@ -116,7 +121,7 @@ export const buildTable = ({
|
||||
relationships,
|
||||
rootRelationsToBuild: rootRelationsToBuild || relationsToBuild,
|
||||
rootTableIDColType: rootTableIDColType || idColType,
|
||||
rootTableName: rootTableName || tableName,
|
||||
rootTableName,
|
||||
}))
|
||||
|
||||
if (timestamps) {
|
||||
@@ -185,8 +190,8 @@ export const buildTable = ({
|
||||
adapter.relations[`relations_${localeTableName}`] = localesTableRelations
|
||||
}
|
||||
|
||||
if (hasManyNumberField) {
|
||||
const numbersTableName = `${tableName}_numbers`
|
||||
if (hasManyNumberField && buildNumbers) {
|
||||
const numbersTableName = `${rootTableName}_numbers`
|
||||
const columns: Record<string, PgColumnBuilder> = {
|
||||
id: serial('id').primaryKey(),
|
||||
number: numeric('number'),
|
||||
@@ -327,5 +332,5 @@ export const buildTable = ({
|
||||
|
||||
adapter.relations[`relations_${tableName}`] = tableRelations
|
||||
|
||||
return { relationsToBuild }
|
||||
return { hasManyNumberField, relationsToBuild }
|
||||
}
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
/* eslint-disable no-param-reassign */
|
||||
import type { Relation } from 'drizzle-orm'
|
||||
import { relations } from 'drizzle-orm'
|
||||
import type { IndexBuilder, PgColumnBuilder, UniqueConstraintBuilder } from 'drizzle-orm/pg-core'
|
||||
import type { Field, TabAsField } from 'payload/types'
|
||||
|
||||
import { relations } from 'drizzle-orm'
|
||||
import {
|
||||
PgNumericBuilder,
|
||||
PgVarcharBuilder,
|
||||
boolean,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
numeric,
|
||||
pgEnum,
|
||||
PgNumericBuilder,
|
||||
PgVarcharBuilder,
|
||||
text,
|
||||
timestamp,
|
||||
varchar,
|
||||
} from 'drizzle-orm/pg-core'
|
||||
import type { Field, TabAsField } from 'payload/types'
|
||||
import { fieldAffectsData, optionIsObject } from 'payload/types'
|
||||
import { InvalidConfiguration } from 'payload/errors'
|
||||
import { fieldAffectsData, optionIsObject } from 'payload/types'
|
||||
import toSnakeCase from 'to-snake-case'
|
||||
|
||||
import type { GenericColumns, PostgresAdapter } from '../types'
|
||||
@@ -31,6 +32,7 @@ import { validateExistingBlockIsIdentical } from './validateExistingBlockIsIdent
|
||||
|
||||
type Args = {
|
||||
adapter: PostgresAdapter
|
||||
buildNumbers: boolean
|
||||
buildRelationships: boolean
|
||||
columnPrefix?: string
|
||||
columns: Record<string, PgColumnBuilder>
|
||||
@@ -60,6 +62,7 @@ type Result = {
|
||||
|
||||
export const traverseFields = ({
|
||||
adapter,
|
||||
buildNumbers,
|
||||
buildRelationships,
|
||||
columnPrefix,
|
||||
columns,
|
||||
@@ -283,19 +286,25 @@ export const traverseFields = ({
|
||||
baseExtraConfig._localeIdx = (cols) => index('_locale_idx').on(cols._locale)
|
||||
}
|
||||
|
||||
const { relationsToBuild: subRelationsToBuild } = buildTable({
|
||||
adapter,
|
||||
baseColumns,
|
||||
baseExtraConfig,
|
||||
disableNotNull: disableNotNullFromHere,
|
||||
disableUnique,
|
||||
fields: disableUnique ? idToUUID(field.fields) : field.fields,
|
||||
rootRelationsToBuild,
|
||||
rootRelationships: relationships,
|
||||
rootTableIDColType,
|
||||
rootTableName,
|
||||
tableName: arrayTableName,
|
||||
})
|
||||
const { hasManyNumberField: subHasManyNumberField, relationsToBuild: subRelationsToBuild } =
|
||||
buildTable({
|
||||
adapter,
|
||||
baseColumns,
|
||||
baseExtraConfig,
|
||||
disableNotNull: disableNotNullFromHere,
|
||||
disableUnique,
|
||||
fields: disableUnique ? idToUUID(field.fields) : field.fields,
|
||||
rootRelationsToBuild,
|
||||
rootRelationships: relationships,
|
||||
rootTableIDColType,
|
||||
rootTableName,
|
||||
tableName: arrayTableName,
|
||||
})
|
||||
|
||||
if (subHasManyNumberField) {
|
||||
if (!hasManyNumberField || subHasManyNumberField === 'index')
|
||||
hasManyNumberField = subHasManyNumberField
|
||||
}
|
||||
|
||||
relationsToBuild.set(fieldName, arrayTableName)
|
||||
|
||||
@@ -351,7 +360,10 @@ export const traverseFields = ({
|
||||
baseExtraConfig._localeIdx = (cols) => index('locale_idx').on(cols._locale)
|
||||
}
|
||||
|
||||
const { relationsToBuild: subRelationsToBuild } = buildTable({
|
||||
const {
|
||||
hasManyNumberField: subHasManyNumberField,
|
||||
relationsToBuild: subRelationsToBuild,
|
||||
} = buildTable({
|
||||
adapter,
|
||||
baseColumns,
|
||||
baseExtraConfig,
|
||||
@@ -365,6 +377,11 @@ export const traverseFields = ({
|
||||
tableName: blockTableName,
|
||||
})
|
||||
|
||||
if (subHasManyNumberField) {
|
||||
if (!hasManyNumberField || subHasManyNumberField === 'index')
|
||||
hasManyNumberField = subHasManyNumberField
|
||||
}
|
||||
|
||||
const blockTableRelations = relations(
|
||||
adapter.tables[blockTableName],
|
||||
({ many, one }) => {
|
||||
@@ -413,6 +430,7 @@ export const traverseFields = ({
|
||||
hasManyNumberField: groupHasManyNumberField,
|
||||
} = traverseFields({
|
||||
adapter,
|
||||
buildNumbers,
|
||||
buildRelationships,
|
||||
columnPrefix,
|
||||
columns,
|
||||
@@ -449,6 +467,7 @@ export const traverseFields = ({
|
||||
hasManyNumberField: groupHasManyNumberField,
|
||||
} = traverseFields({
|
||||
adapter,
|
||||
buildNumbers,
|
||||
buildRelationships,
|
||||
columnPrefix: `${columnName}_`,
|
||||
columns,
|
||||
@@ -486,6 +505,7 @@ export const traverseFields = ({
|
||||
hasManyNumberField: tabHasManyNumberField,
|
||||
} = traverseFields({
|
||||
adapter,
|
||||
buildNumbers,
|
||||
buildRelationships,
|
||||
columnPrefix,
|
||||
columns,
|
||||
@@ -524,6 +544,7 @@ export const traverseFields = ({
|
||||
hasManyNumberField: rowHasManyNumberField,
|
||||
} = traverseFields({
|
||||
adapter,
|
||||
buildNumbers,
|
||||
buildRelationships,
|
||||
columnPrefix,
|
||||
columns,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { CommitTransaction } from 'payload/database'
|
||||
|
||||
export const commitTransaction: CommitTransaction = async function commitTransaction(id) {
|
||||
// if the session was deleted it has already been aborted
|
||||
if (!this.sessions[id]) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3,12 +3,15 @@ import type { RollbackTransaction } from 'payload/database'
|
||||
export const rollbackTransaction: RollbackTransaction = async function rollbackTransaction(
|
||||
id = '',
|
||||
) {
|
||||
// if multiple operations are using the same transaction, the first will flow through and delete the session.
|
||||
// subsequent calls should be ignored.
|
||||
if (!this.sessions[id]) {
|
||||
this.payload.logger.warn('rollbackTransaction called when no transaction exists')
|
||||
return
|
||||
}
|
||||
|
||||
// end the session promise in failure by calling reject
|
||||
await this.sessions[id].reject()
|
||||
|
||||
// delete the session causing any other operations with the same transaction to fail
|
||||
delete this.sessions[id]
|
||||
}
|
||||
|
||||
@@ -3,16 +3,18 @@ import { isArrayOfRows } from '../../utilities/isArrayOfRows'
|
||||
|
||||
type Args = {
|
||||
data: unknown
|
||||
id?: unknown
|
||||
locale?: string
|
||||
}
|
||||
|
||||
export const transformSelects = ({ data, locale }: Args) => {
|
||||
export const transformSelects = ({ id, data, locale }: Args) => {
|
||||
const newRows: Record<string, unknown>[] = []
|
||||
|
||||
if (isArrayOfRows(data)) {
|
||||
data.forEach((value, i) => {
|
||||
const newRow: Record<string, unknown> = {
|
||||
order: i + 1,
|
||||
parent: id,
|
||||
value,
|
||||
}
|
||||
|
||||
|
||||
@@ -422,6 +422,7 @@ export const traverseFields = ({
|
||||
Object.entries(data[field.name]).forEach(([localeKey, localeData]) => {
|
||||
if (Array.isArray(localeData)) {
|
||||
const newRows = transformSelects({
|
||||
id: data._uuid || data.id,
|
||||
data: localeData,
|
||||
locale: localeKey,
|
||||
})
|
||||
@@ -432,6 +433,7 @@ export const traverseFields = ({
|
||||
}
|
||||
} else if (Array.isArray(data[field.name])) {
|
||||
const newRows = transformSelects({
|
||||
id: data._uuid || data.id,
|
||||
data: data[field.name],
|
||||
})
|
||||
|
||||
|
||||
@@ -102,7 +102,9 @@ export const upsertRow = async <T extends TypeWithID>({
|
||||
if (Object.keys(rowToInsert.selects).length > 0) {
|
||||
Object.entries(rowToInsert.selects).forEach(([selectTableName, selectRows]) => {
|
||||
selectRows.forEach((row) => {
|
||||
row.parent = insertedRow.id
|
||||
if (typeof row.parent === 'undefined') {
|
||||
row.parent = insertedRow.id
|
||||
}
|
||||
if (!selectsToInsert[selectTableName]) selectsToInsert[selectTableName] = []
|
||||
selectsToInsert[selectTableName].push(row)
|
||||
})
|
||||
|
||||
@@ -36,7 +36,7 @@ export const insertArrays = async ({ adapter, arrays, db, parentRows }: Args): P
|
||||
}
|
||||
}
|
||||
|
||||
const parentID = parentRows[parentRowIndex].id
|
||||
const parentID = parentRows[parentRowIndex].id || parentRows[parentRowIndex]._parentID
|
||||
|
||||
// Add any sub arrays that need to be created
|
||||
// We will call this recursively below
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/live-preview-react",
|
||||
"version": "0.1.5",
|
||||
"version": "0.2.0",
|
||||
"description": "The official live preview React SDK for Payload",
|
||||
"repository": "https://github.com/payloadcms/payload",
|
||||
"license": "MIT",
|
||||
@@ -25,7 +25,6 @@
|
||||
"payload": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"payload": "^2.0.0",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
||||
},
|
||||
"exports": {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
// you can conditionally render loading UI based on the `isLoading` state
|
||||
|
||||
export const useLivePreview = <T extends any>(props: {
|
||||
apiRoute?: string
|
||||
depth?: number
|
||||
initialData: T
|
||||
serverURL: string
|
||||
@@ -14,7 +15,7 @@ export const useLivePreview = <T extends any>(props: {
|
||||
data: T
|
||||
isLoading: boolean
|
||||
} => {
|
||||
const { depth = 0, initialData, serverURL } = props
|
||||
const { apiRoute, depth, initialData, serverURL } = props
|
||||
const [data, setData] = useState<T>(initialData)
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true)
|
||||
const hasSentReadyMessage = useRef<boolean>(false)
|
||||
@@ -26,6 +27,7 @@ export const useLivePreview = <T extends any>(props: {
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = subscribe({
|
||||
apiRoute,
|
||||
callback: onChange,
|
||||
depth,
|
||||
initialData,
|
||||
@@ -43,7 +45,7 @@ export const useLivePreview = <T extends any>(props: {
|
||||
return () => {
|
||||
unsubscribe(subscription)
|
||||
}
|
||||
}, [serverURL, onChange, depth, initialData])
|
||||
}, [serverURL, onChange, depth, initialData, apiRoute])
|
||||
|
||||
return {
|
||||
data,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/live-preview",
|
||||
"version": "0.1.5",
|
||||
"version": "0.2.0",
|
||||
"description": "The official live preview JavaScript SDK for Payload",
|
||||
"repository": "https://github.com/payloadcms/payload",
|
||||
"license": "MIT",
|
||||
@@ -20,9 +20,6 @@
|
||||
"@payloadcms/eslint-config": "workspace:*",
|
||||
"payload": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"payload": "^2.0.0"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"default": "./src/index.ts",
|
||||
|
||||
@@ -6,31 +6,53 @@ import { mergeData } from '.'
|
||||
// Send this cached value to `mergeData`, instead of `eventData.fieldSchemaJSON` directly
|
||||
let payloadLivePreviewFieldSchema = undefined // TODO: type this from `fieldSchemaToJSON` return type
|
||||
|
||||
// Each time the data is merged, cache the result as a `previousData` variable
|
||||
// This will ensure changes compound overtop of each other
|
||||
let payloadLivePreviewPreviousData = undefined
|
||||
|
||||
export const handleMessage = async <T>(args: {
|
||||
depth: number
|
||||
apiRoute?: string
|
||||
depth?: number
|
||||
event: MessageEvent
|
||||
initialData: T
|
||||
serverURL: string
|
||||
}): Promise<T> => {
|
||||
const { depth, event, initialData, serverURL } = args
|
||||
if (event.origin === serverURL && event.data) {
|
||||
const eventData = JSON.parse(event?.data)
|
||||
const { apiRoute, depth, event, initialData, serverURL } = args
|
||||
|
||||
if (eventData.type === 'payload-live-preview') {
|
||||
if (!payloadLivePreviewFieldSchema && eventData.fieldSchemaJSON) {
|
||||
payloadLivePreviewFieldSchema = eventData.fieldSchemaJSON
|
||||
}
|
||||
if (
|
||||
event.origin === serverURL &&
|
||||
event.data &&
|
||||
typeof event.data === 'object' &&
|
||||
event.data.type === 'payload-live-preview'
|
||||
) {
|
||||
const { data, externallyUpdatedRelationship, fieldSchemaJSON } = event.data
|
||||
|
||||
const mergedData = await mergeData<T>({
|
||||
depth,
|
||||
fieldSchema: payloadLivePreviewFieldSchema,
|
||||
incomingData: eventData.data,
|
||||
initialData,
|
||||
serverURL,
|
||||
})
|
||||
|
||||
return mergedData
|
||||
if (!payloadLivePreviewFieldSchema && fieldSchemaJSON) {
|
||||
payloadLivePreviewFieldSchema = fieldSchemaJSON
|
||||
}
|
||||
|
||||
if (!payloadLivePreviewFieldSchema) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
'Payload Live Preview: No `fieldSchemaJSON` was received from the parent window. Unable to merge data.',
|
||||
)
|
||||
|
||||
return initialData
|
||||
}
|
||||
|
||||
const mergedData = await mergeData<T>({
|
||||
apiRoute,
|
||||
depth,
|
||||
externallyUpdatedRelationship,
|
||||
fieldSchema: payloadLivePreviewFieldSchema,
|
||||
incomingData: data,
|
||||
initialData: payloadLivePreviewPreviousData || initialData,
|
||||
serverURL,
|
||||
})
|
||||
|
||||
payloadLivePreviewPreviousData = mergedData
|
||||
|
||||
return mergedData
|
||||
}
|
||||
|
||||
return initialData
|
||||
|
||||
@@ -1,39 +1,83 @@
|
||||
import type { PaginatedDocs } from 'payload/database'
|
||||
import type { fieldSchemaToJSON } from 'payload/utilities'
|
||||
|
||||
import type { PopulationsByCollection, UpdatedDocument } from './types'
|
||||
|
||||
import { traverseFields } from './traverseFields'
|
||||
|
||||
export type MergeLiveDataArgs<T> = {
|
||||
export const mergeData = async <T>(args: {
|
||||
apiRoute?: string
|
||||
depth: number
|
||||
depth?: number
|
||||
externallyUpdatedRelationship?: UpdatedDocument
|
||||
fieldSchema: ReturnType<typeof fieldSchemaToJSON>
|
||||
incomingData: Partial<T>
|
||||
initialData: T
|
||||
returnNumberOfRequests?: boolean
|
||||
serverURL: string
|
||||
}
|
||||
|
||||
export const mergeData = async <T>({
|
||||
apiRoute,
|
||||
depth,
|
||||
fieldSchema,
|
||||
incomingData,
|
||||
initialData,
|
||||
serverURL,
|
||||
}: MergeLiveDataArgs<T>): Promise<T> => {
|
||||
const result = { ...initialData }
|
||||
|
||||
const populationPromises: Promise<void>[] = []
|
||||
|
||||
traverseFields({
|
||||
}): Promise<
|
||||
T & {
|
||||
_numberOfRequests?: number
|
||||
}
|
||||
> => {
|
||||
const {
|
||||
apiRoute,
|
||||
depth,
|
||||
externallyUpdatedRelationship,
|
||||
fieldSchema,
|
||||
incomingData,
|
||||
populationPromises,
|
||||
result,
|
||||
initialData,
|
||||
returnNumberOfRequests,
|
||||
serverURL,
|
||||
} = args
|
||||
|
||||
const result = { ...initialData }
|
||||
|
||||
const populationsByCollection: PopulationsByCollection = {}
|
||||
|
||||
traverseFields({
|
||||
externallyUpdatedRelationship,
|
||||
fieldSchema,
|
||||
incomingData,
|
||||
populationsByCollection,
|
||||
result,
|
||||
})
|
||||
|
||||
await Promise.all(populationPromises)
|
||||
await Promise.all(
|
||||
Object.entries(populationsByCollection).map(async ([collection, populations]) => {
|
||||
const ids = new Set(populations.map(({ id }) => id))
|
||||
const url = `${serverURL}${
|
||||
apiRoute || '/api'
|
||||
}/${collection}?depth=${depth}&where[id][in]=${Array.from(ids).join(',')}`
|
||||
|
||||
return result
|
||||
let res: PaginatedDocs
|
||||
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}).then((res) => res.json())
|
||||
|
||||
if (res?.docs?.length > 0) {
|
||||
res.docs.forEach((doc) => {
|
||||
populationsByCollection[collection].forEach((population) => {
|
||||
if (population.id === doc.id) {
|
||||
population.ref[population.accessor] = doc
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err) // eslint-disable-line no-console
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
return {
|
||||
...result,
|
||||
...(returnNumberOfRequests
|
||||
? { _numberOfRequests: Object.keys(populationsByCollection).length }
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
type Args = {
|
||||
accessor: number | string
|
||||
apiRoute?: string
|
||||
collection: string
|
||||
depth: number
|
||||
id: number | string
|
||||
ref: Record<string, unknown>
|
||||
serverURL: string
|
||||
}
|
||||
|
||||
export const promise = async ({
|
||||
id,
|
||||
accessor,
|
||||
apiRoute,
|
||||
collection,
|
||||
depth,
|
||||
ref,
|
||||
serverURL,
|
||||
}: Args): Promise<void> => {
|
||||
const res: any = await fetch(
|
||||
`${serverURL}${apiRoute || '/api'}/${collection}/${id}?depth=${depth}`,
|
||||
).then((res) => res.json())
|
||||
|
||||
ref[accessor] = res
|
||||
}
|
||||
@@ -8,10 +8,10 @@ export const ready = (args: { serverURL: string }): void => {
|
||||
const windowToPostTo: Window = window?.opener || window?.parent
|
||||
|
||||
windowToPostTo?.postMessage(
|
||||
JSON.stringify({
|
||||
{
|
||||
ready: true,
|
||||
type: 'payload-live-preview',
|
||||
}),
|
||||
},
|
||||
serverURL,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { handleMessage } from '.'
|
||||
|
||||
export const subscribe = <T>(args: {
|
||||
apiRoute?: string
|
||||
callback: (data: T) => void
|
||||
depth: number
|
||||
depth?: number
|
||||
initialData: T
|
||||
serverURL: string
|
||||
}): ((event: MessageEvent) => void) => {
|
||||
const { callback, depth, initialData, serverURL } = args
|
||||
const { apiRoute, callback, depth, initialData, serverURL } = args
|
||||
|
||||
const onMessage = async (event: MessageEvent) => {
|
||||
const mergedData = await handleMessage<T>({ depth, event, initialData, serverURL })
|
||||
const mergedData = await handleMessage<T>({ apiRoute, depth, event, initialData, serverURL })
|
||||
callback(mergedData)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,83 +1,92 @@
|
||||
import type { fieldSchemaToJSON } from 'payload/utilities'
|
||||
|
||||
import { promise } from './promise'
|
||||
import type { PopulationsByCollection, UpdatedDocument } from './types'
|
||||
|
||||
type Args<T> = {
|
||||
apiRoute?: string
|
||||
depth: number
|
||||
import { traverseRichText } from './traverseRichText'
|
||||
|
||||
export const traverseFields = <T>(args: {
|
||||
externallyUpdatedRelationship?: UpdatedDocument
|
||||
fieldSchema: ReturnType<typeof fieldSchemaToJSON>
|
||||
incomingData: T
|
||||
populationPromises: Promise<void>[]
|
||||
populationsByCollection: PopulationsByCollection
|
||||
result: T
|
||||
serverURL: string
|
||||
}
|
||||
}): void => {
|
||||
const {
|
||||
externallyUpdatedRelationship,
|
||||
fieldSchema: fieldSchemas,
|
||||
incomingData,
|
||||
populationsByCollection,
|
||||
result,
|
||||
} = args
|
||||
|
||||
export const traverseFields = <T>({
|
||||
apiRoute,
|
||||
depth,
|
||||
fieldSchema,
|
||||
incomingData,
|
||||
populationPromises,
|
||||
result,
|
||||
serverURL,
|
||||
}: Args<T>): void => {
|
||||
fieldSchema.forEach((fieldJSON) => {
|
||||
if ('name' in fieldJSON && typeof fieldJSON.name === 'string') {
|
||||
const fieldName = fieldJSON.name
|
||||
fieldSchemas.forEach((fieldSchema) => {
|
||||
if ('name' in fieldSchema && typeof fieldSchema.name === 'string') {
|
||||
const fieldName = fieldSchema.name
|
||||
|
||||
switch (fieldSchema.type) {
|
||||
case 'richText':
|
||||
result[fieldName] = traverseRichText({
|
||||
externallyUpdatedRelationship,
|
||||
incomingData: incomingData[fieldName],
|
||||
populationsByCollection,
|
||||
result: result[fieldName],
|
||||
})
|
||||
|
||||
break
|
||||
|
||||
switch (fieldJSON.type) {
|
||||
case 'array':
|
||||
if (Array.isArray(incomingData[fieldName])) {
|
||||
result[fieldName] = incomingData[fieldName].map((row, i) => {
|
||||
const hasExistingRow =
|
||||
Array.isArray(result[fieldName]) &&
|
||||
typeof result[fieldName][i] === 'object' &&
|
||||
result[fieldName][i] !== null
|
||||
result[fieldName] = incomingData[fieldName].map((incomingRow, i) => {
|
||||
if (!result[fieldName]) {
|
||||
result[fieldName] = []
|
||||
}
|
||||
|
||||
const newRow = hasExistingRow ? { ...result[fieldName][i] } : {}
|
||||
if (!result[fieldName][i]) {
|
||||
result[fieldName][i] = {}
|
||||
}
|
||||
|
||||
traverseFields({
|
||||
apiRoute,
|
||||
depth,
|
||||
fieldSchema: fieldJSON.fields,
|
||||
incomingData: row,
|
||||
populationPromises,
|
||||
result: newRow,
|
||||
serverURL,
|
||||
externallyUpdatedRelationship,
|
||||
fieldSchema: fieldSchema.fields,
|
||||
incomingData: incomingRow,
|
||||
populationsByCollection,
|
||||
result: result[fieldName][i],
|
||||
})
|
||||
|
||||
return newRow
|
||||
return result[fieldName][i]
|
||||
})
|
||||
}
|
||||
|
||||
break
|
||||
|
||||
case 'blocks':
|
||||
if (Array.isArray(incomingData[fieldName])) {
|
||||
result[fieldName] = incomingData[fieldName].map((incomingBlock, i) => {
|
||||
const incomingBlockJSON = fieldJSON.blocks[incomingBlock.blockType]
|
||||
const incomingBlockJSON = fieldSchema.blocks[incomingBlock.blockType]
|
||||
|
||||
// Compare the index and id to determine if this block already exists in the result
|
||||
// If so, we want to use the existing block as the base, otherwise take the incoming block
|
||||
// Either way, we will traverse the fields of the block to populate relationships
|
||||
const isExistingBlock =
|
||||
Array.isArray(result[fieldName]) &&
|
||||
typeof result[fieldName][i] === 'object' &&
|
||||
result[fieldName][i] !== null &&
|
||||
result[fieldName][i].id === incomingBlock.id
|
||||
if (!result[fieldName]) {
|
||||
result[fieldName] = []
|
||||
}
|
||||
|
||||
const block = isExistingBlock ? result[fieldName][i] : incomingBlock
|
||||
if (
|
||||
!result[fieldName][i] ||
|
||||
result[fieldName][i].id !== incomingBlock.id ||
|
||||
result[fieldName][i].blockType !== incomingBlock.blockType
|
||||
) {
|
||||
result[fieldName][i] = {
|
||||
blockType: incomingBlock.blockType,
|
||||
}
|
||||
}
|
||||
|
||||
traverseFields({
|
||||
apiRoute,
|
||||
depth,
|
||||
externallyUpdatedRelationship,
|
||||
fieldSchema: incomingBlockJSON.fields,
|
||||
incomingData: incomingBlock,
|
||||
populationPromises,
|
||||
result: block,
|
||||
serverURL,
|
||||
populationsByCollection,
|
||||
result: result[fieldName][i],
|
||||
})
|
||||
|
||||
return block
|
||||
return result[fieldName][i]
|
||||
})
|
||||
} else {
|
||||
result[fieldName] = []
|
||||
@@ -92,135 +101,169 @@ export const traverseFields = <T>({
|
||||
}
|
||||
|
||||
traverseFields({
|
||||
apiRoute,
|
||||
depth,
|
||||
fieldSchema: fieldJSON.fields,
|
||||
externallyUpdatedRelationship,
|
||||
fieldSchema: fieldSchema.fields,
|
||||
incomingData: incomingData[fieldName] || {},
|
||||
populationPromises,
|
||||
populationsByCollection,
|
||||
result: result[fieldName],
|
||||
serverURL,
|
||||
})
|
||||
|
||||
break
|
||||
|
||||
case 'upload':
|
||||
case 'relationship':
|
||||
if (fieldJSON.hasMany && Array.isArray(incomingData[fieldName])) {
|
||||
const existingValue = Array.isArray(result[fieldName]) ? [...result[fieldName]] : []
|
||||
result[fieldName] = Array.isArray(result[fieldName])
|
||||
? [...result[fieldName]].slice(0, incomingData[fieldName].length)
|
||||
: []
|
||||
// Handle `hasMany` relationships
|
||||
if (fieldSchema.hasMany && Array.isArray(incomingData[fieldName])) {
|
||||
if (!result[fieldName] || !incomingData[fieldName].length) {
|
||||
result[fieldName] = []
|
||||
}
|
||||
|
||||
incomingData[fieldName].forEach((relation, i) => {
|
||||
incomingData[fieldName].forEach((incomingRelation, i) => {
|
||||
// Handle `hasMany` polymorphic
|
||||
if (Array.isArray(fieldJSON.relationTo)) {
|
||||
const existingID = existingValue[i]?.value?.id
|
||||
|
||||
if (
|
||||
existingID !== relation.value ||
|
||||
existingValue[i]?.relationTo !== relation.relationTo
|
||||
) {
|
||||
if (Array.isArray(fieldSchema.relationTo)) {
|
||||
// if the field doesn't exist on the result, create it
|
||||
// the value will be populated later
|
||||
if (!result[fieldName][i]) {
|
||||
result[fieldName][i] = {
|
||||
relationTo: relation.relationTo,
|
||||
relationTo: incomingRelation.relationTo,
|
||||
}
|
||||
}
|
||||
|
||||
const oldID = result[fieldName][i]?.value?.id
|
||||
const oldRelation = result[fieldName][i]?.relationTo
|
||||
const newID = incomingRelation.value
|
||||
const newRelation = incomingRelation.relationTo
|
||||
|
||||
const hasChanged = newID !== oldID || newRelation !== oldRelation
|
||||
const hasUpdated =
|
||||
newRelation === externallyUpdatedRelationship?.entitySlug &&
|
||||
newID === externallyUpdatedRelationship?.id
|
||||
|
||||
if (hasChanged || hasUpdated) {
|
||||
if (!populationsByCollection[newRelation]) {
|
||||
populationsByCollection[newRelation] = []
|
||||
}
|
||||
|
||||
populationPromises.push(
|
||||
promise({
|
||||
id: relation.value,
|
||||
accessor: 'value',
|
||||
apiRoute,
|
||||
collection: relation.relationTo,
|
||||
depth,
|
||||
ref: result[fieldName][i],
|
||||
serverURL,
|
||||
}),
|
||||
)
|
||||
populationsByCollection[newRelation].push({
|
||||
id: incomingRelation.value,
|
||||
accessor: 'value',
|
||||
ref: result[fieldName][i],
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Handle `hasMany` monomorphic
|
||||
const existingID = existingValue[i]?.id
|
||||
const hasChanged = incomingRelation !== result[fieldName][i]?.id
|
||||
const hasUpdated =
|
||||
fieldSchema.relationTo === externallyUpdatedRelationship?.entitySlug &&
|
||||
incomingRelation === externallyUpdatedRelationship?.id
|
||||
|
||||
if (existingID !== relation) {
|
||||
populationPromises.push(
|
||||
promise({
|
||||
id: relation,
|
||||
accessor: i,
|
||||
apiRoute,
|
||||
collection: String(fieldJSON.relationTo),
|
||||
depth,
|
||||
ref: result[fieldName],
|
||||
serverURL,
|
||||
}),
|
||||
)
|
||||
if (hasChanged || hasUpdated) {
|
||||
if (!populationsByCollection[fieldSchema.relationTo]) {
|
||||
populationsByCollection[fieldSchema.relationTo] = []
|
||||
}
|
||||
|
||||
populationsByCollection[fieldSchema.relationTo].push({
|
||||
id: incomingRelation,
|
||||
accessor: i,
|
||||
ref: result[fieldName],
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// Handle `hasOne` polymorphic
|
||||
if (Array.isArray(fieldJSON.relationTo)) {
|
||||
if (Array.isArray(fieldSchema.relationTo)) {
|
||||
// if the field doesn't exist on the result, create it
|
||||
// the value will be populated later
|
||||
if (!result[fieldName]) {
|
||||
result[fieldName] = {
|
||||
relationTo: incomingData[fieldName]?.relationTo,
|
||||
}
|
||||
}
|
||||
|
||||
const hasNewValue =
|
||||
typeof incomingData[fieldName] === 'object' && incomingData[fieldName] !== null
|
||||
incomingData[fieldName] &&
|
||||
typeof incomingData[fieldName] === 'object' &&
|
||||
incomingData[fieldName] !== null
|
||||
|
||||
const hasOldValue =
|
||||
typeof result[fieldName] === 'object' && result[fieldName] !== null
|
||||
result[fieldName] &&
|
||||
typeof result[fieldName] === 'object' &&
|
||||
result[fieldName] !== null
|
||||
|
||||
const newID = hasNewValue
|
||||
? typeof incomingData[fieldName].value === 'object'
|
||||
? incomingData[fieldName].value.id
|
||||
: incomingData[fieldName].value
|
||||
: ''
|
||||
|
||||
const oldID = hasOldValue
|
||||
? typeof result[fieldName].value === 'object'
|
||||
? result[fieldName].value.id
|
||||
: result[fieldName].value
|
||||
: ''
|
||||
|
||||
const newValue = hasNewValue ? incomingData[fieldName].value : ''
|
||||
const newRelation = hasNewValue ? incomingData[fieldName].relationTo : ''
|
||||
|
||||
const oldValue = hasOldValue ? result[fieldName].value : ''
|
||||
const oldRelation = hasOldValue ? result[fieldName].relationTo : ''
|
||||
|
||||
if (newValue !== oldValue || newRelation !== oldRelation) {
|
||||
if (newValue) {
|
||||
if (!result[fieldName]) {
|
||||
result[fieldName] = {
|
||||
relationTo: newRelation,
|
||||
}
|
||||
const hasChanged = newID !== oldID || newRelation !== oldRelation
|
||||
const hasUpdated =
|
||||
newRelation === externallyUpdatedRelationship?.entitySlug &&
|
||||
newID === externallyUpdatedRelationship?.id
|
||||
|
||||
// if the new value/relation is different from the old value/relation
|
||||
// populate the new value, otherwise leave it alone
|
||||
if (hasChanged || hasUpdated) {
|
||||
// if the new value is not empty, populate it
|
||||
// otherwise set the value to null
|
||||
if (newID) {
|
||||
if (!populationsByCollection[newRelation]) {
|
||||
populationsByCollection[newRelation] = []
|
||||
}
|
||||
|
||||
populationPromises.push(
|
||||
promise({
|
||||
id: newValue,
|
||||
accessor: 'value',
|
||||
apiRoute,
|
||||
collection: newRelation,
|
||||
depth,
|
||||
ref: result[fieldName],
|
||||
serverURL,
|
||||
}),
|
||||
)
|
||||
populationsByCollection[newRelation].push({
|
||||
id: newID,
|
||||
accessor: 'value',
|
||||
ref: result[fieldName],
|
||||
})
|
||||
} else {
|
||||
result[fieldName] = null
|
||||
}
|
||||
} else {
|
||||
result[fieldName] = null
|
||||
}
|
||||
} else {
|
||||
// Handle `hasOne` monomorphic
|
||||
const newID: string =
|
||||
(typeof incomingData[fieldName] === 'string' && incomingData[fieldName]) ||
|
||||
(typeof incomingData[fieldName] === 'object' &&
|
||||
incomingData[fieldName] !== null &&
|
||||
const newID: number | string | undefined =
|
||||
(incomingData[fieldName] &&
|
||||
typeof incomingData[fieldName] === 'object' &&
|
||||
incomingData[fieldName].id) ||
|
||||
''
|
||||
incomingData[fieldName]
|
||||
|
||||
const oldID: string =
|
||||
(typeof result[fieldName] === 'string' && result[fieldName]) ||
|
||||
(typeof result[fieldName] === 'object' &&
|
||||
result[fieldName] !== null &&
|
||||
const oldID: number | string | undefined =
|
||||
(result[fieldName] &&
|
||||
typeof result[fieldName] === 'object' &&
|
||||
result[fieldName].id) ||
|
||||
''
|
||||
result[fieldName]
|
||||
|
||||
if (newID !== oldID) {
|
||||
const hasChanged = newID !== oldID
|
||||
const hasUpdated =
|
||||
fieldSchema.relationTo === externallyUpdatedRelationship?.entitySlug &&
|
||||
newID === externallyUpdatedRelationship?.id
|
||||
|
||||
// if the new value is different from the old value
|
||||
// populate the new value, otherwise leave it alone
|
||||
if (hasChanged || hasUpdated) {
|
||||
// if the new value is not empty, populate it
|
||||
// otherwise set the value to null
|
||||
if (newID) {
|
||||
populationPromises.push(
|
||||
promise({
|
||||
id: newID,
|
||||
accessor: fieldName,
|
||||
apiRoute,
|
||||
collection: String(fieldJSON.relationTo),
|
||||
depth,
|
||||
ref: result as Record<string, unknown>,
|
||||
serverURL,
|
||||
}),
|
||||
)
|
||||
if (!populationsByCollection[fieldSchema.relationTo]) {
|
||||
populationsByCollection[fieldSchema.relationTo] = []
|
||||
}
|
||||
|
||||
populationsByCollection[fieldSchema.relationTo].push({
|
||||
id: newID,
|
||||
accessor: fieldName,
|
||||
ref: result as Record<string, unknown>,
|
||||
})
|
||||
} else {
|
||||
result[fieldName] = null
|
||||
}
|
||||
@@ -235,6 +278,4 @@ export const traverseFields = <T>({
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
93
packages/live-preview/src/traverseRichText.ts
Normal file
93
packages/live-preview/src/traverseRichText.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import type { PopulationsByCollection, UpdatedDocument } from './types'
|
||||
|
||||
export const traverseRichText = ({
|
||||
externallyUpdatedRelationship,
|
||||
incomingData,
|
||||
populationsByCollection,
|
||||
result,
|
||||
}: {
|
||||
externallyUpdatedRelationship?: UpdatedDocument
|
||||
incomingData: any
|
||||
populationsByCollection: PopulationsByCollection
|
||||
result: any
|
||||
}): any => {
|
||||
if (Array.isArray(incomingData)) {
|
||||
if (!result) {
|
||||
result = []
|
||||
}
|
||||
|
||||
result = incomingData.map((item, index) => {
|
||||
if (!result[index]) {
|
||||
result[index] = item
|
||||
}
|
||||
|
||||
return traverseRichText({
|
||||
externallyUpdatedRelationship,
|
||||
incomingData: item,
|
||||
populationsByCollection,
|
||||
result: result[index],
|
||||
})
|
||||
})
|
||||
} else if (incomingData && typeof incomingData === 'object') {
|
||||
if (!result) {
|
||||
result = {}
|
||||
}
|
||||
|
||||
// Remove keys from `result` that do not appear in `incomingData`
|
||||
// There's likely another way to do this,
|
||||
// But recursion and references make this very difficult
|
||||
Object.keys(result).forEach((key) => {
|
||||
if (!(key in incomingData)) {
|
||||
delete result[key]
|
||||
}
|
||||
})
|
||||
|
||||
// Iterate over the keys of `incomingData` and populate `result`
|
||||
Object.keys(incomingData).forEach((key) => {
|
||||
if (!result[key]) {
|
||||
// Instantiate the key in `result` if it doesn't exist
|
||||
// Ensure its type matches the type of the `incomingData`
|
||||
// We don't have a schema to check against here
|
||||
result[key] =
|
||||
incomingData[key] && typeof incomingData[key] === 'object'
|
||||
? Array.isArray(incomingData[key])
|
||||
? []
|
||||
: {}
|
||||
: incomingData[key]
|
||||
}
|
||||
|
||||
const isRelationship = key === 'value' && 'relationTo' in incomingData
|
||||
|
||||
if (isRelationship) {
|
||||
const needsPopulation = !result.value || typeof result.value !== 'object'
|
||||
const hasChanged =
|
||||
result &&
|
||||
typeof result === 'object' &&
|
||||
result.value.id === externallyUpdatedRelationship?.id
|
||||
|
||||
if (needsPopulation || hasChanged) {
|
||||
if (!populationsByCollection[incomingData.relationTo]) {
|
||||
populationsByCollection[incomingData.relationTo] = []
|
||||
}
|
||||
|
||||
populationsByCollection[incomingData.relationTo].push({
|
||||
id: incomingData[key],
|
||||
accessor: 'value',
|
||||
ref: result,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
result[key] = traverseRichText({
|
||||
externallyUpdatedRelationship,
|
||||
incomingData: incomingData[key],
|
||||
populationsByCollection,
|
||||
result: result[key],
|
||||
})
|
||||
}
|
||||
})
|
||||
} else {
|
||||
result = incomingData
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -1,3 +1,18 @@
|
||||
export type LivePreviewArgs = {}
|
||||
|
||||
export type LivePreview = void
|
||||
|
||||
export type PopulationsByCollection = {
|
||||
[slug: string]: Array<{
|
||||
accessor: number | string
|
||||
id: number | string
|
||||
ref: Record<string, unknown>
|
||||
}>
|
||||
}
|
||||
|
||||
// TODO: import this from `payload/admin/components/utilities/DocumentEvents/types.ts`
|
||||
export type UpdatedDocument = {
|
||||
entitySlug: string
|
||||
id?: number | string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
@@ -26,9 +26,8 @@
|
||||
</h4>
|
||||
<hr/>
|
||||
|
||||
<h3>
|
||||
🎉 Payload 2.0 is now available! Read more in the <a target="_blank" href="https://payloadcms.com/blog/payload-2-0" rel="dofollow"><strong>announcement post</strong></a>
|
||||
</h3>
|
||||
> [!IMPORTANT]
|
||||
> 🎉 <strong>Payload 2.0 is now available!</strong> Read more in the <a target="_blank" href="https://payloadcms.com/blog/payload-2-0" rel="dofollow"><strong>announcement post</strong></a>.
|
||||
|
||||
<h3>Benefits over a regular CMS</h3>
|
||||
<ul>
|
||||
@@ -52,7 +51,7 @@ Create a cloud account, connect your GitHub, and [deploy in minutes](https://pay
|
||||
Before beginning to work with Payload, make sure you have all of the [required software](https://payloadcms.com/docs/getting-started/installation).
|
||||
|
||||
```text
|
||||
npx create-payload-app
|
||||
npx create-payload-app@latest
|
||||
```
|
||||
|
||||
Alternatively, it only takes about five minutes to [create an app from scratch](https://payloadcms.com/docs/getting-started/installation#from-scratch).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "payload",
|
||||
"version": "2.0.14",
|
||||
"version": "2.3.1",
|
||||
"description": "Node, React and MongoDB Headless CMS and Application Framework",
|
||||
"license": "MIT",
|
||||
"main": "./dist/index.js",
|
||||
@@ -106,7 +106,7 @@
|
||||
"sharp": "0.32.6",
|
||||
"ts-essentials": "7.0.3",
|
||||
"use-context-selector": "1.4.1",
|
||||
"uuid": "8.3.2"
|
||||
"uuid": "9.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@payloadcms/eslint-config": "workspace:*",
|
||||
|
||||
@@ -17,6 +17,7 @@ import { StepNavProvider } from './components/elements/StepNav'
|
||||
import { AuthProvider } from './components/utilities/Auth'
|
||||
import { ConfigProvider } from './components/utilities/Config'
|
||||
import { CustomProvider } from './components/utilities/CustomProvider'
|
||||
import { DocumentEventsProvider } from './components/utilities/DocumentEvents'
|
||||
import { I18n } from './components/utilities/I18n'
|
||||
import { LoadingOverlayProvider } from './components/utilities/LoadingOverlay'
|
||||
import { LocaleProvider } from './components/utilities/Locale'
|
||||
@@ -49,11 +50,13 @@ const Root = ({ config: incomingConfig }: { config?: SanitizedConfig }) => {
|
||||
<LocaleProvider>
|
||||
<StepNavProvider>
|
||||
<LoadingOverlayProvider>
|
||||
<NavProvider>
|
||||
<CustomProvider>
|
||||
<Routes />
|
||||
</CustomProvider>
|
||||
</NavProvider>
|
||||
<DocumentEventsProvider>
|
||||
<NavProvider>
|
||||
<CustomProvider>
|
||||
<Routes />
|
||||
</CustomProvider>
|
||||
</NavProvider>
|
||||
</DocumentEventsProvider>
|
||||
</LoadingOverlayProvider>
|
||||
</StepNavProvider>
|
||||
</LocaleProvider>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
position: sticky;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
z-index: 1;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
|
||||
@@ -56,7 +56,12 @@ export const DocumentControls: React.FC<{
|
||||
|
||||
const { i18n, t } = useTranslation('general')
|
||||
|
||||
const showDotMenu = Boolean(collection && id && !disableActions)
|
||||
const hasCreatePermission = 'create' in permissions && permissions.create?.permission
|
||||
const hasDeletePermission = 'delete' in permissions && permissions.delete?.permission
|
||||
|
||||
const showDotMenu = Boolean(
|
||||
collection && id && !disableActions && (hasCreatePermission || hasDeletePermission),
|
||||
)
|
||||
|
||||
return (
|
||||
<Gutter className={baseClass}>
|
||||
@@ -203,7 +208,7 @@ export const DocumentControls: React.FC<{
|
||||
verticalAlign="bottom"
|
||||
>
|
||||
<PopupList.ButtonGroup>
|
||||
{'create' in permissions && permissions?.create?.permission && (
|
||||
{hasCreatePermission && (
|
||||
<React.Fragment>
|
||||
<PopupList.Button
|
||||
id="action-create"
|
||||
@@ -217,7 +222,7 @@ export const DocumentControls: React.FC<{
|
||||
)}
|
||||
</React.Fragment>
|
||||
)}
|
||||
{'delete' in permissions && permissions?.delete?.permission && id && (
|
||||
{hasDeletePermission && (
|
||||
<DeleteDocument buttonId="action-delete" collection={collection} id={id} />
|
||||
)}
|
||||
</PopupList.ButtonGroup>
|
||||
|
||||
@@ -1,107 +1,109 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Button from '../Button';
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '../Button'
|
||||
|
||||
import './index.scss';
|
||||
import './index.scss'
|
||||
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
const baseClass = 'dropzone';
|
||||
const baseClass = 'dropzone'
|
||||
|
||||
type Props = {
|
||||
onChange: (e: FileList) => void;
|
||||
className?: string;
|
||||
mimeTypes?: string[];
|
||||
onChange: (e: FileList) => void
|
||||
className?: string
|
||||
mimeTypes?: string[]
|
||||
}
|
||||
|
||||
export const Dropzone: React.FC<Props> = ({ onChange, className, mimeTypes }) => {
|
||||
const dropRef = React.useRef<HTMLDivElement>(null);
|
||||
const [dragging, setDragging] = React.useState(false);
|
||||
const inputRef = React.useRef(null);
|
||||
const dropRef = React.useRef<HTMLDivElement>(null)
|
||||
const [dragging, setDragging] = React.useState(false)
|
||||
const inputRef = React.useRef(null)
|
||||
|
||||
const { t } = useTranslation(['upload', 'general']);
|
||||
const { t } = useTranslation(['upload', 'general'])
|
||||
|
||||
const handlePaste = React.useCallback((e: ClipboardEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const handlePaste = React.useCallback(
|
||||
(e: ClipboardEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
if (e.clipboardData.files && e.clipboardData.files.length > 0) {
|
||||
onChange(e.clipboardData.files);
|
||||
}
|
||||
}, [onChange]);
|
||||
if (e.clipboardData.files && e.clipboardData.files.length > 0) {
|
||||
onChange(e.clipboardData.files)
|
||||
}
|
||||
},
|
||||
[onChange],
|
||||
)
|
||||
|
||||
const handleDragEnter = React.useCallback((e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragging(true);
|
||||
}, []);
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setDragging(true)
|
||||
}, [])
|
||||
|
||||
const handleDragLeave = React.useCallback((e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragging(false);
|
||||
}, []);
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setDragging(false)
|
||||
}, [])
|
||||
|
||||
const handleDrop = React.useCallback((e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragging(false);
|
||||
const handleDrop = React.useCallback(
|
||||
(e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setDragging(false)
|
||||
|
||||
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
||||
onChange(e.dataTransfer.files);
|
||||
setDragging(false);
|
||||
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
||||
onChange(e.dataTransfer.files)
|
||||
setDragging(false)
|
||||
|
||||
e.dataTransfer.clearData();
|
||||
}
|
||||
}, [onChange]);
|
||||
e.dataTransfer.clearData()
|
||||
}
|
||||
},
|
||||
[onChange],
|
||||
)
|
||||
|
||||
const handleFileSelection = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
onChange(e.target.files);
|
||||
}
|
||||
}, [onChange]);
|
||||
const handleFileSelection = React.useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
onChange(e.target.files)
|
||||
}
|
||||
},
|
||||
[onChange],
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
const div = dropRef.current;
|
||||
const div = dropRef.current
|
||||
|
||||
if (div) {
|
||||
div.addEventListener('dragenter', handleDragEnter);
|
||||
div.addEventListener('dragleave', handleDragLeave);
|
||||
div.addEventListener('dragover', handleDragOver);
|
||||
div.addEventListener('drop', handleDrop);
|
||||
div.addEventListener('paste', handlePaste);
|
||||
div.addEventListener('dragenter', handleDragEnter)
|
||||
div.addEventListener('dragleave', handleDragLeave)
|
||||
div.addEventListener('dragover', handleDragOver)
|
||||
div.addEventListener('drop', handleDrop)
|
||||
div.addEventListener('paste', handlePaste)
|
||||
|
||||
return () => {
|
||||
div.removeEventListener('dragenter', handleDragEnter);
|
||||
div.removeEventListener('dragleave', handleDragLeave);
|
||||
div.removeEventListener('dragover', handleDragOver);
|
||||
div.removeEventListener('drop', handleDrop);
|
||||
div.removeEventListener('paste', handlePaste);
|
||||
};
|
||||
div.removeEventListener('dragenter', handleDragEnter)
|
||||
div.removeEventListener('dragleave', handleDragLeave)
|
||||
div.removeEventListener('dragover', handleDragOver)
|
||||
div.removeEventListener('drop', handleDrop)
|
||||
div.removeEventListener('paste', handlePaste)
|
||||
}
|
||||
}
|
||||
|
||||
return () => null;
|
||||
}, [handleDragEnter, handleDragLeave, handleDrop, handlePaste]);
|
||||
return () => null
|
||||
}, [handleDragEnter, handleDragLeave, handleDrop, handlePaste])
|
||||
|
||||
const classes = [
|
||||
baseClass,
|
||||
className,
|
||||
dragging ? 'dragging' : '',
|
||||
].filter(Boolean).join(' ');
|
||||
const classes = [baseClass, className, dragging ? 'dragging' : ''].filter(Boolean).join(' ')
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={dropRef}
|
||||
className={classes}
|
||||
>
|
||||
<div ref={dropRef} className={classes}>
|
||||
<Button
|
||||
size="small"
|
||||
buttonStyle="secondary"
|
||||
onClick={() => {
|
||||
inputRef.current.click();
|
||||
inputRef.current.click()
|
||||
}}
|
||||
className={`${baseClass}__file-button`}
|
||||
>
|
||||
@@ -117,10 +119,8 @@ export const Dropzone: React.FC<Props> = ({ onChange, className, mimeTypes }) =>
|
||||
/>
|
||||
|
||||
<p className={`${baseClass}__label`}>
|
||||
{t('or')}
|
||||
{' '}
|
||||
{t('dragAndDrop')}
|
||||
{t('general:or')} {t('dragAndDrop')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
@@ -90,36 +90,41 @@ const Duplicate: React.FC<Props> = ({ id, collection, slug }) => {
|
||||
if (result.status === 201 || result.status === 200) {
|
||||
return json.doc.id
|
||||
}
|
||||
json.errors.forEach((error) => toast.error(error.message))
|
||||
|
||||
// only show the error if this is the initial request failing
|
||||
if (!duplicateID) {
|
||||
json.errors.forEach((error) => toast.error(error.message))
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
let duplicateID
|
||||
let duplicateID: string
|
||||
let abort = false
|
||||
const localeErrors = []
|
||||
|
||||
if (localization) {
|
||||
await localization.localeCodes.reduce(async (priorLocalePatch, locale) => {
|
||||
await priorLocalePatch
|
||||
if (abort) return
|
||||
duplicateID = await saveDocument({ id, duplicateID, locale })
|
||||
const localeResult = await saveDocument({
|
||||
id,
|
||||
duplicateID,
|
||||
locale,
|
||||
})
|
||||
duplicateID = localeResult || duplicateID
|
||||
if (duplicateID && !localeResult) {
|
||||
localeErrors.push(locale)
|
||||
}
|
||||
if (!duplicateID) {
|
||||
abort = true
|
||||
}
|
||||
}, Promise.resolve())
|
||||
|
||||
if (abort && duplicateID) {
|
||||
// delete the duplicate doc to prevent incomplete
|
||||
await requests.delete(`${serverURL}${api}/${slug}/${duplicateID}`, {
|
||||
headers: {
|
||||
'Accept-Language': i18n.language,
|
||||
},
|
||||
})
|
||||
}
|
||||
} else {
|
||||
duplicateID = await saveDocument({ id })
|
||||
}
|
||||
|
||||
if (!duplicateID) {
|
||||
// document was not saved, error toast was displayed
|
||||
return
|
||||
}
|
||||
|
||||
@@ -128,6 +133,16 @@ const Duplicate: React.FC<Props> = ({ id, collection, slug }) => {
|
||||
{ autoClose: 3000 },
|
||||
)
|
||||
|
||||
if (localeErrors.length > 0) {
|
||||
toast.error(
|
||||
`
|
||||
${t('error:localesNotSaved', { count: localeErrors.length })}
|
||||
${localeErrors.join(', ')}
|
||||
`,
|
||||
{ autoClose: 5000 },
|
||||
)
|
||||
}
|
||||
|
||||
setModified(false)
|
||||
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -70,6 +70,11 @@ $header-height: base(5);
|
||||
}
|
||||
}
|
||||
|
||||
&__draggable {
|
||||
@include btn-reset;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
&__focalPoint {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
|
||||
@@ -83,7 +83,7 @@ export const EditUpload: React.FC<{
|
||||
setFormQueryParams({
|
||||
...formQueryParams,
|
||||
uploadEdits: {
|
||||
crop: crop ? crop : undefined,
|
||||
crop: crop || undefined,
|
||||
focalPoint: pointPosition ? pointPosition : undefined,
|
||||
},
|
||||
})
|
||||
@@ -164,7 +164,15 @@ export const EditUpload: React.FC<{
|
||||
/>
|
||||
</ReactCrop>
|
||||
) : (
|
||||
<img alt={t('upload:setFocalPoint')} ref={imageRef} src={fileSrcToUse} />
|
||||
<img
|
||||
alt={t('upload:setFocalPoint')}
|
||||
onLoad={(e) => {
|
||||
setOriginalHeight(e.currentTarget.naturalHeight)
|
||||
setOriginalWidth(e.currentTarget.naturalWidth)
|
||||
}}
|
||||
ref={imageRef}
|
||||
src={fileSrcToUse}
|
||||
/>
|
||||
)}
|
||||
{showFocalPoint && (
|
||||
<DraggableElement
|
||||
@@ -273,7 +281,7 @@ const DraggableElement = ({
|
||||
}) => {
|
||||
const [position, setPosition] = useState({ x: initialPosition.x, y: initialPosition.y })
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const dragRef = useRef<HTMLDivElement | undefined>()
|
||||
const dragRef = useRef<HTMLButtonElement | undefined>()
|
||||
|
||||
const getCoordinates = React.useCallback(
|
||||
(mouseXArg?: number, mouseYArg?: number, recenter?: boolean) => {
|
||||
@@ -319,7 +327,7 @@ const DraggableElement = ({
|
||||
|
||||
return { x, y }
|
||||
},
|
||||
[],
|
||||
[boundsRef, containerRef],
|
||||
)
|
||||
|
||||
const handleMouseDown = (event) => {
|
||||
@@ -349,7 +357,7 @@ const DraggableElement = ({
|
||||
setCheckBounds(false)
|
||||
return
|
||||
}
|
||||
}, [getCoordinates, isDragging, checkBounds, setCheckBounds, position.x, position.y])
|
||||
}, [getCoordinates, isDragging, checkBounds, setCheckBounds, position.x, position.y, onDragEnd])
|
||||
|
||||
React.useEffect(() => {
|
||||
setPosition({ x: initialPosition.x, y: initialPosition.y })
|
||||
@@ -365,15 +373,16 @@ const DraggableElement = ({
|
||||
.join(' ')}
|
||||
onMouseMove={handleMouseMove}
|
||||
>
|
||||
<div
|
||||
<button
|
||||
className={[`${baseClass}__draggable`, className].filter(Boolean).join(' ')}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseUp={onDrop}
|
||||
ref={dragRef}
|
||||
style={{ left: `${position.x}%`, position: 'absolute', top: `${position.y}%` }}
|
||||
style={{ left: `${position.x}%`, top: `${position.y}%` }}
|
||||
type="button"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</button>
|
||||
<div />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -38,7 +38,7 @@ const FileDetails: React.FC<Props> = (props) => {
|
||||
width={width as number}
|
||||
/>
|
||||
|
||||
{isImage(mimeType as string) && (
|
||||
{isImage(mimeType as string) && mimeType !== 'image/svg+xml' && (
|
||||
<UploadActions canEdit={canEdit} showSizePreviews={hasImageSizes && doc.filename} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,28 +1,33 @@
|
||||
import React from 'react'
|
||||
import { Chevron } from '../../..'
|
||||
import { useLocale } from '../../../utilities/Locale'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Chevron } from '../../..'
|
||||
import { getTranslation } from '../../../../../utilities/getTranslation'
|
||||
import { useLocale } from '../../../utilities/Locale'
|
||||
import './index.scss'
|
||||
|
||||
const baseClass = 'localizer-button'
|
||||
|
||||
export const LocalizerLabel: React.FC<{
|
||||
className?: string
|
||||
ariaLabel?: string
|
||||
className?: string
|
||||
}> = (props) => {
|
||||
const { className, ariaLabel } = props
|
||||
const { ariaLabel, className } = props
|
||||
const locale = useLocale()
|
||||
const { t } = useTranslation('general')
|
||||
const { i18n } = useTranslation()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[baseClass, className].filter(Boolean).join(' ')}
|
||||
aria-label={ariaLabel || t('locale')}
|
||||
className={[baseClass, className].filter(Boolean).join(' ')}
|
||||
>
|
||||
<div className={`${baseClass}__label`}>{`${t('locale')}:`}</div>
|
||||
|
||||
<span className={`${baseClass}__current-label`}>{`${locale.label}`}</span>
|
||||
<span className={`${baseClass}__current-label`}>{`${getTranslation(
|
||||
locale.label,
|
||||
i18n,
|
||||
)}`}</span>
|
||||
|
||||
<Chevron className={`${baseClass}__chevron`} />
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import qs from 'qs'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { getTranslation } from '../../../../utilities/getTranslation'
|
||||
import { useConfig } from '../../utilities/Config'
|
||||
import { useLocale } from '../../utilities/Locale'
|
||||
import { useSearchParams } from '../../utilities/SearchParams'
|
||||
@@ -18,9 +20,12 @@ const Localizer: React.FC<{
|
||||
const config = useConfig()
|
||||
const { localization } = config
|
||||
|
||||
const { i18n } = useTranslation()
|
||||
const locale = useLocale()
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
const localeLabel = getTranslation(locale.label, i18n)
|
||||
|
||||
if (localization) {
|
||||
const { locales } = localization
|
||||
|
||||
@@ -44,8 +49,8 @@ const Localizer: React.FC<{
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{locale.label}
|
||||
{locale.label !== locale.code && ` (${locale.code})`}
|
||||
{localeLabel}
|
||||
{localeLabel !== locale.code && ` (${locale.code})`}
|
||||
</PopupList.Button>
|
||||
) : null}
|
||||
|
||||
@@ -57,11 +62,12 @@ const Localizer: React.FC<{
|
||||
locale: localeOption.code,
|
||||
}
|
||||
const search = qs.stringify(newParams)
|
||||
const localeOptionLabel = getTranslation(localeOption.label, i18n)
|
||||
|
||||
return (
|
||||
<PopupList.Button key={localeOption.code} onClick={close} to={{ search }}>
|
||||
{localeOption.label}
|
||||
{localeOption.label !== localeOption.code && ` (${localeOption.code})`}
|
||||
{localeOptionLabel}
|
||||
{localeOptionLabel !== localeOption.code && ` (${localeOption.code})`}
|
||||
</PopupList.Button>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -77,7 +77,6 @@
|
||||
&__sizeOption {
|
||||
padding: base(0.5);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: base(1);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease-in-out;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import type { SanitizedCollectionConfig } from '../../../../exports/types'
|
||||
import type { FileSizes, Upload } from '../../../../uploads/types'
|
||||
@@ -23,9 +23,49 @@ const sortSizes = (sizes: FileSizes, imageSizes: Upload['imageSizes']) => {
|
||||
return orderedSizes
|
||||
}
|
||||
|
||||
type PreviewSizeCardProps = {
|
||||
active: boolean
|
||||
baseURL: string
|
||||
meta: FileSizes[0]
|
||||
name: string
|
||||
onClick?: () => void
|
||||
previewSrc: string
|
||||
}
|
||||
const PreviewSizeCard: React.FC<PreviewSizeCardProps> = ({
|
||||
name,
|
||||
active,
|
||||
baseURL,
|
||||
meta,
|
||||
onClick,
|
||||
previewSrc,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={[`${baseClass}__sizeOption`, active && `${baseClass}--selected`]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
onClick={typeof onClick === 'function' ? onClick : undefined}
|
||||
onKeyDown={(e) => {
|
||||
if (typeof onClick !== 'function') return
|
||||
if (e.key === 'Enter') onClick()
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className={`${baseClass}__image`}>
|
||||
<img alt={meta.filename} src={previewSrc} />
|
||||
</div>
|
||||
<div className={`${baseClass}__sizeMeta`}>
|
||||
<div className={`${baseClass}__sizeName`}>{name}</div>
|
||||
<Meta {...meta} staticURL={baseURL} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const PreviewSizes: React.FC<{
|
||||
collection: SanitizedCollectionConfig
|
||||
doc?: Data & {
|
||||
doc: Data & {
|
||||
sizes?: FileSizes
|
||||
}
|
||||
imageCacheTag?: string
|
||||
@@ -36,58 +76,70 @@ const PreviewSizes: React.FC<{
|
||||
const { sizes } = doc
|
||||
|
||||
const [orderedSizes, setOrderedSizes] = useState<FileSizes>(() => sortSizes(sizes, imageSizes))
|
||||
const [selectedSize, setSelectedSize] = useState<null | string>(
|
||||
orderedSizes?.[imageSizes[0]?.name]?.filename ? imageSizes[0]?.name : null,
|
||||
)
|
||||
const [selectedSize, setSelectedSize] = useState<null | string>(null)
|
||||
|
||||
const generateImageUrl = (filename) => {
|
||||
return `${staticURL}/${filename}${imageCacheTag ? `?${imageCacheTag}` : ''}`
|
||||
const generateImageUrl = (doc) => {
|
||||
if (!doc.filename) return null
|
||||
if (doc.url) return `${doc.url}${imageCacheTag ? `?${imageCacheTag}` : ''}`
|
||||
}
|
||||
useEffect(() => {
|
||||
setOrderedSizes(sortSizes(sizes, imageSizes))
|
||||
}, [sizes, imageSizes, imageCacheTag])
|
||||
|
||||
const mainPreviewSrc = generateImageUrl(`${orderedSizes[selectedSize]?.filename}`)
|
||||
const mainPreviewSrc = selectedSize
|
||||
? generateImageUrl(doc.sizes[selectedSize])
|
||||
: generateImageUrl(doc)
|
||||
|
||||
const originalImage = useMemo(
|
||||
(): FileSizes[0] => ({
|
||||
filename: doc.filename,
|
||||
filesize: doc.filesize,
|
||||
height: doc.height,
|
||||
mimeType: doc.mimeType,
|
||||
width: doc.width,
|
||||
}),
|
||||
[doc],
|
||||
)
|
||||
const originalFilename = 'Original'
|
||||
|
||||
return (
|
||||
<div className={baseClass}>
|
||||
<div className={`${baseClass}__imageWrap`}>
|
||||
<div className={`${baseClass}__meta`}>
|
||||
<div className={`${baseClass}__sizeName`}>{selectedSize}</div>
|
||||
<Meta {...(selectedSize && orderedSizes[selectedSize])} staticURL={staticURL} />
|
||||
<div className={`${baseClass}__sizeName`}>{selectedSize || originalFilename}</div>
|
||||
<Meta
|
||||
{...(selectedSize ? orderedSizes[selectedSize] : originalImage)}
|
||||
staticURL={staticURL}
|
||||
/>
|
||||
</div>
|
||||
<img alt={doc.filename} className={`${baseClass}__preview`} src={mainPreviewSrc} />
|
||||
</div>
|
||||
<div className={`${baseClass}__listWrap`}>
|
||||
<div className={`${baseClass}__list`}>
|
||||
<PreviewSizeCard
|
||||
active={!selectedSize}
|
||||
baseURL={staticURL}
|
||||
meta={originalImage}
|
||||
name={originalFilename}
|
||||
onClick={() => setSelectedSize(null)}
|
||||
previewSrc={generateImageUrl(doc)}
|
||||
/>
|
||||
|
||||
{Object.entries(orderedSizes).map(([key, val]) => {
|
||||
const selected = selectedSize === key
|
||||
const previewSrc = generateImageUrl(val.filename)
|
||||
const previewSrc = generateImageUrl(val)
|
||||
|
||||
if (previewSrc) {
|
||||
return (
|
||||
<div
|
||||
className={[`${baseClass}__sizeOption`, selected && `${baseClass}--selected`]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
<PreviewSizeCard
|
||||
active={selected}
|
||||
baseURL={staticURL}
|
||||
key={key}
|
||||
meta={val}
|
||||
name={key}
|
||||
onClick={() => setSelectedSize(key)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.keyCode === 13) {
|
||||
setSelectedSize(key)
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className={`${baseClass}__image`}>
|
||||
<img alt={val.filename} src={previewSrc} />
|
||||
</div>
|
||||
<div className={`${baseClass}__sizeMeta`}>
|
||||
<div className={`${baseClass}__sizeName`}>{key}</div>
|
||||
<Meta {...val} staticURL={staticURL} />
|
||||
</div>
|
||||
</div>
|
||||
previewSrc={previewSrc}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import qs from 'qs'
|
||||
import React, { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { useForm, useFormModified } from '../../forms/Form/context'
|
||||
import FormSubmit from '../../forms/Submit'
|
||||
import { useConfig } from '../../utilities/Config'
|
||||
import { useDocumentInfo } from '../../utilities/DocumentInfo'
|
||||
import { useLocale } from '../../utilities/Locale'
|
||||
import RenderCustomComponent from '../../utilities/RenderCustomComponent'
|
||||
|
||||
export type CustomPublishButtonProps = React.ComponentType<
|
||||
@@ -12,6 +15,7 @@ export type CustomPublishButtonProps = React.ComponentType<
|
||||
}
|
||||
>
|
||||
export type DefaultPublishButtonProps = {
|
||||
canPublish: boolean
|
||||
disabled: boolean
|
||||
id?: string
|
||||
label: string
|
||||
@@ -19,10 +23,13 @@ export type DefaultPublishButtonProps = {
|
||||
}
|
||||
const DefaultPublishButton: React.FC<DefaultPublishButtonProps> = ({
|
||||
id,
|
||||
canPublish,
|
||||
disabled,
|
||||
label,
|
||||
publish,
|
||||
}) => {
|
||||
if (!canPublish) return null
|
||||
|
||||
return (
|
||||
<FormSubmit buttonId={id} disabled={disabled} onClick={publish} size="small" type="button">
|
||||
{label}
|
||||
@@ -35,22 +42,68 @@ type Props = {
|
||||
}
|
||||
|
||||
export const Publish: React.FC<Props> = ({ CustomComponent }) => {
|
||||
const { publishedDoc, unpublishedVersions } = useDocumentInfo()
|
||||
const { submit } = useForm()
|
||||
const { code } = useLocale()
|
||||
const { id, collection, global, publishedDoc, unpublishedVersions } = useDocumentInfo()
|
||||
const [hasPublishPermission, setHasPublishPermission] = React.useState(false)
|
||||
const { getData, submit } = useForm()
|
||||
const modified = useFormModified()
|
||||
const {
|
||||
routes: { api },
|
||||
serverURL,
|
||||
} = useConfig()
|
||||
const { t } = useTranslation('version')
|
||||
|
||||
const hasNewerVersions = unpublishedVersions?.totalDocs > 0
|
||||
const canPublish = modified || hasNewerVersions || !publishedDoc
|
||||
|
||||
const publish = useCallback(() => {
|
||||
submit({
|
||||
void submit({
|
||||
overrides: {
|
||||
_status: 'published',
|
||||
},
|
||||
})
|
||||
}, [submit])
|
||||
|
||||
React.useEffect(() => {
|
||||
const fetchPublishAccess = async () => {
|
||||
let docAccessURL: string
|
||||
let operation = 'update'
|
||||
|
||||
const params = {
|
||||
locale: code || undefined,
|
||||
}
|
||||
if (global) {
|
||||
docAccessURL = `/globals/${global.slug}/access`
|
||||
} else if (collection) {
|
||||
if (!id) operation = 'create'
|
||||
docAccessURL = `/${collection.slug}/access${id ? `/${id}` : ''}`
|
||||
}
|
||||
|
||||
if (docAccessURL) {
|
||||
const data = getData()
|
||||
|
||||
const res = await fetch(`${serverURL}${api}${docAccessURL}?${qs.stringify(params)}`, {
|
||||
body: JSON.stringify({
|
||||
...data,
|
||||
_status: 'published',
|
||||
}),
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
method: 'post',
|
||||
})
|
||||
const json = await res.json()
|
||||
const result = Boolean(json?.[operation]?.permission)
|
||||
setHasPublishPermission(result)
|
||||
} else {
|
||||
setHasPublishPermission(true)
|
||||
}
|
||||
}
|
||||
|
||||
void fetchPublishAccess()
|
||||
}, [api, code, collection, getData, global, id, serverURL])
|
||||
|
||||
return (
|
||||
<RenderCustomComponent
|
||||
CustomComponent={CustomComponent}
|
||||
@@ -58,6 +111,7 @@ export const Publish: React.FC<Props> = ({ CustomComponent }) => {
|
||||
componentProps={{
|
||||
id: 'action-save',
|
||||
DefaultButton: DefaultPublishButton,
|
||||
canPublish: hasPublishPermission,
|
||||
disabled: !canPublish,
|
||||
label: t('publishChanges'),
|
||||
publish,
|
||||
|
||||
@@ -50,24 +50,24 @@ const SortColumn: React.FC<Props> = (props) => {
|
||||
{!disable && (
|
||||
<div className={`${baseClass}__buttons`}>
|
||||
<button
|
||||
type='button'
|
||||
aria-label={t('sortByLabelDirection', {
|
||||
direction: t('ascending'),
|
||||
label: getTranslation(label, i18n),
|
||||
})}
|
||||
className={[ascClasses, `${baseClass}__button`].filter(Boolean).join(' ')}
|
||||
className={[...ascClasses, `${baseClass}__button`].filter(Boolean).join(' ')}
|
||||
onClick={() => setSort(asc)}
|
||||
type="button"
|
||||
>
|
||||
<Chevron direction="up" />
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
aria-label={t('sortByLabelDirection', {
|
||||
direction: t('descending'),
|
||||
label: getTranslation(label, i18n),
|
||||
})}
|
||||
className={[descClasses, `${baseClass}__button`].filter(Boolean).join(' ')}
|
||||
className={[...descClasses, `${baseClass}__button`].filter(Boolean).join(' ')}
|
||||
onClick={() => setSort(desc)}
|
||||
type="button"
|
||||
>
|
||||
<Chevron />
|
||||
</button>
|
||||
|
||||
@@ -6,10 +6,14 @@ import DatePicker from '../../../DatePicker'
|
||||
|
||||
const baseClass = 'condition-value-date'
|
||||
|
||||
const DateField: React.FC<Props> = ({ onChange, value }) => (
|
||||
<div className={baseClass}>
|
||||
<DatePicker onChange={onChange} value={value} />
|
||||
</div>
|
||||
)
|
||||
const DateField: React.FC<Props> = ({ admin, disabled, onChange, value }) => {
|
||||
const { date } = admin || {}
|
||||
|
||||
return (
|
||||
<div className={baseClass}>
|
||||
<DatePicker {...date} onChange={onChange} readOnly={disabled} value={value} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DateField
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { Props as DateType } from '../../../../../components/elements/DatePicker/types'
|
||||
export type Props = {
|
||||
admin?: {
|
||||
date?: DateType
|
||||
}
|
||||
disabled?: boolean
|
||||
onChange: () => void
|
||||
value: Date
|
||||
}
|
||||
|
||||
@@ -7,11 +7,12 @@ import './index.scss'
|
||||
|
||||
const baseClass = 'condition-value-number'
|
||||
|
||||
const NumberField: React.FC<Props> = ({ onChange, value }) => {
|
||||
const NumberField: React.FC<Props> = ({ disabled, onChange, value }) => {
|
||||
const { t } = useTranslation('general')
|
||||
return (
|
||||
<input
|
||||
className={baseClass}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={t('enterAValue')}
|
||||
type="number"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export type Props = {
|
||||
disabled?: boolean
|
||||
onChange: (e: string) => void
|
||||
value: string
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ const baseClass = 'condition-value-relationship'
|
||||
const maxResultsPerRequest = 10
|
||||
|
||||
const RelationshipField: React.FC<Props> = (props) => {
|
||||
const { admin: { isSortable } = {}, hasMany, onChange, relationTo, value } = props
|
||||
const { admin: { isSortable } = {}, disabled, hasMany, onChange, relationTo, value } = props
|
||||
|
||||
const {
|
||||
collections,
|
||||
@@ -261,6 +261,7 @@ const RelationshipField: React.FC<Props> = (props) => {
|
||||
<div className={classes}>
|
||||
{!errorLoading && (
|
||||
<ReactSelect
|
||||
disabled={disabled}
|
||||
isMulti={hasMany}
|
||||
isSortable={isSortable}
|
||||
onChange={(selected) => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { PaginatedDocs } from '../../../../../../database/types'
|
||||
import type { RelationshipField } from '../../../../../../fields/config/types'
|
||||
|
||||
export type Props = {
|
||||
disabled?: boolean
|
||||
onChange: (val: unknown) => void
|
||||
value: unknown
|
||||
} & RelationshipField
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user