Compare commits

..

139 Commits

Author SHA1 Message Date
James
2faade7a03 chore(release): v0.13.3 2021-11-29 12:19:44 -05:00
James
89682cf034 chore: new yarn lock 2021-11-29 12:12:09 -05:00
James Mikrut
82c69a17b9 Merge pull request #372 from payloadcms/fix/sharp-m1-prebuilt
fix: upgrade sharp for prebuilt M1 binaries
2021-11-29 11:22:28 -05:00
James
f0f4dc12e5 chore(release): v0.13.2 2021-11-29 08:34:53 -05:00
James
727fbeceb4 fix: #373 2021-11-29 08:33:20 -05:00
James
5127826de0 chore(release): v0.13.1 2021-11-29 08:20:33 -05:00
James
ded891e390 fix: ensures sorting by _id instead of improper id 2021-11-29 08:18:59 -05:00
Elliot DeNolf
34f416aace fix: upgrade sharp for prebuilt M1 binaries 2021-11-28 20:21:46 -05:00
James
bc753951a0 Merge branch 'master' of github.com:payloadcms/payload 2021-11-27 10:20:05 -05:00
James
d6d76d4088 feat: only adds list search query param if value is present 2021-11-27 10:19:50 -05:00
James Mikrut
70b58e2826 Update CHANGELOG.md 2021-11-26 17:14:57 -05:00
James
43a25195b7 chore(release): v0.13.0 2021-11-26 17:12:07 -05:00
James
e01173dd51 Merge branch 'master' of github.com:payloadcms/payload 2021-11-26 17:10:20 -05:00
James
77a208fff7 docs: typescript 2021-11-26 17:10:01 -05:00
James Mikrut
cef06e3c79 Merge pull request #367 from payloadcms/feat/form-onchange
Feat/form onchange
2021-11-25 10:39:02 -05:00
James
a0fb48c9a3 fix: #358 - reuploading with existing filenames 2021-11-25 10:34:03 -05:00
James
6b150e01d3 feat: further types field based functions 2021-11-25 09:54:12 -05:00
Elliot DeNolf
820b6ad4c7 feat: more typing of generics, better commenting of properties 2021-11-25 09:41:44 -05:00
Elliot DeNolf
bb18e8250c fix: typing for collection description 2021-11-25 09:41:44 -05:00
Elliot DeNolf
b99eb8ba73 feat: smarter generics 2021-11-25 09:41:44 -05:00
Elliot DeNolf
f258c5904e feat: type payload operation calls with generics 2021-11-25 09:41:44 -05:00
James
57cab22387 chore: optimizes buildQuery legibility 2021-11-24 18:37:24 -05:00
James
7050b5285e fix: ensures non-localized relationships with many relationTos can be queried 2021-11-24 18:01:33 -05:00
James
38ee73ba2e chore: beta release 2021-11-24 17:20:10 -05:00
Jarrod Flesch
96421b3d59 fix: ensures uploads can be fetched with CORS 2021-11-24 17:15:25 -05:00
Jacob Fletcher
0245747020 feat: renames useFieldType to useField 2021-11-24 15:37:36 -05:00
Jacob Fletcher
4affdc3a93 feat: supports custom onChange handling in text, select, and upload fields 2021-11-24 15:28:41 -05:00
James
ccbe9f5137 Merge branch 'master' of github.com:payloadcms/payload 2021-11-24 15:21:55 -05:00
James
23f7efe7d1 chore: changelog 2021-11-24 15:21:52 -05:00
Jarrod Flesch
051b7d45be feat: applies upload access control to all auto-generated image sizes 2021-11-24 15:19:21 -05:00
Jacob Fletcher
3540a188a4 Merge branch 'master' of github.com:payloadcms/payload into feat/form-onchange 2021-11-24 14:25:08 -05:00
James
da6e1df293 fix: allows sync or async preview urls 2021-11-24 13:20:13 -05:00
James
01429b6570 Merge branch 'feat/preview-async' of github.com:payloadcms/payload 2021-11-24 13:19:49 -05:00
James
cdd55a1c6b chore: adds tests for querying use cases 2021-11-24 13:18:10 -05:00
Jacob Fletcher
40ca3dae61 feat: migrates admin preview to async 2021-11-24 12:32:44 -05:00
James
5d43262f42 feat: indexes filenames 2021-11-24 11:02:54 -05:00
James
bd373598b5 chore: beta release 2021-11-24 10:45:51 -05:00
James
26aaef8851 feat: merges type generation 2021-11-24 10:35:30 -05:00
James
6fd5ac2c08 feat: azure cosmos compatibility 2021-11-24 10:30:47 -05:00
Elliot DeNolf
21a810c38c feat: add id fields to generated types 2021-11-23 16:43:59 -05:00
James
91fae55d90 chore: updates type generation log message 2021-11-23 10:19:25 -05:00
James
d9e1b5ede3 fix: issue with querying by id and using comma-separated values 2021-11-23 10:18:30 -05:00
James
99a3386dd6 Merge branch 'master' of github.com:payloadcms/payload 2021-11-22 15:45:36 -05:00
James Mikrut
c49c9b0328 Merge pull request #361 from payloadcms/fix/description-type
fix: updates field description type to include react nodes
2021-11-22 12:22:20 -05:00
James Mikrut
d151003eb6 Merge pull request #364 from tejasahluwalia/fix/webp-support-admin-dashboard
fix: Include 'WebP' as image type for admin dashboard thumbnail component
2021-11-22 12:22:02 -05:00
Jacob Fletcher
3436e6173f wip: custom onchange events for text, select, and upload fields 2021-11-22 08:50:48 -05:00
Tejas Ahluwalia
b2fe27dda5 Include 'WebP' as image type
This is called in the useThumbail hook. Adding webp support for thumbnails in the admin Thumbnail component.
2021-11-21 00:05:10 +05:30
James
ed5a5ebe7e feat: finishes typing all fields 2021-11-16 21:15:49 -05:00
James
2ca76ba8ce feat: generates further field types 2021-11-16 21:01:57 -05:00
James
6dd1b0e033 feat: adds field types to type generation 2021-11-16 20:24:13 -05:00
James
5a965d2263 feat: baseline type generation 2021-11-16 18:49:42 -05:00
James
3ab9d9e740 Merge branch 'feature/ts-type-gen' of github.com:payloadcms/payload into feature/ts-type-gen 2021-11-16 18:17:31 -05:00
James
438b6b3e51 Merge branch 'master' of github.com:payloadcms/payload 2021-11-10 17:30:49 -05:00
James
40899c211b chore: beta release 2021-11-10 17:30:31 -05:00
James
b2c5b7e575 feat: ensures update hooks have access to full original docs even in spite of access control 2021-11-10 17:25:59 -05:00
Jacob Fletcher
291c193ad4 fix: updates field description type to include react nodes 2021-11-10 11:23:10 -05:00
Elliot DeNolf
7c6424ff35 chore: resolve conflicts 2021-11-09 20:00:21 -05:00
James Mikrut
a7525e2931 Merge pull request #355 from donstephan/donstephan-patch-1
Typo in docs for select field
2021-11-05 12:27:38 -04:00
Don Stephan
e7b1adf4ed Better english 2021-11-04 20:28:32 -05:00
Don Stephan
f67286be7b Typo in options label for select field 2021-11-04 20:13:17 -05:00
James
e3e41c3621 Merge branch 'feat/relationship-filter' 2021-11-01 23:16:11 -04:00
James
72fc413764 fix: ensures buildQuery works with fields as well as simultaneous or / and 2021-11-01 23:11:54 -04:00
James
463c4e60de feat: adds relationship filter field 2021-11-01 23:11:03 -04:00
James
e06df905c5 chore: beta release 2021-11-01 22:33:31 -04:00
James
8987ce1f69 chore: scaffolds relationship filter 2021-11-01 22:31:32 -04:00
James
7337169342 chore: adds query testing 2021-11-01 22:30:35 -04:00
James
bee18a5e99 chore: adds buildQuery testing 2021-11-01 22:30:16 -04:00
James
abf61d0734 fix: ensures richtext links retain proper formatting 2021-11-01 19:41:22 -04:00
James
20d4e72a95 fix: ensures 'like' query param remains functional in all cases 2021-11-01 19:05:09 -04:00
James
056f078615 chore: release beta 2021-11-01 17:12:08 -04:00
James
94c2b8d80b fix: #351 2021-11-01 17:10:11 -04:00
James
0eceb8d76c chore: improves demo collections for testing 2021-11-01 17:09:02 -04:00
James
37b21b0762 fix: ensures tquerying by relationship subpaths works 2021-11-01 17:07:42 -04:00
James
40b33d9f5e fix: bug with relationship cell when no doc is available 2021-11-01 17:07:05 -04:00
James Mikrut
7303312142 Merge pull request #349 from payloadcms/feat/query-improvements
feat: improves querying logic
2021-11-01 14:34:58 -04:00
James
57c0346a00 fix: ensures relationship field search can return more than 10 options 2021-11-01 14:34:46 -04:00
James
6b14984352 chore: removes non-functional buildQuery code 2021-10-27 13:08:42 -04:00
James
4c85747849 feat: improves querying logic 2021-10-27 00:49:27 -04:00
James
a870cc7036 chore(release): v0.12.3 2021-10-23 11:37:09 -04:00
James
b4c15ed3f3 fix: #348, relationship options appearing twice in admin ui 2021-10-23 11:34:23 -04:00
James
a0b38f6832 fix: ensures tooltips in email fields are positioned properly 2021-10-23 11:33:56 -04:00
James
83f41df82f chore(release): v0.12.2 2021-10-21 19:36:31 -04:00
James
5b36bd7b43 chore: dependencies 2021-10-21 19:35:22 -04:00
James
d443ea582c fix: improves paste html formatting 2021-10-21 19:33:50 -04:00
James
881952e1cc chore(release): v0.12.1 2021-10-21 18:40:01 -04:00
James
9d7feb9796 fix: rich text copy and paste now saves formatting properly 2021-10-21 18:38:34 -04:00
James
bc6c892e0a chore(release): v0.12.0 2021-10-21 13:22:21 -04:00
James
48315b0e67 Merge branch 'master' of github.com:payloadcms/payload 2021-10-21 13:20:37 -04:00
James
c35009f14c fix: bug where field hooks and access control couuld potentially compete 2021-10-21 13:20:23 -04:00
James Mikrut
935a483eaa Update overview.mdx 2021-10-21 09:27:42 -04:00
James
badbdca351 chore: changelog 2021-10-21 09:19:57 -04:00
James
c02e8f14c7 feat: exposes withCondition for re-use 2021-10-21 09:16:49 -04:00
James Mikrut
92cb30e921 Merge pull request #344 from payloadcms/feat/ui-field
feat: builds UI field
2021-10-20 19:33:03 -04:00
James
edb723a4fb feat: builds UI field 2021-10-20 16:56:07 -04:00
James Mikrut
dbac0724ad Update CHANGELOG.md 2021-10-20 09:27:58 -04:00
James
328585edbd chore(release): v0.11.0 2021-10-20 09:26:26 -04:00
James
914cca6b92 fix: #343 - upload rte element crashes admin when no upload collection present 2021-10-20 09:24:23 -04:00
James
e3b05f9076 chore: changelog, beta release 2021-10-19 10:35:38 -04:00
James
86e88d998f fix: #338, array / block fields with only nested array block fields break admin UI 2021-10-19 10:31:59 -04:00
James Mikrut
6d50afd864 Merge pull request #342 from payloadcms/feat/upload-url-field
feat: adds dynamic url field to upload-enabled collections
2021-10-19 10:30:38 -04:00
James
4527dda08c chore: ensures adminThumbnail uses new url field 2021-10-18 21:21:24 -04:00
James
cc4d1fd045 feat: adds dynamic url field to upload-enabled collections 2021-10-18 21:15:32 -04:00
James
3b99deda45 fix: #341 - searching on multiple relationship collections 2021-10-18 19:25:08 -04:00
James
900f05eefd feat: adds safety checks while querying on id with bad values 2021-10-18 19:23:24 -04:00
James
716c05f5d8 Merge branch 'master' of github.com:payloadcms/payload 2021-10-18 13:48:13 -04:00
James
b22c8963cb docs: accuracy 2021-10-18 13:48:02 -04:00
James
eb05b47c54 chore: changelog 2021-10-18 13:12:26 -04:00
James
ca91f47d32 feat: allows richText enter key break out functionality to be extended in custom elements 2021-10-18 13:08:57 -04:00
James Mikrut
5040ee629f Update README.md 2021-10-14 14:27:29 -04:00
Dan Ribbens
5be09ffc78 Merge branch 'master' of github.com:payloadcms/payload 2021-10-13 17:03:38 -04:00
Dan Ribbens
4c87123514 docs: fix typo 2021-10-13 17:03:21 -04:00
James
f57f81a3cb chore: publish beta 2021-10-13 16:19:38 -04:00
James
423ca01ab1 feat: improves richtext link 2021-10-13 16:14:00 -04:00
James
9eedce7345 Merge branch 'master' of github.com:payloadcms/payload 2021-10-13 09:19:48 -04:00
James
a2df67eccd fix: removes node 15 from CI 2021-10-13 09:19:39 -04:00
James Mikrut
3908c012f9 Update CHANGELOG.md 2021-10-12 21:36:10 -04:00
James
ecda271258 chore: rolls back ts / eslint updates 2021-10-12 21:35:07 -04:00
James
84f6a9d659 fix: more strict field typing 2021-10-12 21:18:12 -04:00
James
7d49302ffa fix: properly types row field 2021-10-12 19:42:37 -04:00
James
f3455aafe9 chore: changelog 2021-10-12 19:36:19 -04:00
James
fcd9c28871 fix: per page now properly modifies search query 2021-10-12 19:31:01 -04:00
James
a6fc1fdc58 feat: saves active list filters in URL, implements per-page control 2021-10-12 19:25:43 -04:00
James
630fa68714 Merge branch 'master' of github.com:payloadcms/payload 2021-10-12 11:08:37 -04:00
James
ef4f284fb0 Merge branch 'feature/per-page' of github.com:payloadcms/payload 2021-10-12 11:08:32 -04:00
James Mikrut
5a63f11ed7 Merge pull request #339 from payloadcms/fix/build-failure-code
fix: use proper error code on webpack build failure
2021-10-12 10:56:20 -04:00
Elliot DeNolf
6807637e25 wip(per-page): thread the needle, not working 2021-10-11 17:42:02 -04:00
Elliot DeNolf
d88ce2d342 feat(per-page): set and load from preferences 2021-10-11 17:09:44 -04:00
Dan Ribbens
b257e01c8d fix: make name required on field types (#337)
* fix: make name required on field types

* fix: improve typescript types
2021-10-11 15:52:18 -04:00
Elliot DeNolf
c132f2ff10 feat(per-page): add pagination to admin config 2021-10-11 11:34:25 -04:00
James
d0259ceecd chore(release): v0.10.11 2021-10-07 20:29:06 -04:00
James
fd4fbe8c8b fix: bug with local API and not passing array / block data 2021-10-07 20:25:42 -04:00
James
4432031341 chore(release): v0.10.10 2021-10-07 11:30:23 -04:00
Jarrod Flesch
932628bc14 Merge branch 'master' of github.com:payloadcms/payload 2021-10-07 10:58:39 -04:00
Jarrod Flesch
27117292f3 fix: deepObjectCopy returns Date object instead of empty object 2021-10-07 10:58:33 -04:00
Elliot DeNolf
3715e011c9 feat(admin): initial per page component 2021-10-06 21:39:12 -04:00
Elliot DeNolf
2eb81546c3 fix: use proper error code on webpack build failure 2021-10-06 10:30:56 -04:00
James
bbdeebd1d4 chore(release): v0.10.9 2021-10-05 18:13:04 -04:00
Jarrod Flesch
5056e18734 Merge branch 'master' of github.com:payloadcms/payload 2021-10-05 18:05:53 -04:00
Jarrod Flesch
e3229c55f3 fix: ensures field read access within login operation has id 2021-10-05 18:05:48 -04:00
209 changed files with 7236 additions and 3926 deletions

View File

@@ -28,8 +28,8 @@ module.exports = {
'plugin:@typescript-eslint/recommended',
],
rules: {
"no-shadow": "off",
"@typescript-eslint/no-shadow": ["error"],
'no-shadow': 'off',
'@typescript-eslint/no-shadow': ['error'],
'import/no-unresolved': [
2,
{
@@ -38,18 +38,20 @@ module.exports = {
],
},
],
}
},
},
],
rules: {
'no-sparse-arrays': 'off',
'import/no-extraneous-dependencies': ["error", { "packageDir": "./" }],
'import/no-extraneous-dependencies': ['error', { packageDir: './' }],
'react/jsx-filename-extension': [2, { extensions: ['.js', '.jsx', '.ts', '.tsx'] }],
'import/prefer-default-export': 'off',
'react/prop-types': 'off',
'react/require-default-props': 'off',
'react/no-unused-prop-types': 'off',
'no-underscore-dangle': 'off',
'no-use-before-define': 'off',
'@typescript-eslint/no-use-before-define': ['error'],
'import/extensions': [
'error',
'ignorePackages',

View File

@@ -7,7 +7,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [12.x, 14.x, 15.x, 16.x]
node-version: [12.x, 14.x, 16.x]
steps:
- uses: actions/checkout@v2

3
.gitignore vendored
View File

@@ -228,3 +228,6 @@ build
# Ignore built components
components/index.js
components/styles.css
# Ignore generated
demo/generated-types.ts

15
.vscode/launch.json vendored
View File

@@ -65,6 +65,19 @@
"name": "Launch Chrome against Localhost",
"url": "http://localhost:3000/admin",
"webRoot": "${workspaceFolder}"
}
},
{
"type": "node",
"request": "launch",
"name": "Debug Payload Generate Types",
"program": "${workspaceFolder}/src/bin/generateTypes.ts",
"env": {
"PAYLOAD_CONFIG_PATH": "demo/payload.config.ts",
},
"outFiles": [
"${workspaceFolder}/dist/**/*.js",
"!**/node_modules/**"
]
},
]
}

View File

@@ -1,3 +1,155 @@
## [0.13.3](https://github.com/payloadcms/payload/compare/v0.13.2...v0.13.3) (2021-11-29)
### Bug Fixes
* upgrade sharp for prebuilt M1 binaries ([34f416a](https://github.com/payloadcms/payload/commit/34f416aace112013359351e17c4371c30303156f))
## [0.13.2](https://github.com/payloadcms/payload/compare/v0.13.1...v0.13.2) (2021-11-29)
### Bug Fixes
* [#373](https://github.com/payloadcms/payload/issues/373) ([727fbec](https://github.com/payloadcms/payload/commit/727fbeceb4b93936ca08d0ca48ac0d2beb1ce96e))
## [0.13.1](https://github.com/payloadcms/payload/compare/v0.13.0...v0.13.1) (2021-11-29)
### Bug Fixes
* ensures sorting by _id instead of improper id ([ded891e](https://github.com/payloadcms/payload/commit/ded891e390a93f71963762c0200c97a0beec5cad))
### Features
* only adds list search query param if value is present ([d6d76d4](https://github.com/payloadcms/payload/commit/d6d76d4088a23ed43122333873ada6846c808d37))
# [0.13.0](https://github.com/payloadcms/payload/compare/v0.12.3...v0.13.0) (2021-11-26)
### Bug Fixes
* [#351](https://github.com/payloadcms/payload/issues/351) ([94c2b8d](https://github.com/payloadcms/payload/commit/94c2b8d80b046c067057d4ad089ed6a2edd656cf))
* [#358](https://github.com/payloadcms/payload/issues/358) - reuploading with existing filenames ([a0fb48c](https://github.com/payloadcms/payload/commit/a0fb48c9a37beceafc6f0638604e9946d0814635))
* allows sync or async preview urls ([da6e1df](https://github.com/payloadcms/payload/commit/da6e1df293ce46bc4d0c84645db61feea2881aa7))
* bug with relationship cell when no doc is available ([40b33d9](https://github.com/payloadcms/payload/commit/40b33d9f5e99285cb0de148dbe059259817fcad8))
3839ef75151f))
* ensures richtext links retain proper formatting ([abf61d0](https://github.com/payloadcms/payload/commit/abf61d0734c09fd0fc5c5b827cb0631e11701f71))
* ensures that querying by relationship subpaths works ([37b21b0](https://github.com/payloadcms/payload/commit/37b21b07628e892e85c2cf979d9e2c8af0d291f7))
* ensures uploads can be fetched with CORS ([96421b3](https://github.com/payloadcms/payload/commit/96421b3d59a87f8a3d781005c02344fe5d3a607f))
* typing for collection description ([bb18e82](https://github.com/payloadcms/payload/commit/bb18e8250c5742d9615e5780c1cd02d33ecca3d0))
* updates field description type to include react nodes ([291c193](https://github.com/payloadcms/payload/commit/291c193ad4a9ec8ce9310cc63c714eba10eca102))
### Features
* :tada: :tada: builds a way to automatically generate types for collections and globals!.
* :tada: dramatically improves Payload types like local API methods and hooks to function as `generic`s
* adds relationship filter field ([463c4e6](https://github.com/payloadcms/payload/commit/463c4e60de8e647fca6268b826d826f9c6e45412))
* applies upload access control to all auto-generated image sizes ([051b7d4](https://github.com/payloadcms/payload/commit/051b7d45befc331af3f73a669b2bb6467505902f))
* azure cosmos compatibility ([6fd5ac2](https://github.com/payloadcms/payload/commit/6fd5ac2c082a5a5e6f510d781b2a2e12b7b62cb9))
* ensures update hooks have access to full original docs even in spite of access control ([b2c5b7e](https://github.com/payloadcms/payload/commit/b2c5b7e5752e829c7a53c054decceb43ec33065e))
* improves querying logic ([4c85747](https://github.com/payloadcms/payload/commit/4c8574784995b1cb1f939648f4d2158286089b3d))
* indexes filenames ([5d43262](https://github.com/payloadcms/payload/commit/5d43262f42e0529a44572f398aa1ec5fd7858286))
* renames useFieldType to useField ([0245747](https://github.com/payloadcms/payload/commit/0245747020c7c039b15d055f54a4548a364d047e))
* supports custom onChange handling in text, select, and upload fields ([4affdc3](https://github.com/payloadcms/payload/commit/4affdc3a9397d70f5baacdd12753c8fc8c7d8368))
## [0.12.3](https://github.com/payloadcms/payload/compare/v0.12.2...v0.12.3) (2021-10-23)
### Bug Fixes
* [#348](https://github.com/payloadcms/payload/issues/348), relationship options appearing twice in admin ui ([b4c15ed](https://github.com/payloadcms/payload/commit/b4c15ed3f3649ea6d157987571874fb8486ab3cb))
* ensures tooltips in email fields are positioned properly ([a0b38f6](https://github.com/payloadcms/payload/commit/a0b38f68322cd7a39ca6ae08e6ffb7f57aa62171))
## [0.12.2](https://github.com/payloadcms/payload/compare/v0.12.1...v0.12.2) (2021-10-21)
### Bug Fixes
* improves paste html formatting ([d443ea5](https://github.com/payloadcms/payload/commit/d443ea582cc60be367dd3edbdcb062af0786b8ee))
## [0.12.1](https://github.com/payloadcms/payload/compare/v0.12.0...v0.12.1) (2021-10-21)
### Bug Fixes
* rich text copy and paste now saves formatting properly ([9d7feb9](https://github.com/payloadcms/payload/commit/9d7feb9796e4b76e01f4ac2d0cb117bb091aa3d5))
# [0.12.0](https://github.com/payloadcms/payload/compare/v0.11.0...v0.12.0) (2021-10-21)
### Bug Fixes
* bug where field hooks and access control couuld potentially compete ([c35009f](https://github.com/payloadcms/payload/commit/c35009f14c9403e63727d4d77af51a449a5f7b4b))
### Features
* builds UI field ([edb723a](https://github.com/payloadcms/payload/commit/edb723a4fb8b4c353a9073cc7ec5f5cfd026cbe0))
* exposes withCondition for re-use ([c02e8f1](https://github.com/payloadcms/payload/commit/c02e8f14c74a2ab9a53b0d8fd81f1083bede594e))
## [0.11.2-beta.0](https://github.com/payloadcms/payload/compare/v0.11.0...v0.11.2-beta.0) (2021-10-21)
### Features
* exposes withCondition for re-use ([c02e8f1](https://github.com/payloadcms/payload/commit/c02e8f14c74a2ab9a53b0d8fd81f1083bede594e))
## [0.11.1-beta.0](https://github.com/payloadcms/payload/compare/v0.11.0...v0.11.1-beta.0) (2021-10-20)
### Features
* builds UI field ([edb723a](https://github.com/payloadcms/payload/commit/edb723a4fb8b4c353a9073cc7ec5f5cfd026cbe0))
# [0.11.0](https://github.com/payloadcms/payload/compare/v0.10.11...v0.11.0) (2021-10-20)
### Bug Fixes
* [#338](https://github.com/payloadcms/payload/issues/338), array / block fields with only nested array block fields break admin UI ([86e88d9](https://github.com/payloadcms/payload/commit/86e88d998fbc36d7ea2456dfbc685edadff107d3))
* [#341](https://github.com/payloadcms/payload/issues/341) - searching on multiple relationship collections ([3b99ded](https://github.com/payloadcms/payload/commit/3b99deda450fbbe4a9d05c28c9c485c466872097))
* [#343](https://github.com/payloadcms/payload/issues/343) - upload rte element crashes admin when no upload collection present ([914cca6](https://github.com/payloadcms/payload/commit/914cca6b926923bd238605856a7b7125c13244e1))
* make name required on field types ([#337](https://github.com/payloadcms/payload/issues/337)) ([b257e01](https://github.com/payloadcms/payload/commit/b257e01c8dea5d22172ce4f71e4124aecc39bba8))
* more strict field typing ([84f6a9d](https://github.com/payloadcms/payload/commit/84f6a9d659fd443545f3ba7adf9f6adab98452ca))
* per page now properly modifies search query ([fcd9c28](https://github.com/payloadcms/payload/commit/fcd9c2887175396bdedc051f3f30f1080d8c5953))
* properly types row field ([7d49302](https://github.com/payloadcms/payload/commit/7d49302ffa8207498e6e70255b3be84b3ac890c1))
* removes node 15 from CI ([a2df67e](https://github.com/payloadcms/payload/commit/a2df67eccd9ab6f8c9d4982bdade9b47186c2c82))
* use proper error code on webpack build failure ([2eb8154](https://github.com/payloadcms/payload/commit/2eb81546c3b4bf1804d25ccd5307af4855c4f750))
### Features
* adds dynamic url field to upload-enabled collections ([cc4d1fd](https://github.com/payloadcms/payload/commit/cc4d1fd045ed54c6a35c7104182e6fbeadb6dac4))
* adds safety checks while querying on id with bad values ([900f05e](https://github.com/payloadcms/payload/commit/900f05eefdc63978809a88a2e1474be08afff6c6))
* **admin:** initial per page component ([3715e01](https://github.com/payloadcms/payload/commit/3715e011c97c8e30174c35c502fa7db12bc84e2c))
* allows richText enter key break out functionality to be extended in custom elements ([ca91f47](https://github.com/payloadcms/payload/commit/ca91f47d325de5211f24000c7d90b10a8fdfc544))
* improves richtext link ([423ca01](https://github.com/payloadcms/payload/commit/423ca01ab1d5d07e2f5369d82928d6c7dad052b0))
* **per-page:** add pagination to admin config ([c132f2f](https://github.com/payloadcms/payload/commit/c132f2ff10b3efdb3854ec2d5a895120ccf22002))
* **per-page:** set and load from preferences ([d88ce2d](https://github.com/payloadcms/payload/commit/d88ce2d342b20c1601b1b58470c226a9826758b3))
* saves active list filters in URL, implements per-page control ([a6fc1fd](https://github.com/payloadcms/payload/commit/a6fc1fdc5838c3d17c5a8b6cbe9a46b86c89af71))
## [0.10.11](https://github.com/payloadcms/payload/compare/v0.10.10...v0.10.11) (2021-10-08)
### Bug Fixes
* bug with local API and not passing array / block data ([fd4fbe8](https://github.com/payloadcms/payload/commit/fd4fbe8c8b492445ab29d26d9648cff4e98d5708))
## [0.10.10](https://github.com/payloadcms/payload/compare/v0.10.9...v0.10.10) (2021-10-07)
### Bug Fixes
* deepObjectCopy returns Date object instead of empty object ([2711729](https://github.com/payloadcms/payload/commit/27117292f3a4e207d9705e79f82fb78f70985915))
## [0.10.9](https://github.com/payloadcms/payload/compare/v0.10.8...v0.10.9) (2021-10-05)
### Bug Fixes
* ensures field read access within login operation has id ([e3229c5](https://github.com/payloadcms/payload/commit/e3229c55f352a2f68bbea967f816badfd265dd02))
## [0.10.8](https://github.com/payloadcms/payload/compare/v0.10.7...v0.10.8) (2021-10-04)

View File

@@ -1,5 +1,5 @@
<h1 align="center">Payload</h1>
<p align="center">A self-hosted, JavaScript headless CMS & application framework built with Express, MongoDB and React.</p>
<p align="center">A self-hosted, TypeScript / JavaScript headless CMS & application framework built with Express, MongoDB and React.</p>
<p align="center">
<a href="https://github.com/payloadcms/payload/actions">
<img src="https://github.com/payloadcms/payload/workflows/build/badge.svg">

View File

@@ -6,7 +6,8 @@ export {
useFormModified,
} from '../dist/admin/components/forms/Form/context';
export { default as useFieldType } from '../dist/admin/components/forms/useFieldType';
export { default as useField } from '../dist/admin/components/forms/useField';
export { default as useFieldType } from '../dist/admin/components/forms/useField';
export { default as Form } from '../dist/admin/components/forms/Form';
@@ -18,3 +19,5 @@ export { default as Submit } from '../dist/admin/components/forms/Submit';
export { default as Label } from '../dist/admin/components/forms/Label';
export { default as reduceFieldsToValues } from '../dist/admin/components/forms/Form/reduceFieldsToValues';
export { default as withCondition } from '../dist/admin/components/forms/withCondition';

View File

@@ -0,0 +1,7 @@
import React from 'react';
const DemoUIFieldCell: React.FC = () => (
<p>Demo UI Field Cell</p>
);
export default DemoUIFieldCell;

View File

@@ -0,0 +1,7 @@
import React from 'react';
const DemoUIField: React.FC = () => (
<p>Demo UI Field</p>
);
export default DemoUIField;

View File

@@ -5,6 +5,8 @@ import Quote from '../blocks/Quote';
import NumberBlock from '../blocks/Number';
import CallToAction from '../blocks/CallToAction';
import CollectionDescription from '../customComponents/CollectionDescription';
import DemoUIField from '../client/components/DemoUIField/Field';
import DemoUIFieldCell from '../client/components/DemoUIField/Cell';
const AllFields: CollectionConfig = {
slug: 'all-fields',
@@ -13,6 +15,7 @@ const AllFields: CollectionConfig = {
plural: 'All Fields',
},
admin: {
defaultColumns: ['text', 'demo', 'createdAt'],
useAsTitle: 'text',
preview: (doc, { token }) => {
const { text } = doc;
@@ -302,6 +305,17 @@ const AllFields: CollectionConfig = {
],
},
},
{
type: 'ui',
name: 'demo',
admin: {
position: 'sidebar',
components: {
Field: DemoUIField,
Cell: DemoUIFieldCell,
},
},
},
{
name: 'slug',
type: 'text',

View File

@@ -0,0 +1,55 @@
import React, { useCallback, useState } from 'react';
import SelectInput from '../../../../../../../src/admin/components/forms/field-types/Select';
import { Props as SelectFieldType } from '../../../../../../../src/admin/components/forms/field-types/Select/types';
import useField from '../../../../../../../src/admin/components/forms/useField';
const Select: React.FC<SelectFieldType> = (props) => {
const {
path,
name,
label,
options
} = props;
const {
value,
setValue
} = useField({
path
});
const onChange = useCallback((incomingValue) => {
const sendToCRM = async () => {
try {
const req = await fetch('https://fake-crm.com', {
method: 'post',
body: JSON.stringify({
someKey: incomingValue
})
});
const res = await req.json();
if (res.ok) {
console.log('Successfully synced to CRM.')
}
} catch (e) {
console.error(e);
}
}
sendToCRM();
setValue(incomingValue)
}, [])
return (
<SelectInput
name={name}
label={label}
options={options}
value={value as string}
onChange={onChange}
/>
)
};
export default Select;

View File

@@ -0,0 +1,35 @@
import React, { useCallback, useState } from 'react';
import TextInput from '../../../../../../../src/admin/components/forms/field-types/Text';
import { Props as TextFieldType } from '../../../../../../../src/admin/components/forms/field-types/Text/types';
import useField from '../../../../../../../src/admin/components/forms/useField';
const Text: React.FC<TextFieldType> = (props) => {
const {
path,
name,
label
} = props;
const {
value,
setValue
} = useField({
path
});
const onChange = useCallback((incomingValue) => {
const valueWithoutSpaces = incomingValue.replace(/\s/g, '');
setValue(valueWithoutSpaces)
}, [])
return (
<TextInput
name={name}
label={label}
value={value as string}
onChange={onChange}
/>
)
};
export default Text;

View File

@@ -0,0 +1,50 @@
import React, { useCallback } from 'react';
import TextInput from '../../../../../../../src/admin/components/forms/field-types/Text';
import { UIField as UIFieldType } from '../../../../../../../src/fields/config/types';
import SelectInput from '../../../../../../../src/admin/components/forms/field-types/Select';
const UIField: React.FC<UIFieldType> = () => {
const [textValue, setTextValue] = React.useState('');
const [selectValue, setSelectValue] = React.useState('');
const onTextChange = useCallback((incomingValue) => {
setTextValue(incomingValue);
}, [])
const onSelectChange = useCallback((incomingValue) => {
setSelectValue(incomingValue);
}, [])
return (
<div>
<TextInput
name="ui-text"
label="Presentation-only text field (does not submit)"
value={textValue as string}
onChange={onTextChange}
/>
<SelectInput
name="ui-select"
label="Presentation-only select field (does not submit)"
options={[
{
label: 'Option 1',
value: 'option-1'
},
{
label: 'Option 2',
value: 'option-2'
},
{
label: 'Option 3',
value: 'option-4'
}
]}
value={selectValue as string}
onChange={onSelectChange}
/>
</div>
)
};
export default UIField;

View File

@@ -0,0 +1,38 @@
import React, { useCallback, useState } from 'react';
import Upload from '../../../../../../../src/admin/components/forms/field-types/Upload';
import { Props as UploadFieldType } from '../../../../../../../src/admin/components/forms/field-types/Upload/types';
import useField from '../../../../../../../src/admin/components/forms/useField';
const Text: React.FC<UploadFieldType> = (props) => {
const {
path,
name,
label,
relationTo,
fieldTypes
} = props;
const {
value,
setValue
} = useField({
path
});
const onChange = useCallback((incomingValue) => {
setValue(incomingValue)
}, [])
return (
<Upload
relationTo={relationTo}
fieldTypes={fieldTypes}
name={name}
label={label}
value={value as string}
onChange={onChange}
/>
)
};
export default Text;

View File

@@ -1,11 +1,15 @@
import { CollectionConfig } from '../../../src/collections/config/types';
import DescriptionField from './components/fields/Description/Field';
import TextField from './components/fields/Text/Field';
import SelectField from './components/fields/Select/Field';
import UploadField from './components/fields/Upload/Field';
import DescriptionCell from './components/fields/Description/Cell';
import DescriptionFilter from './components/fields/Description/Filter';
import NestedArrayField from './components/fields/NestedArrayCustomField/Field';
import GroupField from './components/fields/Group/Field';
import NestedGroupField from './components/fields/NestedGroupCustomField/Field';
import NestedText1Field from './components/fields/NestedText1/Field';
import UIField from './components/fields/UI/Field';
import ListView from './components/views/List';
import CustomDescriptionComponent from '../../customComponents/Description';
@@ -25,11 +29,69 @@ const CustomComponents: CollectionConfig = {
unique: true,
localized: true,
},
{
name: 'text',
label: 'Custom text field (removes whitespace)',
type: 'text',
required: true,
localized: true,
admin: {
components: {
Field: TextField,
},
},
},
{
name: 'select',
label: 'Custom select field (sends value to crm)',
type: 'select',
localized: true,
options: [
{
label: 'Option 1',
value: '1',
},
{
label: 'Option 2',
value: '2',
},
{
label: 'Option 3',
value: '3',
},
],
admin: {
components: {
Field: SelectField,
},
},
},
{
name: 'ui',
label: 'UI',
type: 'ui',
admin: {
components: {
Field: UIField,
},
},
},
{
name: 'upload',
label: 'Upload',
type: 'upload',
relationTo: 'media',
localized: true,
admin: {
components: {
Field: UploadField,
},
},
},
{
name: 'description',
label: 'Description',
type: 'textarea',
required: true,
localized: true,
admin: {
components: {

View File

@@ -1,6 +1,8 @@
/* eslint-disable no-param-reassign */
import { CollectionConfig } from '../../src/collections/config/types';
/* eslint-disable no-param-reassign, no-console */
// If importing outside of demo project, should import CollectionAfterReadHook, CollectionBeforeChangeHook, etc
import { AfterChangeHook, AfterDeleteHook, AfterReadHook, BeforeChangeHook, BeforeDeleteHook, BeforeReadHook, CollectionConfig } from '../../src/collections/config/types';
import { FieldHook } from '../../src/fields/config/types';
import { Hook } from '../payload-types';
const Hooks: CollectionConfig = {
slug: 'hooks',
@@ -19,51 +21,51 @@ const Hooks: CollectionConfig = {
},
hooks: {
beforeRead: [
(operation) => {
((operation) => {
if (operation.req.headers.hook === 'beforeRead') {
console.log('before reading Hooks document');
}
},
}) as BeforeReadHook<Hook>,
],
beforeChange: [
(operation) => {
((operation) => {
if (operation.req.headers.hook === 'beforeChange') {
operation.data.description += '-beforeChangeSuffix';
}
return operation.data;
},
}) as BeforeChangeHook<Hook>,
],
beforeDelete: [
(operation) => {
((operation) => {
if (operation.req.headers.hook === 'beforeDelete') {
// TODO: Find a better hook operation to assert against in tests
operation.req.headers.hook = 'afterDelete';
}
},
}) as BeforeDeleteHook,
],
afterRead: [
(operation) => {
((operation) => {
const { doc } = operation;
doc.afterReadHook = true;
return doc;
},
}) as AfterReadHook<Hook & { afterReadHook: boolean }>,
],
afterChange: [
(operation) => {
((operation) => {
if (operation.req.headers.hook === 'afterChange') {
operation.doc.afterChangeHook = true;
}
return operation.doc;
},
}) as AfterChangeHook<Hook & { afterChangeHook: boolean }>,
],
afterDelete: [
(operation) => {
((operation) => {
if (operation.req.headers.hook === 'afterDelete') {
operation.doc.afterDeleteHook = true;
}
return operation.doc;
},
}) as AfterDeleteHook,
],
},
fields: [
@@ -77,7 +79,7 @@ const Hooks: CollectionConfig = {
localized: true,
hooks: {
afterRead: [
({ value }) => (value ? value.toUpperCase() : null),
({ value }) => (value ? value.toUpperCase() : null) as FieldHook<Hook, 'title'>,
],
},
},

View File

@@ -8,10 +8,13 @@ const Preview: CollectionConfig = {
},
admin: {
useAsTitle: 'title',
preview: (doc, { token }) => {
preview: async (doc, { token }) => {
const { title } = doc;
if (title) {
return `http://localhost:3000/previewable-posts/${title}?preview=true&token=${token}`;
const mockAsyncReq = await fetch(`http://localhost:3000/api/previewable-post?depth=0`)
const mockJSON = await mockAsyncReq.json();
const mockParam = mockJSON?.docs?.[0]?.title || '';
return `http://localhost:3000/previewable-posts/${title}?preview=true&token=${token}&mockParam=${mockParam}`;
}
return null;

View File

@@ -15,7 +15,6 @@ const RelationshipA: CollectionConfig = {
label: 'Post',
type: 'relationship',
relationTo: 'relationship-b',
localized: true,
},
{
name: 'LocalizedPost',

View File

@@ -5,11 +5,18 @@ const RelationshipB: CollectionConfig = {
access: {
read: () => true,
},
admin: {
useAsTitle: 'title',
},
labels: {
singular: 'Relationship B',
plural: 'Relationship B',
},
fields: [
{
name: 'title',
type: 'text',
},
{
name: 'post',
label: 'Post',
@@ -33,6 +40,11 @@ const RelationshipB: CollectionConfig = {
hasMany: true,
relationTo: ['localized-posts', 'previewable-post'],
},
{
name: 'nonLocalizedRelationToMany',
type: 'relationship',
relationTo: ['localized-posts', 'relationship-a'],
},
{
name: 'strictAccess',
type: 'relationship',

721
demo/payload-types.ts Normal file
View File

@@ -0,0 +1,721 @@
/* tslint:disable */
/**
* This file was automatically generated by json-schema-to-typescript.
* DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file,
* and run json-schema-to-typescript to regenerate this file.
*/
export interface Config {}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "navigation-array".
*/
export interface NavigationArray {
id: string;
array?: {
text?: string;
textarea?: string;
id?: string;
}[];
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "global-with-access".
*/
export interface GlobalWithStrictAccess {
id: string;
title: string;
relationship: (string | LocalizedPost)[];
singleRelationship: string | LocalizedPost;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "localized-posts".
*/
export interface LocalizedPost {
id: string;
title: string;
summary?: string;
description: string;
richText?: {
[k: string]: unknown;
}[];
priority: number;
localizedGroup?: {
text?: string;
};
nonLocalizedGroup?: {
text?: string;
};
nonLocalizedArray?: {
localizedEmbeddedText?: string;
id?: string;
}[];
richTextBlocks?: {
content?: {
[k: string]: unknown;
}[];
id?: string;
blockName?: string;
blockType: 'richTextBlock';
}[];
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "blocks-global".
*/
export interface BlocksGlobal {
id: string;
blocks?: (
| {
author: string | PublicUser;
quote: string;
color: string;
id?: string;
blockName?: string;
blockType: 'quote';
}
| {
label: string;
url: string;
id?: string;
blockName?: string;
blockType: 'cta';
}
)[];
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "public-users".
*/
export interface PublicUser {
id: string;
email?: string;
resetPasswordToken?: string;
resetPasswordExpiration?: string;
_verified?: boolean;
_verificationToken?: string;
loginAttempts?: number;
lockUntil?: string;
adminOnly?: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "admins".
*/
export interface Admin {
id: string;
email?: string;
resetPasswordToken?: string;
resetPasswordExpiration?: string;
enableAPIKey?: boolean;
apiKey?: string;
apiKeyIndex?: string;
loginAttempts?: number;
lockUntil?: string;
roles: ('admin' | 'editor' | 'moderator' | 'user' | 'viewer')[];
publicUser?: (string | PublicUser)[];
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "all-fields".
*/
export interface AllFields {
id: string;
text: string;
descriptionText?: string;
descriptionFunction?: string;
image?: string | Media;
select: 'option-1' | 'option-2' | 'option-3' | 'option-4';
selectMany: ('option-1' | 'option-2' | 'option-3' | 'option-4')[];
dayOnlyDateFieldExample: string;
timeOnlyDateFieldExample?: string;
radioGroupExample: 'option-1' | 'option-2' | 'option-3';
email?: string;
number?: number;
group?: {
nestedText1?: string;
nestedText2?: string;
};
array?: {
arrayText1: string;
arrayText2: string;
arrayText3?: string;
checkbox?: boolean;
id?: string;
}[];
blocks: (
| {
testEmail: string;
id?: string;
blockName?: string;
blockType: 'email';
}
| {
testNumber: number;
id?: string;
blockName?: string;
blockType: 'number';
}
| {
author: string | PublicUser;
quote: string;
color: string;
id?: string;
blockName?: string;
blockType: 'quote';
}
| {
label: string;
url: string;
id?: string;
blockName?: string;
blockType: 'cta';
}
)[];
relationship?: string | Conditions;
relationshipHasMany?: (string | LocalizedPost)[];
relationshipMultipleCollections?:
| {
value: string | LocalizedPost;
relationTo: 'localized-posts';
}
| {
value: string | Conditions;
relationTo: 'conditions';
};
textarea?: string;
richText: {
[k: string]: unknown;
}[];
slug: string;
checkbox?: boolean;
dateFieldExample?: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media".
*/
export interface Media {
id: string;
url?: string;
filename?: string;
mimeType?: string;
filesize?: number;
width?: number;
height?: number;
sizes?: {
maintainedAspectRatio?: {
url?: string;
width?: number;
height?: number;
mimeType?: string;
filesize?: number;
filename?: string;
};
tablet?: {
url?: string;
width?: number;
height?: number;
mimeType?: string;
filesize?: number;
filename?: string;
};
mobile?: {
url?: string;
width?: number;
height?: number;
mimeType?: string;
filesize?: number;
filename?: string;
};
icon?: {
url?: string;
width?: number;
height?: number;
mimeType?: string;
filesize?: number;
filename?: string;
};
};
alt: string;
foundUploadSizes?: boolean;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "conditions".
*/
export interface Conditions {
id: string;
title: string;
enableTest?: boolean;
number?: number;
simpleCondition: string;
orCondition: string;
nestedConditions?: string;
blocks: (
| {
testEmail: string;
id?: string;
blockName?: string;
blockType: 'email';
}
| {
testNumber: number;
id?: string;
blockName?: string;
blockType: 'number';
}
| {
author: string | PublicUser;
quote: string;
color: string;
id?: string;
blockName?: string;
blockType: 'quote';
}
| {
label: string;
url: string;
id?: string;
blockName?: string;
blockType: 'cta';
}
)[];
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "auto-label".
*/
export interface AutoLabel {
id: string;
autoLabelField?: string;
noLabel?: string;
labelOverride?: string;
testRelationship?: string | AllFields;
specialBlock?: {
testNumber?: number;
id?: string;
blockName?: string;
blockType: 'number';
}[];
noLabelBlock?: {
testNumber?: number;
id?: string;
blockName?: string;
blockType: 'number';
}[];
items?: {
itemName?: string;
id?: string;
}[];
noLabelArray?: {
textField?: string;
id?: string;
}[];
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "code".
*/
export interface Code {
id: string;
code: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "custom-components".
*/
export interface CustomComponent {
id: string;
title: string;
description: string;
componentDescription?: string;
array?: {
nestedArrayCustomField?: string;
id?: string;
}[];
group?: {
nestedGroupCustomField?: string;
};
nestedText1?: string;
nestedText2?: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "custom-id".
*/
export interface CustomID {
id: number;
name: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "files".
*/
export interface File {
id: string;
url?: string;
filename?: string;
mimeType?: string;
filesize?: number;
type: 'Type 1' | 'Type 2' | 'Type 3';
owner: string | Admin;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "default-values".
*/
export interface DefaultValueTest {
id: string;
text?: string;
image?: string | Media;
select?: 'option-1' | 'option-2' | 'option-3' | 'option-4';
selectMany?: ('option-1' | 'option-2' | 'option-3' | 'option-4')[];
radioGroupExample?: 'option-1' | 'option-2' | 'option-3';
email?: string;
number?: number;
group?: {
nestedText1?: string;
nestedText2?: string;
};
array?: {
arrayText1?: string;
arrayText2?: string;
arrayText3?: string;
checkbox?: boolean;
id?: string;
}[];
blocks?: (
| {
testEmail: string;
id?: string;
blockName?: string;
blockType: 'email';
}
| {
testNumber: number;
id?: string;
blockName?: string;
blockType: 'number';
}
| {
author: string | PublicUser;
quote: string;
color: string;
id?: string;
blockName?: string;
blockType: 'quote';
}
| {
label: string;
url: string;
id?: string;
blockName?: string;
blockType: 'cta';
}
)[];
relationship?: string | Conditions;
relationshipHasMany?: (string | LocalizedPost)[];
relationshipMultipleCollections?:
| {
value: string | LocalizedPost;
relationTo: 'localized-posts';
}
| {
value: string | Conditions;
relationTo: 'conditions';
};
textarea?: string;
slug?: string;
checkbox?: boolean;
richText?: {
[k: string]: unknown;
}[];
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "blocks".
*/
export interface Blocks {
id: string;
layout: (
| {
testEmail: string;
id?: string;
blockName?: string;
blockType: 'email';
}
| {
testNumber: number;
id?: string;
blockName?: string;
blockType: 'number';
}
| {
author: string | PublicUser;
quote: string;
color: string;
id?: string;
blockName?: string;
blockType: 'quote';
}
| {
label: string;
url: string;
id?: string;
blockName?: string;
blockType: 'cta';
}
)[];
nonLocalizedLayout: (
| {
testEmail: string;
id?: string;
blockName?: string;
blockType: 'email';
}
| {
testNumber: number;
id?: string;
blockName?: string;
blockType: 'number';
}
| {
author: string | PublicUser;
quote: string;
color: string;
id?: string;
blockName?: string;
blockType: 'quote';
}
| {
label: string;
url: string;
id?: string;
blockName?: string;
blockType: 'cta';
}
)[];
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "hidden-fields".
*/
export interface HiddenFields {
id: string;
title: string;
hiddenAdmin: string;
hiddenAPI: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "hooks".
*/
export interface Hook {
id: string;
title: string;
description: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "localized-arrays".
*/
export interface LocalizedArray {
id: string;
array: {
allowPublicReadability?: boolean;
arrayText1: string;
arrayText2: string;
arrayText3?: string;
id?: string;
}[];
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "local-operations".
*/
export interface LocalOperation {
id: string;
title: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "nested-arrays".
*/
export interface NestedArray {
id: string;
array: {
parentIdentifier: string;
nestedArray: {
childIdentifier: string;
deeplyNestedArray: {
grandchildIdentifier?: string;
id?: string;
}[];
id?: string;
}[];
id?: string;
}[];
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "previewable-post".
*/
export interface PreviewablePost {
id: string;
title: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "relationship-a".
*/
export interface RelationshipA {
id: string;
post?: string | RelationshipB;
LocalizedPost?: (string | LocalizedPost)[];
postLocalizedMultiple?: (
| {
value: string | LocalizedPost;
relationTo: 'localized-posts';
}
| {
value: string | AllFields;
relationTo: 'all-fields';
}
| {
value: number | CustomID;
relationTo: 'custom-id';
}
)[];
postManyRelationships?: {
value: string | RelationshipB;
relationTo: 'relationship-b';
};
postMaxDepth?: string | RelationshipB;
customID?: (number | CustomID)[];
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "relationship-b".
*/
export interface RelationshipB {
id: string;
title?: string;
post?: (string | RelationshipA)[];
postManyRelationships?:
| {
value: string | RelationshipA;
relationTo: 'relationship-a';
}
| {
value: string | Media;
relationTo: 'media';
};
localizedPosts?: (
| {
value: string | LocalizedPost;
relationTo: 'localized-posts';
}
| {
value: string | PreviewablePost;
relationTo: 'previewable-post';
}
)[];
strictAccess?: string | StrictAccess;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "strict-access".
*/
export interface StrictAccess {
id: string;
address: string;
city: string;
state: string;
zip: number;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "rich-text".
*/
export interface RichText {
id: string;
defaultRichText: {
[k: string]: unknown;
}[];
customRichText: {
[k: string]: unknown;
}[];
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "select".
*/
export interface Select {
id: string;
Select: 'one' | 'two' | 'three';
SelectHasMany: ('one' | 'two' | 'three')[];
SelectJustStrings: ('blue' | 'green' | 'yellow')[];
Radio: 'one' | 'two' | 'three';
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "validations".
*/
export interface Validation {
id: string;
text: string;
lessThan10: number;
greaterThan10LessThan50: number;
atLeast3Rows: {
greaterThan30: number;
id?: string;
}[];
array: {
lessThan20: number;
id?: string;
}[];
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "uniques".
*/
export interface Unique {
id: string;
title: string;
description?: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "unstored-media".
*/
export interface UnstoredMedia {
id: string;
url?: string;
filename?: string;
mimeType?: string;
filesize?: number;
width?: number;
height?: number;
sizes?: {
tablet?: {
url?: string;
width?: number;
height?: number;
mimeType?: string;
filesize?: number;
filename?: string;
};
};
alt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "geolocation".
*/
export interface Geolocation {
id: string;
location?: [number, number];
localizedPoint?: [number, number];
}

View File

@@ -37,6 +37,9 @@ import UnstoredMedia from './collections/UnstoredMedia';
export default buildConfig({
cookiePrefix: 'payload',
serverURL: 'http://localhost:3000',
typescript: {
outputFile: path.resolve(__dirname, './payload-types.ts'),
},
admin: {
user: 'admins',
indexHTML: path.resolve(__dirname, './client/index.html'),
@@ -45,7 +48,7 @@ export default buildConfig({
// // ogImage: '/static/find-image-here.jpg',
// // favicon: '/img/whatever.png',
// },
disable: false,
// disable: true,
scss: path.resolve(__dirname, './client/scss/overrides.scss'),
components: {
// Nav: () => (

View File

@@ -90,13 +90,13 @@ All Payload fields support the ability to swap in your own React components. So,
#### Sending and receiving values from the form
When swapping out the `Field` component, you'll be responsible for sending and receiving the field's `value` from the form itself. To do so, import the `useFieldType` hook as follows:
When swapping out the `Field` component, you'll be responsible for sending and receiving the field's `value` from the form itself. To do so, import the `useField` hook as follows:
```js
import { useFieldType } from 'payload/components/forms';
import { useField } from 'payload/components/forms';
const CustomTextField = ({ path }) => {
const { value, setValue } = useFieldType({ path });
const { value, setValue } = useField({ path });
return (
<input

View File

@@ -106,3 +106,12 @@ const ExampleCollection = {
}
```
### TypeScript
As you build your own Block configs, you might want to store them in separate files but retain typing accordingly. To do so, you can import and use Payload's `Block` type:
```js
import type { Block } from 'payload/types';
```

View File

@@ -50,6 +50,7 @@ const Pages = {
- [Text](/docs/fields/text) - simple text input
- [Textarea](/docs/fields/textarea) - allows a bit larger of a text editor
- [Upload](/docs/fields/upload) - allows local file and image upload
- [UI](/docs/fields/ui) - inject your own custom components and do whatever you need
### Field-level hooks
@@ -117,6 +118,10 @@ In addition to each field's base configuration, you can define specific traits a
| `disabled` | If a field is `disabled`, it is completely omitted from the Admin panel. |
| `hidden` | Setting a field's `hidden` property on its `admin` config will transform it into a `hidden` input type. Its value will still submit with the Admin panel's requests, but the field itself will not be visible to editors. |
### Custom components
All Payload fields support the ability to swap in your own React components with ease. For more information, including examples, [click here](/docs/admin/components#fields).
### Conditional logic
You can show and hide fields based on what other fields are doing by utilizing conditional logic on a field by field basis. The `condition` property on a field's admin config accepts a function which takes two arguments:
@@ -155,9 +160,34 @@ The `condition` function should return a boolean that will control if the field
}
```
### Custom components
If you are building custom components, and you'd like for your custom components to support conditional logic as well, you can utilize Payload's `withCondition` higher-order function by importing it and using it as follows:
All Payload fields support the ability to swap in your own React components with ease. For more information, including examples, [click here](/docs/admin/components#fields).
```js
import { withCondition } from 'payload/components/forms';
const CustomComponent = () => {
return (
<div>
<p>Hello, on my own, I won't respect my field's conditional logic.<p/>
<p>But, I will if I'm wrapped with the withCondition HOC as shown below!</p>
</div>
)
}
const CustomComponentWithCondition = withCondition(CustomComponent);
const customFieldWithCondition = {
name: 'myCustomField',
type: 'text',
admin: {
condition: (data) => Boolean(data.enableCustomField),
components: {
Field: CustomComponentWithCondition,
}
}
}
```
### Description
@@ -209,3 +239,15 @@ This example will display the number of characters allowed as the user types.
}
```
This component will count the number of characters entered.
### TypeScript
You can import the internal Payload `Field` type as well as other common field types as follows:
```js
import type {
Field,
Validate,
Condition,
} from 'payload/types';
```

View File

@@ -125,7 +125,7 @@ Custom `Leaf` objects follow a similar pattern but require you to define the `Le
type: 'richText', // required
defaultValue: [{
children: [{ text: 'Here is some default content for this field' }],
}]
}],
required: true,
admin: {
elements: [
@@ -274,3 +274,53 @@ const serialize = (children) => children.map((node, i) => {
<strong>Note:</strong><br/>
The above example is for how to render to JSX, although for plain HTML the pattern is similar. Just remove the JSX and return HTML strings instead!
</Banner>
### Built-in SlateJS Plugins
Payload comes with a few built-in SlateJS plugins which can be extended to make developing your own elements and leaves a bit easier. They will be documented here over time.
#### `shouldBreakOutOnEnter`
Payload's built-in heading elements all allow a "hard return" to "break out" of the currently active element. For example, if you hit `enter` while typing an `h1`, the `h1` will be "broken out of" and you'll be able to continue writing as the default paragraph element.
If you want to utilize this functionality within your own custom elements, you can do so by adding a custom plugin to your `element` like the following "large body" element example:
`customLargeBodyElement.js`:
```js
import Button from './Button';
import Element from './Element';
import withLargeBody from './plugin';
export default {
name: 'large-body',
Button,
Element,
plugins: [
(incomingEditor) => {
const editor = incomingEditor;
const { shouldBreakOutOnEnter } = editor;
editor.shouldBreakOutOnEnter = (element) => (element.type === 'large-body' ? true : shouldBreakOutOnEnter(element));
return editor;
}
],
};
```
Above, you can see that we are creating a custom SlateJS element with a name of `large-body`. This might render a slightly larger body copy on the frontend of your app(s). We pass it a name, button, and element&mdash;but additionally, we pass it a `plugins` array containing a single SlateJS plugin.
The plugin itself extends Payload's built-in `shouldBreakOutOnEnter` Slate function to add its own element name to the list of elements that should "break out" when the `enter` key is pressed.
### TypeScript
If you are building your own custom Rich Text elements or leaves, you may benefit from importing the following types:
```js
import type {
RichTextCustomElement,
RichTextCustomLeaf,
} from 'payload/types';
```

View File

@@ -15,7 +15,7 @@ keywords: select, multi-select, fields, config, configuration, documentation, Co
| Option | Description |
| ---------------- | ----------- |
| **`name`** * | To be used as the property name when stored and retrieved from the database. |
| **`options`** * | Array of options to allow the field to store. Can either be an array of strings, or an array of objects containing an `option` string and a `value` string. |
| **`options`** * | Array of options to allow the field to store. Can either be an array of strings, or an array of objects containing a `label` string and a `value` string. |
| **`hasMany`** | Boolean when, if set to `true`, allows this field to have many selections instead of only one. |
| **`label`** | Used as a field label in the Admin panel and to name the generated GraphQL type. |
| **`unique`** | Enforce that each entry in the Collection has a unique value for this field. |

54
docs/fields/ui.mdx Normal file
View File

@@ -0,0 +1,54 @@
---
title: UI Field
label: UI
order: 170
desc: UI fields are purely presentational and allow developers to customize the admin panel to a very fine degree, including adding actions and other functions.
keywords: custom field, react component, fields, config, configuration, documentation, Content Management System, cms, headless, javascript, node, react, express
---
<Banner >
The UI (user interface) field gives you a ton of power to add your own React components directly into the Admin panel, nested directly within your other fields. It has absolutely no effect on the data of your documents. It is presentational-only.
</Banner>
This field is helpful if you need to build in custom functionality via React components within the Admin panel. Think of it as a way for you to "plug in" your own React components directly within your other fields, so you can provide your editors with new controls exactly where you want them to go.
With this field, you can also inject custom `Cell` components that appear as additional columns within collections' List views.
**Example uses:**
- Add a custom message or block of text within the body of an Edit view to describe the purpose of surrounding fields
- Add a "Refund" button to an Order's Edit view sidebar, which might make a fetch call to a custom `refund` endpoint
- Add a "view page" button into a Pages List view to give editors a shortcut to view a page on the frontend of the site
- Build a "clear cache" button or similar mechanism to manually clear caches of specific documents
### Config
| Option | Description |
| ---------------------------- | ----------- |
| **`name`** * | A unique identifier for this field. |
| **`label`** | Human-readable label for this UI field. |
| **`admin.components.Field`** | React component to be rendered for this field within the Edit view. |
| **`admin.components.Cell`** | React component to be rendered as a Cell within collection List views. |
*\* An asterisk denotes that a property is required.*
### Example
`collections/ExampleCollection.js`
```js
{
slug: 'example-collection',
fields: [
{
type: 'ui', // required
admin: {
components: {
Field: MyCustomUIField,
Cell: MyCustomUICell,
}
}
}
]
}
```

View File

@@ -1,7 +1,7 @@
---
title: Upload Field
label: Upload
order: 170
order: 180
desc: Upload fields will allow a file to be uploaded, only from a collection supporting Uploads. Learn how to use Upload fields, see examples and options.
keywords: upload, images media, fields, config, configuration, documentation, Content Management System, cms, headless, javascript, node, react, express
---

View File

@@ -188,3 +188,26 @@ const afterLoginHook = async ({
return user;
}
```
## TypeScript
Payload exports a type for each Collection hook which can be accessed as follows:
```js
import type {
CollectionBeforeOperationHook,
CollectionBeforeValidateHook,
CollectionBeforeChangeHook,
CollectionAfterChangeHook,
CollectionAfterReadHook,
CollectionBeforeReadHook,
CollectionBeforeDeleteHook,
CollectionAfterDeleteHook,
CollectionBeforeLoginHook,
CollectionAfterLoginHook,
CollectionAfterForgotPasswordHook,
} from 'payload/types';
// Use hook types here...
}
```

View File

@@ -70,3 +70,31 @@ All field hooks can optionally modify the return value of the field before the o
<strong>Important</strong><br/>
Due to GraphQL's typed nature, you should never change the type of data that you return from a field, otherwise GraphQL will produce errors. If you need to change the shape or type of data, reconsider Field Hooks and instead evaluate if Collection / Global hooks might suit you better.
</Banner>
## TypeScript
Payload exports a type for field hooks which can be accessed and used as follows:
```js
import type { FieldHook } from 'payload/types';
// Field hook type is a generic that takes two arguments:
// 1: The document type
// 2: the value type
type ExampleFieldHook = FieldHook<ExampleDocumentType, string>;
const exampleFieldHook: ExampleFieldHook = (args) => {
const {
value, // Typed as `string` as shown above
data, // Typed as a Partial of your ExampleDocumentType
originalDoc, // Typed as ExampleDocumentType
operation,
req,
}
// Do something here...
return value; // should return a string as typed above, undefined, or null
}
```

View File

@@ -98,3 +98,20 @@ const afterReadHook = async ({
req, // full express request
}) => {...}
```
## TypeScript
Payload exports a type for each Global hook which can be accessed as follows:
```js
import type {
GlobalBeforeValidateHook,
GlobalBeforeChangeHook,
GlobalAfterChangeHook,
GlobalBeforeReadHook,
GlobalAfterReadHook,
} from 'payload/types';
// Use hook types here...
}
```

View File

@@ -0,0 +1,114 @@
---
title: Generating TypeScript Interfaces
label: Generating Types
order: 20
desc: Generate your own TypeScript interfaces based on your collections and globals.
keywords: headless cms, typescript, documentation, Content Management System, cms, headless, javascript, node, react, express
---
While building your own custom functionality into Payload, like plugins, hooks, access control functions, custom routes, GraphQL queries / mutations, or anything else, you may benefit from generating your own TypeScript types dynamically from your Payload config itself.
Run the following command in a Payload project to generate types:
```
payload generate:types
```
You can run this command whenever you need to regenerate your types, and then you can use these types in your Payload code directly.
For example, let's look at the following simple Payload config:
```ts
const config: Config = {
serverURL: process.env.PAYLOAD_PUBLIC_SERVER_URL,
admin: {
user: 'users',
}
collections: [
{
slug: 'users',
fields: [
{
name: 'name',
type: 'text',
required: true,
}
]
},
{
slug: 'posts',
admin: {
useAsTitle: 'title',
},
fields: [
{
name: 'title',
type: 'text',
},
{
name: 'author',
type: 'relationship',
relationTo: 'users',
},
]
}
]
}
```
By generating types, we'll end up with a file containing the following two TypeScript interfaces:
```ts
export interface User {
id: string;
name: string;
email?: string;
resetPasswordToken?: string;
resetPasswordExpiration?: string;
loginAttempts?: number;
lockUntil?: string;
}
export interface Post {
id: string;
title?: string;
author?: string | User;
}
```
#### Customizing the output path of your generated types
You can specify where you want your types to be generated by adding a property to your Payload config:
```
{
// the remainder of your config
typescript: {
outputFile: path.resolve(__dirname, './generated-types.ts'),
},
}
```
The above example places your types next to your Payload config itself as the file `generated-types.ts`. By default, the file will be output to your current working directory as `payload-types.ts`.
#### Adding an NPM script
<Banner type="warning">
<strong>Important:</strong><br/>
Payload needs to be able to find your config to generate your types.
</Banner>
Payload will automatically try and locate your config, but might not always be able to find it. For example, if you are working in a `/src` directory or similar, you need to tell Payload where to find your config manually by using an environment variable. If this applies to you, you can create an NPM script to make generating your types easier.
To add an NPM script to generate your types and show Payload where to find your config, open your `package.json` and update the `scripts` property to the following:
```
{
"scripts": {
"generate:types": "PAYLOAD_CONFIG_PATH=src/payload.config.ts payload generate:types",
},
}
```
Now you can run `yarn generate:types` to easily generate your types.

View File

@@ -0,0 +1,36 @@
---
title: TypeScript - Overview
label: Overview
order: 10
desc: Payload is the most powerful TypeScript headless CMS available.
keywords: headless cms, typescript, documentation, Content Management System, cms, headless, javascript, node, react, express
---
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
```
Pick a TypeScript project type to get started easily.
#### Setting up from Scratch
It's also possible to set up a TypeScript project from scratch. We plan to write up a guide for exactly how—so keep an eye out for that, too.
## Using Payload's Exported Types
Payload exports a number of types that you may find useful while writing your own plugins, hooks, access control functions, custom routes, GraphQL queries / mutations, or anything else.
##### Config Types
- [Base config](/docs/configuration/overview#typescript)
- [Collections](/docs/configuration/collections#typescript)
- [Globals](/docs/configuration/globals#typescript)
- [Fields](/docs/fields/overview#typescript)
##### Hook Types
- [Collection hooks](/docs/hooks/collections#typescript)
- [Global hooks](/docs/hooks/globals#typescript)
- [Field hooks](/docs/hooks/fields#typescript)

View File

@@ -139,7 +139,7 @@ If you are using a plugin to send your files off to a third-party file storage h
This is a fairly advanced feature. If you do disable local file storage, by default, your admin panel's thumbnails will be broken as you will not have stored a file. It will be totally up to you to use either a plugin or your own hooks to store your files in a permanent manner, as well as provide your own admin thumbnail using <strong>upload.adminThumbnail</strong>.
</Banner>
### Admin thumbnails
### Admin Thumbnails
You can specify how Payload retrieves admin thumbnails for your upload-enabled Collections. This property accepts two different configurations:

View File

@@ -1,6 +1,6 @@
{
"name": "payload",
"version": "0.10.8",
"version": "0.13.3",
"description": "Node, React and MongoDB Headless CMS and Application Framework",
"license": "SEE LICENSE IN license.md",
"author": {
@@ -34,6 +34,7 @@
"build:watch": "nodemon --watch 'src/**' --ext 'ts,tsx' --exec 'yarn build:tsc'",
"demo:build:analyze": "cross-env PAYLOAD_CONFIG_PATH=demo/payload.config.ts PAYLOAD_ANALYZE_BUNDLE=true node dist/bin/build",
"demo:build": "cross-env PAYLOAD_CONFIG_PATH=demo/payload.config.ts node dist/bin/build",
"demo:generate:types": "cross-env PAYLOAD_CONFIG_PATH=demo/payload.config.ts node dist/bin/generateTypes",
"dev": "cross-env PAYLOAD_CONFIG_PATH=demo/payload.config.ts nodemon",
"test": "yarn test:int && yarn test:client",
"pretest": "yarn build",
@@ -81,14 +82,12 @@
"@babel/register": "^7.11.5",
"@date-io/date-fns": "^2.10.6",
"@faceless-ui/collapsibles": "^1.0.0",
"@faceless-ui/modal": "^1.1.2",
"@faceless-ui/modal": "^1.1.4",
"@faceless-ui/scroll-info": "^1.2.3",
"@faceless-ui/window-info": "^1.2.4",
"@payloadcms/config-provider": "^0.1.0",
"@types/mime": "^2.0.3",
"@udecode/slate-plugins": "^0.71.9",
"assert": "^2.0.0",
"async-some": "^1.0.2",
"@types/passport-local-mongoose": "^6.1.0",
"babel-jest": "^26.3.0",
"babel-loader": "^8.1.0",
"body-parser": "^1.19.0",
@@ -107,8 +106,9 @@
"express-rate-limit": "^5.1.3",
"falsey": "^1.0.0",
"file-loader": "^6.2.0",
"find-up": "^5.0.0",
"find-up": "5.0.0",
"flatley": "^5.2.0",
"fs-extra": "^10.0.0",
"graphql": "15.4.0",
"graphql-playground-middleware-express": "^1.7.14",
"graphql-query-complexity": "^0.7.0",
@@ -120,6 +120,7 @@
"isomorphic-fetch": "^3.0.0",
"jest": "^26.6.3",
"joi": "^17.3.0",
"json-schema-to-typescript": "^10.1.5",
"jsonwebtoken": "^8.5.1",
"method-override": "^3.0.0",
"micro-memoize": "^4.0.9",
@@ -127,7 +128,7 @@
"mini-css-extract-plugin": "1.3.3",
"minimist": "^1.2.0",
"mkdirp": "^1.0.4",
"mongoose": "^5.8.9",
"mongoose": "^6.0.12",
"mongoose-paginate-v2": "^1.3.6",
"node-sass": "^6.0.1",
"nodemailer": "^6.4.2",
@@ -163,7 +164,7 @@
"sanitize-filename": "^1.6.3",
"sass": "^1.42.0",
"sass-loader": "^10.1.0",
"sharp": "^0.28.1",
"sharp": "^0.29.3",
"slate": "^0.66.2",
"slate-history": "^0.66.0",
"slate-hyperscript": "^0.66.0",
@@ -185,7 +186,6 @@
"@testing-library/react": "^11.0.4",
"@trbl/eslint-config": "^1.2.4",
"@types/asap": "^2.0.0",
"@types/autoprefixer": "^9.7.2",
"@types/babel__core": "^7.1.12",
"@types/babel__plugin-transform-runtime": "^7.9.1",
"@types/babel__preset-env": "^7.9.1",
@@ -195,11 +195,9 @@
"@types/eslint": "^7.2.6",
"@types/express": "^4.17.13",
"@types/express-fileupload": "^1.1.5",
"@types/express-graphql": "^0.9.0",
"@types/express-rate-limit": "^5.1.0",
"@types/extract-text-webpack-plugin": "^3.0.4",
"@types/file-loader": "^4.2.0",
"@types/find-up": "^4.0.0",
"@types/hapi__joi": "~17.1.6",
"@types/html-webpack-plugin": "^3.2.4",
"@types/ignore-styles": "^5.0.0",
@@ -207,13 +205,12 @@
"@types/isomorphic-fetch": "^0.0.35",
"@types/jest": "^26.0.15",
"@types/joi": "^14.3.4",
"@types/json-schema": "^7.0.9",
"@types/jsonwebtoken": "^8.5.0",
"@types/method-override": "^0.0.31",
"@types/mini-css-extract-plugin": "^1.2.1",
"@types/minimist": "^1.2.1",
"@types/mkdirp": "^1.0.1",
"@types/mongodb": "^3.5.34",
"@types/mongoose": "^5.10.1",
"@types/mongoose-paginate-v2": "^1.3.8",
"@types/node-fetch": "^2.5.7",
"@types/node-sass": "^4.11.1",
@@ -224,7 +221,6 @@
"@types/passport-anonymous": "^1.0.3",
"@types/passport-jwt": "^3.0.3",
"@types/passport-local": "^1.0.33",
"@types/passport-local-mongoose": "^4.0.13",
"@types/pino": "^6.3.4",
"@types/pluralize": "^0.0.29",
"@types/prismjs": "^1.16.2",
@@ -240,7 +236,6 @@
"@types/react-select": "^3.0.26",
"@types/sass": "^1.16.0",
"@types/sharp": "^0.26.1",
"@types/terser-webpack-plugin": "^5.0.2",
"@types/testing-library__jest-dom": "^5.9.5",
"@types/uuid": "^8.3.0",
"@types/webpack": "4.41.26",

View File

@@ -1,7 +1,5 @@
import React, { useState, useEffect } from 'react';
import getInitialState from './getInitialState';
import React, { useState } from 'react';
import flattenTopLevelFields from '../../../../utilities/flattenTopLevelFields';
import { usePreferences } from '../../utilities/Preferences';
import Pill from '../Pill';
import Plus from '../../icons/Plus';
import X from '../../icons/X';
@@ -14,37 +12,11 @@ const baseClass = 'column-selector';
const ColumnSelector: React.FC<Props> = (props) => {
const {
collection,
collection: {
admin: {
useAsTitle,
defaultColumns,
},
},
handleChange,
columns,
setColumns,
} = props;
const [fields] = useState(() => flattenTopLevelFields(collection.fields));
const [columns, setColumns] = useState(() => {
const { columns: initializedColumns } = getInitialState(fields, useAsTitle, defaultColumns);
return initializedColumns;
});
const { setPreference, getPreference } = usePreferences();
const preferenceKey = `${collection.slug}-list-columns`;
useEffect(() => {
(async () => {
const columnPreference: string[] = await getPreference<string[]>(preferenceKey);
if (columnPreference) {
// filter invalid columns to clean up removed fields
const filteredColumnPreferences = columnPreference.filter((preference: string) => fields.find((field) => (field.name === preference)));
if (filteredColumnPreferences.length > 0) setColumns(filteredColumnPreferences);
}
})();
}, [fields, getPreference, preferenceKey]);
useEffect(() => {
if (typeof handleChange === 'function') handleChange(columns);
}, [columns, handleChange]);
return (
<div className={baseClass}>
@@ -59,8 +31,8 @@ const ColumnSelector: React.FC<Props> = (props) => {
} else {
newState.unshift(field.name);
}
setColumns(newState);
setPreference(preferenceKey, newState);
}}
alignIcon="left"
key={field.name || i}

View File

@@ -2,5 +2,6 @@ import { SanitizedCollectionConfig } from '../../../../collections/config/types'
export type Props = {
collection: SanitizedCollectionConfig,
handleChange: (columns) => void,
columns: string[]
setColumns: (columns: string[]) => void,
}

View File

@@ -10,12 +10,12 @@ const baseClass = 'file-meta';
const Meta: React.FC<Props> = (props) => {
const {
filename, filesize, width, height, mimeType, staticURL,
filename, filesize, width, height, mimeType, staticURL, url,
} = props;
const { serverURL } = useConfig();
const fileURL = `${serverURL}${staticURL}/${filename}`;
const fileURL = url || `${serverURL}${staticURL}/${filename}`;
return (
<div className={baseClass}>

View File

@@ -6,4 +6,5 @@ export type Props = {
width?: number,
height?: number,
sizes?: unknown,
url?: string
}

View File

@@ -31,6 +31,7 @@ const FileDetails: React.FC<Props> = (props) => {
height,
mimeType,
sizes,
url,
} = doc;
const [moreInfoOpen, setMoreInfoOpen] = useState(false);
@@ -52,6 +53,7 @@ const FileDetails: React.FC<Props> = (props) => {
width={width as number}
height={height as number}
mimeType={mimeType as string}
url={url as string}
/>
{hasSizes && (
<Button
@@ -91,18 +93,24 @@ const FileDetails: React.FC<Props> = (props) => {
height={moreInfoOpen ? 'auto' : 0}
>
<ul className={`${baseClass}__sizes`}>
{Object.entries(sizes).map(([key, val]) => (
<li key={key}>
<div className={`${baseClass}__size-label`}>
{key}
</div>
<Meta
{...val}
mimeType={mimeType}
staticURL={staticURL}
/>
</li>
))}
{Object.entries(sizes).map(([key, val]) => {
if (val?.filename) {
return (
<li key={key}>
<div className={`${baseClass}__size-label`}>
{key}
</div>
<Meta
{...val}
mimeType={mimeType}
staticURL={staticURL}
/>
</li>
);
}
return null;
})}
</ul>
</AnimateHeight>
)}

View File

@@ -1,11 +1,15 @@
import React, { useEffect, useState } from 'react';
import React, { useState } from 'react';
import AnimateHeight from 'react-animate-height';
import { fieldAffectsData } from '../../../../fields/config/types';
import SearchFilter from '../SearchFilter';
import ColumnSelector from '../ColumnSelector';
import WhereBuilder from '../WhereBuilder';
import SortComplex from '../SortComplex';
import Button from '../Button';
import { Props } from './types';
import { useSearchParams } from '../../utilities/SearchParams';
import validateWhereQuery from '../WhereBuilder/validateWhereQuery';
import './index.scss';
@@ -13,11 +17,14 @@ const baseClass = 'list-controls';
const ListControls: React.FC<Props> = (props) => {
const {
handleChange,
collection,
enableColumns = true,
enableSort = false,
setSort,
columns,
setColumns,
handleSortChange,
handleWhereChange,
modifySearchQuery = true,
collection: {
fields,
admin: {
@@ -26,55 +33,20 @@ const ListControls: React.FC<Props> = (props) => {
},
} = props;
const [titleField, setTitleField] = useState(null);
const [search, setSearch] = useState('');
const [columns, setColumns] = useState([]);
const [where, setWhere] = useState({});
const [visibleDrawer, setVisibleDrawer] = useState<'where' | 'sort' | 'columns'>();
const params = useSearchParams();
const shouldInitializeWhereOpened = validateWhereQuery(params?.where);
useEffect(() => {
if (useAsTitle) {
const foundTitleField = fields.find((field) => field.name === useAsTitle);
if (foundTitleField) {
setTitleField(foundTitleField);
}
}
}, [useAsTitle, fields]);
useEffect(() => {
const newState: any = {
columns,
};
if (search) {
newState.where = {
and: [
search,
],
};
}
if (where) {
if (!search) {
newState.where = {
and: [],
};
}
newState.where.and.push(where);
}
handleChange(newState);
}, [search, columns, where, handleChange]);
const [titleField] = useState(() => fields.find((field) => fieldAffectsData(field) && field.name === useAsTitle));
const [visibleDrawer, setVisibleDrawer] = useState<'where' | 'sort' | 'columns'>(shouldInitializeWhereOpened ? 'where' : undefined);
return (
<div className={baseClass}>
<div className={`${baseClass}__wrap`}>
<SearchFilter
handleChange={setSearch}
fieldName={titleField ? titleField.name : undefined}
fieldLabel={titleField ? titleField.label : undefined}
fieldName={titleField && fieldAffectsData(titleField) ? titleField.name : undefined}
handleChange={handleWhereChange}
modifySearchQuery={modifySearchQuery}
fieldLabel={titleField && titleField.label ? titleField.label : undefined}
/>
<div className={`${baseClass}__buttons`}>
<div className={`${baseClass}__buttons-wrap`}>
@@ -119,7 +91,8 @@ const ListControls: React.FC<Props> = (props) => {
>
<ColumnSelector
collection={collection}
handleChange={setColumns}
columns={columns}
setColumns={setColumns}
/>
</AnimateHeight>
)}
@@ -128,8 +101,9 @@ const ListControls: React.FC<Props> = (props) => {
height={visibleDrawer === 'where' ? 'auto' : 0}
>
<WhereBuilder
handleChange={setWhere}
collection={collection}
modifySearchQuery={modifySearchQuery}
handleChange={handleWhereChange}
/>
</AnimateHeight>
{enableSort && (
@@ -138,8 +112,9 @@ const ListControls: React.FC<Props> = (props) => {
height={visibleDrawer === 'sort' ? 'auto' : 0}
>
<SortComplex
handleChange={setSort}
modifySearchQuery={modifySearchQuery}
collection={collection}
handleChange={handleSortChange}
/>
</AnimateHeight>
)}

View File

@@ -1,11 +1,15 @@
import { Where } from '../../../../types';
import { SanitizedCollectionConfig } from '../../../../collections/config/types';
export type Props = {
enableColumns?: boolean,
enableSort?: boolean,
setSort: (sort: string) => void,
modifySearchQuery?: boolean
handleSortChange?: (sort: string) => void
handleWhereChange?: (where: Where) => void
columns?: string[]
setColumns?: (columns: string[]) => void,
collection: SanitizedCollectionConfig,
handleChange: (newState) => void,
}
export type ListControls = {

View File

@@ -1,11 +1,12 @@
import React from 'react';
import { useHistory, useLocation } from 'react-router-dom';
import { useHistory } from 'react-router-dom';
import queryString from 'qs';
import { Props, Node } from './types';
import Page from './Page';
import Separator from './Separator';
import ClickableArrow from './ClickableArrow';
import { useSearchParams } from '../../utilities/SearchParams';
import './index.scss';
@@ -19,7 +20,7 @@ const baseClass = 'paginator';
const Pagination: React.FC<Props> = (props) => {
const history = useHistory();
const location = useLocation();
const params = useSearchParams();
const {
totalPages = null,
@@ -38,9 +39,12 @@ const Pagination: React.FC<Props> = (props) => {
// uses react router to set the current page
const updatePage = (page) => {
if (!disableHistoryChange) {
const params = queryString.parse(location.search, { ignoreQueryPrefix: true });
params.page = page;
history.push({ search: queryString.stringify(params, { addQueryPrefix: true }) });
const newParams = {
...params,
};
newParams.page = page;
history.push({ search: queryString.stringify(newParams, { addQueryPrefix: true }) });
}
if (typeof onChange === 'function') onChange(page);

View File

@@ -0,0 +1,38 @@
@import '../../../scss/styles.scss';
.per-page {
ul {
list-style: none;
padding: 0;
margin: 0;
}
.popup-button--default {
@extend %btn-reset;
position: relative;
cursor: pointer;
}
&__button {
@extend %btn-reset;
color: white;
cursor: pointer;
text-align: left;
width: 100%;
&:hover {
text-decoration: underline;
}
}
&__button-active {
font-weight: bold;
svg {
@include color-svg(white);
margin-left: base(-.25);
margin-right: base(-.125);
transform: rotate(-90deg);
}
}
}

View File

@@ -0,0 +1,86 @@
import React from 'react';
import qs from 'qs';
import { useHistory } from 'react-router-dom';
import { useSearchParams } from '../../utilities/SearchParams';
import Popup from '../Popup';
import Chevron from '../../icons/Chevron';
import { SanitizedCollectionConfig } from '../../../../collections/config/types';
import './index.scss';
const baseClass = 'per-page';
type Props = {
collection: SanitizedCollectionConfig
limit: number
handleChange?: (limit: number) => void
modifySearchParams?: boolean
}
const PerPage: React.FC<Props> = ({ collection, limit, handleChange, modifySearchParams = true }) => {
const {
admin: {
pagination: {
limits,
},
},
} = collection;
const params = useSearchParams();
const history = useHistory();
return (
<div className={baseClass}>
<Popup
color="dark"
horizontalAlign="right"
button={(
<strong>
Per Page:
{' '}
{limit}
<Chevron />
</strong>
)}
render={({ close }) => (
<div>
<ul>
{limits.map((limitNumber, i) => (
<li
className={`${baseClass}-item`}
key={i}
>
<button
type="button"
className={[
`${baseClass}__button`,
limitNumber === Number(limit) && `${baseClass}__button-active`,
].filter(Boolean).join(' ')}
onClick={() => {
close();
if (handleChange) handleChange(limitNumber);
if (modifySearchParams) {
history.replace({
search: qs.stringify({
...params,
limit: limitNumber,
}, { addQueryPrefix: true }),
});
}
}}
>
{limitNumber === Number(limit) && (
<Chevron />
)}
{limitNumber}
</button>
</li>
))}
</ul>
</div>
)}
/>
</div>
);
};
export default PerPage;

View File

@@ -11,7 +11,6 @@ const PopupButton: React.FC<Props> = (props) => {
button,
setActive,
active,
onToggleOpen,
} = props;
const classes = [
@@ -20,10 +19,13 @@ const PopupButton: React.FC<Props> = (props) => {
].filter(Boolean).join(' ');
const handleClick = () => {
if (typeof onToggleOpen === 'function') onToggleOpen(!active);
setActive(!active);
};
if (buttonType === 'none') {
return null;
}
if (buttonType === 'custom') {
return (
<div

View File

@@ -1,7 +1,6 @@
export type Props = {
buttonType: 'custom' | 'default',
buttonType: 'custom' | 'default' | 'none',
button: React.ReactNode,
setActive: (active: boolean) => void,
active: boolean,
onToggleOpen: (active: boolean) => void,
}

View File

@@ -23,6 +23,8 @@ const Popup: React.FC<Props> = (props) => {
horizontalAlign = 'left',
initActive = false,
onToggleOpen,
padding,
forceOpen,
} = props;
const buttonRef = useRef(null);
@@ -75,6 +77,8 @@ const Popup: React.FC<Props> = (props) => {
}, 500, [setVerticalAlign, contentRef, scrollY, windowHeight]);
useEffect(() => {
if (typeof onToggleOpen === 'function') onToggleOpen(active);
if (active) {
document.addEventListener('mousedown', handleClickOutside);
} else {
@@ -84,7 +88,11 @@ const Popup: React.FC<Props> = (props) => {
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [active]);
}, [active, onToggleOpen]);
useEffect(() => {
setActive(forceOpen);
}, [forceOpen]);
const classes = [
baseClass,
@@ -98,7 +106,9 @@ const Popup: React.FC<Props> = (props) => {
].filter(Boolean).join(' ');
return (
<div className={classes}>
<div
className={classes}
>
<div
ref={buttonRef}
className={`${baseClass}__wrapper`}
@@ -111,7 +121,6 @@ const Popup: React.FC<Props> = (props) => {
onMouseLeave={() => setActive(false)}
>
<PopupButton
onToggleOpen={onToggleOpen}
buttonType={buttonType}
button={button}
setActive={setActive}
@@ -121,7 +130,6 @@ const Popup: React.FC<Props> = (props) => {
)
: (
<PopupButton
onToggleOpen={onToggleOpen}
buttonType={buttonType}
button={button}
setActive={setActive}
@@ -134,8 +142,17 @@ const Popup: React.FC<Props> = (props) => {
className={`${baseClass}__content`}
ref={contentRef}
>
<div className={`${baseClass}__wrap`}>
<div className={`${baseClass}__scroll`}>
<div
className={`${baseClass}__wrap`}
// TODO: color ::after with bg color
>
<div
className={`${baseClass}__scroll`}
style={{
padding,
}}
>
{render && render({ close: () => setActive(false) })}
{children && children}
</div>

View File

@@ -1,3 +1,5 @@
import { CSSProperties } from 'react';
export type Props = {
className?: string
render?: (any) => void,
@@ -5,9 +7,12 @@ export type Props = {
horizontalAlign?: 'left' | 'center' | 'right',
size?: 'small' | 'large' | 'wide',
color?: 'light' | 'dark',
buttonType?: 'default' | 'custom',
buttonType?: 'default' | 'custom' | 'none',
button?: React.ReactNode,
forceOpen?: boolean
showOnHover?: boolean,
initActive?: boolean,
onToggleOpen?: () => void,
onToggleOpen?: (active: boolean) => void,
backgroundColor?: CSSProperties['backgroundColor'],
padding?: CSSProperties['padding'],
}

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import { useAuth } from '@payloadcms/config-provider';
import Button from '../Button';
import { Props } from './types';
@@ -6,13 +6,34 @@ import { useLocale } from '../../utilities/Locale';
const baseClass = 'preview-btn';
const PreviewButton: React.FC<Props> = ({ generatePreviewURL, data }) => {
const PreviewButton: React.FC<Props> = (props) => {
const {
generatePreviewURL,
data
} = props;
const [url, setUrl] = useState<string | undefined>(undefined);
const locale = useLocale();
const { token } = useAuth();
if (generatePreviewURL && typeof generatePreviewURL === 'function') {
const url = generatePreviewURL(data, { locale, token });
useEffect(() => {
if (generatePreviewURL && typeof generatePreviewURL === 'function') {
const makeRequest = async () => {
const previewURL = await generatePreviewURL(data, { locale, token });
setUrl(previewURL);
}
makeRequest();
}
}, [
generatePreviewURL,
locale,
token,
data
]);
if (url) {
return (
<Button
el="anchor"

View File

@@ -13,6 +13,7 @@ const ReactSelect: React.FC<Props> = (props) => {
onChange,
value,
disabled = false,
placeholder,
} = props;
const classes = [
@@ -23,6 +24,7 @@ const ReactSelect: React.FC<Props> = (props) => {
return (
<Select
placeholder={placeholder}
{...props}
value={value}
onChange={onChange}

View File

@@ -17,4 +17,5 @@ export type Props = {
isDisabled?: boolean
onInputChange?: (val: string) => void
onMenuScrollToBottom?: () => void
placeholder?: string
}

View File

@@ -1,7 +1,11 @@
import React, { useEffect, useState } from 'react';
import { useHistory } from 'react-router-dom';
import queryString from 'qs';
import { Props } from './types';
import Search from '../../icons/Search';
import useDebounce from '../../../hooks/useDebounce';
import { useSearchParams } from '../../utilities/SearchParams';
import { Where } from '../../../../types';
import './index.scss';
@@ -11,21 +15,43 @@ const SearchFilter: React.FC<Props> = (props) => {
const {
fieldName = 'id',
fieldLabel = 'ID',
modifySearchQuery = true,
handleChange,
} = props;
const [search, setSearch] = useState('');
const params = useSearchParams();
const history = useHistory();
const [search, setSearch] = useState(() => params?.where?.[fieldName]?.like || '');
const debouncedSearch = useDebounce(search, 300);
useEffect(() => {
if (typeof handleChange === 'function') {
handleChange(debouncedSearch ? {
if (debouncedSearch !== params?.where?.[fieldName]?.like) {
const newWhere = {
...(typeof params?.where === 'object' ? params.where : {}),
[fieldName]: {
like: debouncedSearch,
},
} : null);
};
if (!debouncedSearch) {
delete newWhere[fieldName];
}
if (handleChange) handleChange(newWhere as Where);
if (modifySearchQuery && params?.where?.[fieldName]?.like !== newWhere?.[fieldName]?.like) {
history.replace({
search: queryString.stringify({
...params,
page: 1,
where: newWhere,
}),
});
}
}
}, [debouncedSearch, handleChange, fieldName]);
}, [debouncedSearch, history, fieldName, params, handleChange, modifySearchQuery]);
return (
<div className={baseClass}>
@@ -33,7 +59,7 @@ const SearchFilter: React.FC<Props> = (props) => {
className={`${baseClass}__input`}
placeholder={`Search by ${fieldLabel}`}
type="text"
value={search}
value={search || ''}
onChange={(e) => setSearch(e.target.value)}
/>
<Search />

View File

@@ -1,5 +1,8 @@
import { Where } from '../../../../types';
export type Props = {
fieldName?: string,
fieldLabel?: string,
handleChange: (any) => void,
modifySearchQuery?: boolean
handleChange?: (where: Where) => void
}

View File

@@ -1,21 +1,23 @@
import React, { useState, useEffect } from 'react';
import React, { useCallback } from 'react';
import { useHistory } from 'react-router-dom';
import queryString from 'qs';
import { Props } from './types';
import Chevron from '../../icons/Chevron';
import Button from '../Button';
import './index.scss';
import { useSearchParams } from '../../utilities/SearchParams';
const baseClass = 'sort-column';
const SortColumn: React.FC<Props> = (props) => {
const {
label, handleChange, name, disable = false,
label, name, disable = false,
} = props;
const [sort, setSort] = useState(null);
const params = useSearchParams();
const history = useHistory();
useEffect(() => {
handleChange(sort);
}, [sort, handleChange]);
const { sort } = params;
const desc = `-${name}`;
const asc = name;
@@ -26,6 +28,15 @@ const SortColumn: React.FC<Props> = (props) => {
const descClasses = [`${baseClass}__desc`];
if (sort === desc) descClasses.push(`${baseClass}--active`);
const setSort = useCallback((newSort) => {
history.push({
search: queryString.stringify({
...params,
sort: newSort,
}, { addQueryPrefix: true }),
});
}, [params, history]);
return (
<div className={baseClass}>
<span className={`${baseClass}__label`}>{label}</span>

View File

@@ -1,6 +1,5 @@
export type Props = {
label: string,
handleChange: (sort) => void,
name: string,
disable?: boolean,
}

View File

@@ -1,7 +1,11 @@
import React, { useState, useEffect } from 'react';
import queryString from 'qs';
import { useHistory } from 'react-router-dom';
import { Props } from './types';
import ReactSelect from '../ReactSelect';
import sortableFieldTypes from '../../../../fields/sortableFieldTypes';
import { useSearchParams } from '../../utilities/SearchParams';
import { fieldAffectsData } from '../../../../fields/config/types';
import './index.scss';
@@ -12,11 +16,15 @@ const sortOptions = [{ label: 'Ascending', value: '' }, { label: 'Descending', v
const SortComplex: React.FC<Props> = (props) => {
const {
collection,
modifySearchQuery = true,
handleChange,
} = props;
const history = useHistory();
const params = useSearchParams();
const [sortFields] = useState(() => collection.fields.reduce((fields, field) => {
if (field.name && sortableFieldTypes.indexOf(field.type) > -1) {
if (fieldAffectsData(field) && sortableFieldTypes.indexOf(field.type) > -1) {
return [
...fields,
{ label: field.label, value: field.name },
@@ -25,14 +33,25 @@ const SortComplex: React.FC<Props> = (props) => {
return fields;
}, []));
const [sortField, setSortField] = useState(null);
const [sortOrder, setSortOrder] = useState('-');
const [sortField, setSortField] = useState(sortFields[0]);
const [sortOrder, setSortOrder] = useState({ label: 'Descending', value: '-' });
useEffect(() => {
if (sortField) {
handleChange(`${sortOrder}${sortField}`);
if (sortField?.value) {
const newSortValue = `${sortOrder.value}${sortField.value}`;
if (handleChange) handleChange(newSortValue);
if (params.sort !== newSortValue && modifySearchQuery) {
history.replace({
search: queryString.stringify({
...params,
sort: newSortValue,
}, { addQueryPrefix: true }),
});
}
}
}, [sortField, sortOrder, handleChange]);
}, [history, params, sortField, sortOrder, modifySearchQuery, handleChange]);
return (
<div className={baseClass}>
@@ -43,7 +62,7 @@ const SortComplex: React.FC<Props> = (props) => {
Column to Sort
</div>
<ReactSelect
value={sortFields.find((field) => field.name === sortField)}
value={sortField}
options={sortFields}
onChange={setSortField}
/>
@@ -53,10 +72,9 @@ const SortComplex: React.FC<Props> = (props) => {
Order
</div>
<ReactSelect
value={sortOptions.find((order) => order.value === sortOrder)}
value={sortOrder}
options={sortOptions}
onChange={setSortOrder}
// onChange={(val) => console.log(val)}
/>
</div>
</div>

View File

@@ -1,6 +1,8 @@
import { SanitizedCollectionConfig } from '../../../../collections/config/types';
export type Props = {
handleChange: (controls: any) => void,
collection: SanitizedCollectionConfig,
collection: SanitizedCollectionConfig
sort?: string,
handleChange?: (sort: string) => void
modifySearchQuery?: boolean
}

View File

@@ -18,13 +18,13 @@
max-width: initial;
}
@include large-break {
@include mid-break {
li {
width: 33.33%;
}
}
@include mid-break {
@include small-break {
li {
width: 50%;
}

View File

@@ -0,0 +1,11 @@
@import '../../../../../scss/styles.scss';
.condition-value-relationship {
&__error-loading {
border: 1px solid $color-red;
min-height: base(2);
padding: base(.5) base(.75);
background-color: $color-red;
color: white;
}
}

View File

@@ -0,0 +1,267 @@
import React, { useReducer, useState, useCallback, useEffect } from 'react';
import { useConfig } from '@payloadcms/config-provider';
import { Props, Option, ValueWithRelation } from './types';
import optionsReducer from './optionsReducer';
import useDebounce from '../../../../../hooks/useDebounce';
import ReactSelect from '../../../ReactSelect';
import { Value } from '../../../ReactSelect/types';
import { PaginatedDocs } from '../../../../../../collections/config/types';
import './index.scss';
const baseClass = 'condition-value-relationship';
const maxResultsPerRequest = 10;
const RelationshipField: React.FC<Props> = (props) => {
const { onChange, value, relationTo, hasMany } = props;
const {
serverURL,
routes: {
api,
},
collections,
} = useConfig();
const hasMultipleRelations = Array.isArray(relationTo);
const [options, dispatchOptions] = useReducer(optionsReducer, []);
const [lastFullyLoadedRelation, setLastFullyLoadedRelation] = useState(-1);
const [lastLoadedPage, setLastLoadedPage] = useState(1);
const [search, setSearch] = useState('');
const [errorLoading, setErrorLoading] = useState('');
const [hasLoadedFirstOptions, setHasLoadedFirstOptions] = useState(false);
const debouncedSearch = useDebounce(search, 300);
const addOptions = useCallback((data, relation) => {
const collection = collections.find((coll) => coll.slug === relation);
dispatchOptions({ type: 'ADD', data, relation, hasMultipleRelations, collection });
}, [collections, hasMultipleRelations]);
const getResults = useCallback(async ({
lastFullyLoadedRelation: lastFullyLoadedRelationArg,
lastLoadedPage: lastLoadedPageArg,
search: searchArg,
}) => {
let lastLoadedPageToUse = typeof lastLoadedPageArg !== 'undefined' ? lastLoadedPageArg : 1;
const lastFullyLoadedRelationToUse = typeof lastFullyLoadedRelationArg !== 'undefined' ? lastFullyLoadedRelationArg : -1;
const relations = Array.isArray(relationTo) ? relationTo : [relationTo];
const relationsToFetch = lastFullyLoadedRelationToUse === -1 ? relations : relations.slice(lastFullyLoadedRelationToUse + 1);
let resultsFetched = 0;
if (!errorLoading) {
relationsToFetch.reduce(async (priorRelation, relation) => {
await priorRelation;
if (resultsFetched < 10) {
const collection = collections.find((coll) => coll.slug === relation);
const fieldToSearch = collection?.admin?.useAsTitle || 'id';
const searchParam = searchArg ? `&where[${fieldToSearch}][like]=${searchArg}` : '';
const response = await fetch(`${serverURL}${api}/${relation}?limit=${maxResultsPerRequest}&page=${lastLoadedPageToUse}&depth=0${searchParam}`);
if (response.ok) {
const data: PaginatedDocs<any> = await response.json();
if (data.docs.length > 0) {
resultsFetched += data.docs.length;
addOptions(data, relation);
setLastLoadedPage(data.page);
if (!data.nextPage) {
setLastFullyLoadedRelation(relations.indexOf(relation));
// If there are more relations to search, need to reset lastLoadedPage to 1
// both locally within function and state
if (relations.indexOf(relation) + 1 < relations.length) {
lastLoadedPageToUse = 1;
}
}
}
} else {
setErrorLoading('An error has occurred.');
}
}
}, Promise.resolve());
}
}, [addOptions, api, collections, serverURL, errorLoading, relationTo]);
const findOptionsByValue = useCallback((): Option | Option[] => {
if (value) {
if (hasMany) {
if (Array.isArray(value)) {
return value.map((val) => {
if (hasMultipleRelations) {
let matchedOption: Option;
options.forEach((opt) => {
if (opt.options) {
opt.options.some((subOpt) => {
if (subOpt?.value === val.value) {
matchedOption = subOpt;
return true;
}
return false;
});
}
});
return matchedOption;
}
return options.find((opt) => opt.value === val);
});
}
return undefined;
}
if (hasMultipleRelations) {
let matchedOption: Option;
const valueWithRelation = value as ValueWithRelation;
options.forEach((opt) => {
if (opt?.options) {
opt.options.some((subOpt) => {
if (subOpt?.value === valueWithRelation.value) {
matchedOption = subOpt;
return true;
}
return false;
});
}
});
return matchedOption;
}
return options.find((opt) => opt.value === value);
}
return undefined;
}, [hasMany, hasMultipleRelations, value, options]);
const handleInputChange = useCallback((newSearch) => {
if (search !== newSearch) {
setSearch(newSearch);
}
}, [search]);
const addOptionByID = useCallback(async (id, relation) => {
if (!errorLoading && id !== 'null') {
const response = await fetch(`${serverURL}${api}/${relation}/${id}?depth=0`);
if (response.ok) {
const data = await response.json();
addOptions({ docs: [data] }, relation);
} else {
console.error(`There was a problem loading the document with ID of ${id}.`);
}
}
}, [addOptions, api, errorLoading, serverURL]);
// ///////////////////////////
// Get results when search input changes
// ///////////////////////////
useEffect(() => {
dispatchOptions({
type: 'CLEAR',
required: true,
});
setHasLoadedFirstOptions(true);
setLastLoadedPage(1);
setLastFullyLoadedRelation(-1);
getResults({ search: debouncedSearch });
}, [getResults, debouncedSearch, relationTo]);
// ///////////////////////////
// Format options once first options have been retrieved
// ///////////////////////////
useEffect(() => {
if (value && hasLoadedFirstOptions) {
if (hasMany) {
const matchedOptions = findOptionsByValue();
(matchedOptions as Value[] || []).forEach((option, i) => {
if (!option) {
if (hasMultipleRelations) {
addOptionByID(value[i].value, value[i].relationTo);
} else {
addOptionByID(value[i], relationTo);
}
}
});
} else {
const matchedOption = findOptionsByValue();
if (!matchedOption) {
if (hasMultipleRelations) {
const valueWithRelation = value as ValueWithRelation;
addOptionByID(valueWithRelation.value, valueWithRelation.relationTo);
} else {
addOptionByID(value, relationTo);
}
}
}
}
}, [addOptionByID, findOptionsByValue, hasMany, hasMultipleRelations, relationTo, value, hasLoadedFirstOptions]);
const classes = [
'field-type',
baseClass,
errorLoading && 'error-loading',
].filter(Boolean).join(' ');
const valueToRender = (findOptionsByValue() || value) as Value;
return (
<div className={classes}>
{!errorLoading && (
<ReactSelect
placeholder="Select a value"
onInputChange={handleInputChange}
onChange={(selected) => {
if (hasMany) {
onChange(selected ? selected.map((option) => {
if (hasMultipleRelations) {
return {
relationTo: option.relationTo,
value: option.value,
};
}
return option.value;
}) : null);
} else if (hasMultipleRelations) {
onChange({
relationTo: selected.relationTo,
value: selected.value,
});
} else {
onChange(selected.value);
}
}}
onMenuScrollToBottom={() => {
getResults({ lastFullyLoadedRelation, lastLoadedPage: lastLoadedPage + 1 });
}}
value={valueToRender}
options={options}
isMulti={hasMany}
/>
)}
{errorLoading && (
<div className={`${baseClass}__error-loading`}>
{errorLoading}
</div>
)}
</div>
);
};
export default RelationshipField;

View File

@@ -0,0 +1,92 @@
import { Option, Action } from './types';
const reduceToIDs = (options) => options.reduce((ids, option) => {
if (option.options) {
return [
...ids,
...reduceToIDs(option.options),
];
}
return [
...ids,
option.id,
];
}, []);
const optionsReducer = (state: Option[], action: Action): Option[] => {
switch (action.type) {
case 'CLEAR': {
return action.required ? [] : [{ value: 'null', label: 'None' }];
}
case 'ADD': {
const { hasMultipleRelations, collection, relation, data } = action;
const labelKey = collection.admin.useAsTitle || 'id';
const loadedIDs = reduceToIDs(state);
if (!hasMultipleRelations) {
return [
...state,
...data.docs.reduce((docs, doc) => {
if (loadedIDs.indexOf(doc.id) === -1) {
loadedIDs.push(doc.id);
return [
...docs,
{
label: doc[labelKey],
value: doc.id,
},
];
}
return docs;
}, []),
];
}
const newOptions = [...state];
const optionsToAddTo = newOptions.find((optionGroup) => optionGroup.label === collection.labels.plural);
const newSubOptions = data.docs.reduce((docs, doc) => {
if (loadedIDs.indexOf(doc.id) === -1) {
loadedIDs.push(doc.id);
return [
...docs,
{
label: doc[labelKey],
relationTo: relation,
value: doc.id,
},
];
}
return docs;
}, []);
if (optionsToAddTo) {
optionsToAddTo.options = [
...optionsToAddTo.options,
...newSubOptions,
];
} else {
newOptions.push({
label: collection.labels.plural,
options: newSubOptions,
value: undefined,
});
}
return newOptions;
}
default: {
return state;
}
}
};
export default optionsReducer;

View File

@@ -0,0 +1,34 @@
import { RelationshipField } from '../../../../../../fields/config/types';
import { PaginatedDocs, SanitizedCollectionConfig } from '../../../../../../collections/config/types';
export type Props = {
onChange: (val: unknown) => void,
value: unknown,
} & RelationshipField
export type Option = {
label: string
value: string
relationTo?: string
options?: Option[]
}
type CLEAR = {
type: 'CLEAR'
required: boolean
}
type ADD = {
type: 'ADD'
data: PaginatedDocs<any>
relation: string
hasMultipleRelations: boolean
collection: SanitizedCollectionConfig
}
export type Action = CLEAR | ADD
export type ValueWithRelation = {
relationTo: string
value: string
}

View File

@@ -6,6 +6,7 @@ import Button from '../../Button';
import Date from './Date';
import Number from './Number';
import Text from './Text';
import Relationship from './Relationship';
import useDebounce from '../../../../hooks/useDebounce';
import { FieldCondition } from '../types';
@@ -15,6 +16,7 @@ const valueFields = {
Date,
Number,
Text,
Relationship,
};
const baseClass = 'condition';
@@ -27,18 +29,23 @@ const Condition: React.FC<Props> = (props) => {
orIndex,
andIndex,
} = props;
const fieldValue = Object.keys(value)[0];
const operatorAndValue = value?.[fieldValue] ? Object.entries(value[fieldValue])[0] : undefined;
const [activeField, setActiveField] = useState({ operators: [] } as FieldCondition);
const [internalValue, setInternalValue] = useState(value.value);
const operatorValue = operatorAndValue?.[0];
const queryValue = operatorAndValue?.[1];
const [activeField, setActiveField] = useState<FieldCondition>(() => fields.find((field) => fieldValue === field.value));
const [internalValue, setInternalValue] = useState(queryValue);
const debouncedValue = useDebounce(internalValue, 300);
useEffect(() => {
const newActiveField = fields.find((field) => value.field === field.value);
const newActiveField = fields.find((field) => fieldValue === field.value);
if (newActiveField) {
setActiveField(newActiveField);
}
}, [value, fields]);
}, [fieldValue, fields]);
useEffect(() => {
dispatch({
@@ -49,7 +56,7 @@ const Condition: React.FC<Props> = (props) => {
});
}, [debouncedValue, dispatch, orIndex, andIndex]);
const ValueComponent = valueFields[activeField.component] || valueFields.Text;
const ValueComponent = valueFields[activeField?.component] || valueFields.Text;
return (
<div className={baseClass}>
@@ -57,7 +64,7 @@ const Condition: React.FC<Props> = (props) => {
<div className={`${baseClass}__inputs`}>
<div className={`${baseClass}__field`}>
<ReactSelect
value={fields.find((field) => value.field === field.value)}
value={fields.find((field) => fieldValue === field.value)}
options={fields}
onChange={(field) => dispatch({
type: 'update',
@@ -69,14 +76,17 @@ const Condition: React.FC<Props> = (props) => {
</div>
<div className={`${baseClass}__operator`}>
<ReactSelect
value={activeField.operators.find((operator) => value.operator === operator.value)}
disabled={!fieldValue}
value={activeField.operators.find((operator) => operatorValue === operator.value)}
options={activeField.operators}
onChange={(operator) => dispatch({
type: 'update',
orIndex,
andIndex,
operator: operator.value,
})}
onChange={(operator) => {
dispatch({
type: 'update',
orIndex,
andIndex,
operator: operator.value,
});
}}
/>
</div>
<div className={`${baseClass}__value`}>
@@ -84,7 +94,8 @@ const Condition: React.FC<Props> = (props) => {
CustomComponent={activeField?.props?.admin?.components?.Filter}
DefaultComponent={ValueComponent}
componentProps={{
...activeField.props,
...activeField?.props,
operator: operatorValue,
value: internalValue,
onChange: setInternalValue,
}}
@@ -110,6 +121,7 @@ const Condition: React.FC<Props> = (props) => {
iconStyle="with-border"
onClick={() => dispatch({
type: 'add',
field: fields[0].value,
relation: 'and',
orIndex,
andIndex: andIndex + 1,

View File

@@ -1,8 +1,9 @@
import { Action, AndClause, FieldCondition } from '../types';
import { Where } from '../../../../../types';
import { Action, FieldCondition } from '../types';
export type Props = {
fields: FieldCondition[],
value: AndClause,
value: Where,
dispatch: (action: Action) => void
orIndex: number,
andIndex: number,

View File

@@ -100,7 +100,7 @@ const fieldTypeConditions = {
operators: [...base],
},
relationship: {
component: 'Text',
component: 'Relationship',
operators: [...base],
},
select: {

View File

@@ -1,4 +1,6 @@
import React, { useState, useReducer } from 'react';
import queryString from 'qs';
import { useHistory } from 'react-router-dom';
import { Props } from './types';
import useThrottledEffect from '../../../hooks/useThrottledEffect';
import Button from '../Button';
@@ -6,19 +8,14 @@ import reducer from './reducer';
import Condition from './Condition';
import fieldTypes from './field-types';
import flattenTopLevelFields from '../../../../utilities/flattenTopLevelFields';
import { useSearchParams } from '../../utilities/SearchParams';
import validateWhereQuery from './validateWhereQuery';
import { Where } from '../../../../types';
import './index.scss';
const baseClass = 'where-builder';
const validateWhereQuery = (query) => {
if (query.or.length > 0 && query.or[0].and && query.or[0].and.length > 0) {
return query;
}
return null;
};
const reduceFields = (fields) => flattenTopLevelFields(fields).reduce((reduced, field) => {
if (typeof fieldTypes[field.type] === 'object') {
const formattedField = {
@@ -42,52 +39,52 @@ const reduceFields = (fields) => flattenTopLevelFields(fields).reduce((reduced,
const WhereBuilder: React.FC<Props> = (props) => {
const {
collection,
modifySearchQuery = true,
handleChange,
collection: {
labels: {
plural,
} = {},
} = {},
handleChange,
} = props;
const [where, dispatchWhere] = useReducer(reducer, []);
const history = useHistory();
const params = useSearchParams();
const [conditions, dispatchConditions] = useReducer(reducer, params.where, (whereFromSearch) => {
if (modifySearchQuery && validateWhereQuery(whereFromSearch)) {
return whereFromSearch.or;
}
return [];
});
const [reducedFields] = useState(() => reduceFields(collection.fields));
useThrottledEffect(() => {
let whereQuery = {
or: [],
const currentParams = queryString.parse(history.location.search, { ignoreQueryPrefix: true, depth: 10 });
const newWhereQuery = {
...typeof currentParams?.where === 'object' ? currentParams.where : {},
or: conditions,
};
if (where) {
whereQuery.or = where.map((or) => or.reduce((conditions, condition) => {
const { field, operator, value } = condition;
if (field && operator && value) {
return {
and: [
...conditions.and,
{
[field]: {
[operator]: value,
},
},
],
};
}
if (handleChange) handleChange(newWhereQuery as Where);
return conditions;
}, {
and: [],
}));
if (modifySearchQuery) {
history.replace({
search: queryString.stringify({
...currentParams,
page: 1,
where: newWhereQuery,
}, { addQueryPrefix: true }),
});
}
whereQuery = validateWhereQuery(whereQuery);
if (typeof handleChange === 'function') handleChange(whereQuery);
}, 500, [where, handleChange]);
}, 500, [conditions, modifySearchQuery, handleChange]);
return (
<div className={baseClass}>
{where.length > 0 && (
{conditions.length > 0 && (
<React.Fragment>
<div className={`${baseClass}__label`}>
Filter
@@ -97,7 +94,7 @@ const WhereBuilder: React.FC<Props> = (props) => {
where
</div>
<ul className={`${baseClass}__or-filters`}>
{where.map((or, orIndex) => (
{conditions.map((or, orIndex) => (
<li key={orIndex}>
{orIndex !== 0 && (
<div className={`${baseClass}__label`}>
@@ -105,7 +102,7 @@ const WhereBuilder: React.FC<Props> = (props) => {
</div>
)}
<ul className={`${baseClass}__and-filters`}>
{or && or.map((_, andIndex) => (
{Array.isArray(or?.and) && or.and.map((_, andIndex) => (
<li key={andIndex}>
{andIndex !== 0 && (
<div className={`${baseClass}__label`}>
@@ -113,12 +110,12 @@ const WhereBuilder: React.FC<Props> = (props) => {
</div>
)}
<Condition
value={where[orIndex][andIndex]}
value={conditions[orIndex].and[andIndex]}
orIndex={orIndex}
andIndex={andIndex}
key={andIndex}
fields={reducedFields}
dispatch={dispatchWhere}
dispatch={dispatchConditions}
/>
</li>
))}
@@ -132,13 +129,13 @@ const WhereBuilder: React.FC<Props> = (props) => {
buttonStyle="icon-label"
iconPosition="left"
iconStyle="with-border"
onClick={() => dispatchWhere({ type: 'add' })}
onClick={() => dispatchConditions({ type: 'add', field: reducedFields[0].value })}
>
Or
</Button>
</React.Fragment>
)}
{where.length === 0 && (
{conditions.length === 0 && (
<div className={`${baseClass}__no-filters`}>
<div className={`${baseClass}__label`}>No filters set</div>
<Button
@@ -147,7 +144,7 @@ const WhereBuilder: React.FC<Props> = (props) => {
buttonStyle="icon-label"
iconPosition="left"
iconStyle="with-border"
onClick={() => dispatchWhere({ type: 'add' })}
onClick={() => dispatchConditions({ type: 'add', field: reducedFields[0].value })}
>
Add filter
</Button>

View File

@@ -1,7 +1,10 @@
import { OrClause, Action } from './types';
import { Action } from './types';
import { Where } from '../../../../types';
const reducer = (state: OrClause[], action: Action): OrClause[] => {
const newState = [...state];
const reducer = (state: Where[], action: Action): Where[] => {
const newState = [
...state,
];
const {
orIndex,
@@ -10,23 +13,27 @@ const reducer = (state: OrClause[], action: Action): OrClause[] => {
switch (action.type) {
case 'add': {
const { relation } = action;
const { relation, field } = action;
if (relation === 'and') {
newState[orIndex].splice(andIndex, 0, {});
newState[orIndex].and.splice(andIndex, 0, { [field]: {} });
return newState;
}
return [
...newState,
[{}],
{
and: [{
[field]: {},
}],
},
];
}
case 'remove': {
newState[orIndex].splice(andIndex, 1);
newState[orIndex].and.splice(andIndex, 1);
if (newState[orIndex].length === 0) {
if (newState[orIndex].and.length === 0) {
newState.splice(orIndex, 1);
}
@@ -36,20 +43,36 @@ const reducer = (state: OrClause[], action: Action): OrClause[] => {
case 'update': {
const { field, operator, value } = action;
newState[orIndex][andIndex] = {
...newState[orIndex][andIndex],
};
if (typeof newState[orIndex].and[andIndex] === 'object') {
newState[orIndex].and[andIndex] = {
...newState[orIndex].and[andIndex],
};
if (operator) {
newState[orIndex][andIndex].operator = operator;
}
const [existingFieldName, existingCondition] = Object.entries(newState[orIndex].and[andIndex])[0] || [undefined, undefined];
if (field) {
newState[orIndex][andIndex].field = field;
}
if (operator) {
newState[orIndex].and[andIndex] = {
[existingFieldName]: {
[operator]: Object.values(existingCondition)[0],
},
};
}
if (value !== undefined) {
newState[orIndex][andIndex].value = value;
if (field) {
newState[orIndex].and[andIndex] = {
[field]: {
[Object.keys(existingCondition)[0]]: Object.values(existingCondition)[0],
},
};
}
if (value !== undefined) {
newState[orIndex].and[andIndex] = {
[existingFieldName]: {
[Object.keys(existingCondition)[0]]: value,
},
};
}
}
return newState;

View File

@@ -1,10 +1,11 @@
import { SanitizedCollectionConfig } from '../../../../collections/config/types';
import { Field } from '../../../../fields/config/types';
import { Operator } from '../../../../types';
import { Operator, Where } from '../../../../types';
export type Props = {
handleChange: (controls: any) => void,
collection: SanitizedCollectionConfig,
handleChange?: (where: Where) => void
modifySearchQuery?: boolean
}
export type FieldCondition = {
@@ -14,7 +15,7 @@ export type FieldCondition = {
label: string
value: Operator
}[]
component: string
component?: string
props: Field
}
@@ -22,6 +23,7 @@ export type Relation = 'and' | 'or'
export type ADD = {
type: 'add'
field: string
relation?: Relation
andIndex?: number
orIndex?: number
@@ -44,10 +46,6 @@ export type UPDATE = {
export type Action = ADD | REMOVE | UPDATE
export type AndClause = {
operator?: string
value?: unknown
field?: string
export type State = {
or: Where[]
}
export type OrClause = AndClause[]

View File

@@ -0,0 +1,11 @@
import { Where } from '../../../../types';
const validateWhereQuery = (whereQuery): whereQuery is Where => {
if (whereQuery?.or?.length > 0 && whereQuery?.or?.[0]?.and && whereQuery?.or?.[0]?.and?.length > 0) {
return true;
}
return false;
};
export default validateWhereQuery;

View File

@@ -1,5 +1,5 @@
import React from 'react';
import useFieldType from '../../useFieldType';
import useField from '../../useField';
import Pill from '../../../elements/Pill';
import { Props } from './types';
@@ -10,7 +10,7 @@ const baseClass = 'section-title';
const SectionTitle: React.FC<Props> = (props) => {
const { label, path, readOnly } = props;
const { value, setValue } = useFieldType({ path });
const { value, setValue } = useField({ path });
const classes = [
baseClass,

View File

@@ -13,6 +13,7 @@ import { Props } from './types';
import HiddenInput from '../field-types/HiddenInput';
import './index.scss';
import { fieldAffectsData } from '../../../../fields/config/types';
const baseClass = 'draggable-section';
@@ -111,7 +112,7 @@ const DraggableSection: React.FC<Props> = (props) => {
permissions={permissions?.fields}
fieldSchema={fieldSchema.map((field) => ({
...field,
path: `${parentPath}.${rowIndex}${field.name ? `.${field.name}` : ''}`,
path: `${parentPath}.${rowIndex}${fieldAffectsData(field) ? `.${field.name}` : ''}`,
}))}
/>
</NegativeFieldGutterProvider>

View File

@@ -2,9 +2,9 @@ import React from 'react';
export type DescriptionFunction = (value: unknown) => string
export type DescriptionComponent = React.ComponentType<{value: unknown}>
export type DescriptionComponent = React.ComponentType<{ value: unknown }>
type Description = string | DescriptionFunction | DescriptionComponent
export type Description = string | DescriptionFunction | DescriptionComponent
export type Props = {
description?: Description

View File

@@ -1,8 +1,8 @@
import ObjectID from 'bson-objectid';
import { Field as FieldSchema } from '../../../../fields/config/types';
import { Field as FieldSchema, fieldAffectsData, FieldAffectingData, fieldIsPresentationalOnly } from '../../../../fields/config/types';
import { Fields, Field, Data } from './types';
const buildValidationPromise = async (fieldState: Field, field: FieldSchema) => {
const buildValidationPromise = async (fieldState: Field, field: FieldAffectingData) => {
const validatedFieldState = fieldState;
let validationResult: boolean | string = true;
@@ -43,14 +43,14 @@ const buildStateFromSchema = async (fieldSchema: FieldSchema[], fullData: Data =
const iterateFields = (fields: FieldSchema[], data: Data, parentPassesCondition: boolean, path = '') => fields.reduce((state, field) => {
let initialData = data;
if (!field?.admin?.disabled) {
if (field.name && field.defaultValue && typeof initialData?.[field.name] === 'undefined') {
if (!fieldIsPresentationalOnly(field) && !field?.admin?.disabled) {
if (fieldAffectsData(field) && field.defaultValue && typeof initialData?.[field.name] === 'undefined') {
initialData = { [field.name]: field.defaultValue };
}
const passesCondition = Boolean((field?.admin?.condition ? field.admin.condition(fullData || {}, initialData || {}) : true) && parentPassesCondition);
if (field.name) {
if (fieldAffectsData(field)) {
if (field.type === 'relationship' && initialData?.[field.name] === null) {
initialData[field.name] = 'null';
}
@@ -135,10 +135,12 @@ const buildStateFromSchema = async (fieldSchema: FieldSchema[], fullData: Data =
};
}
const namedField = field as FieldAffectingData;
// Handle normal fields
return {
...state,
[`${path}${field.name}`]: structureFieldState(field, passesCondition, data),
[`${path}${namedField.name}`]: structureFieldState(field, passesCondition, data),
};
}

View File

@@ -95,8 +95,11 @@ function fieldReducer(state: Fields, action): Fields {
};
}
// Add new object containing subfield names to unflattenedRows array
unflattenedRows.splice(rowIndex + 1, 0, subFieldState);
// If there are subfields
if (Object.keys(subFieldState).length > 0) {
// Add new object containing subfield names to unflattenedRows array
unflattenedRows.splice(rowIndex + 1, 0, subFieldState);
}
const newState = {
...remainingFlattenedState,

View File

@@ -2,6 +2,7 @@ import React, { createContext, useEffect, useContext, useState } from 'react';
import RenderCustomComponent from '../../utilities/RenderCustomComponent';
import useIntersect from '../../../hooks/useIntersect';
import { Props, Context } from './types';
import { fieldAffectsData, fieldIsPresentationalOnly } from '../../../../fields/config/types';
const baseClass = 'render-fields';
@@ -65,18 +66,34 @@ const RenderFields: React.FC<Props> = (props) => {
{hasRendered && (
<RenderedFieldContext.Provider value={contextValue}>
{fieldSchema.map((field, i) => {
if (!field?.hidden && field?.admin?.disabled !== true) {
if ((filter && typeof filter === 'function' && filter(field)) || !filter) {
const FieldComponent = field?.admin?.hidden ? fieldTypes.hidden : fieldTypes[field.type];
const fieldIsPresentational = fieldIsPresentationalOnly(field);
let FieldComponent = fieldTypes[field.type];
const fieldPermissions = field?.name ? permissions?.[field.name] : permissions;
if (fieldIsPresentational || (!field?.hidden && field?.admin?.disabled !== true)) {
if ((filter && typeof filter === 'function' && filter(field)) || !filter) {
if (fieldIsPresentational) {
return (
<FieldComponent
{...field}
key={i}
/>
);
}
if (field?.admin?.hidden) {
FieldComponent = fieldTypes.hidden;
}
const isFieldAffectingData = fieldAffectsData(field);
const fieldPermissions = isFieldAffectingData ? permissions?.[field.name] : permissions;
let { admin: { readOnly } = {} } = field;
if (readOnlyOverride) readOnly = true;
if (permissions?.[field?.name]?.read?.permission !== false) {
if (permissions?.[field?.name]?.[operation]?.permission === false) {
if ((isFieldAffectingData && permissions?.[field?.name]?.read?.permission !== false) || !isFieldAffectingData) {
if (isFieldAffectingData && permissions?.[field?.name]?.[operation]?.permission === false) {
readOnly = true;
}
@@ -88,7 +105,7 @@ const RenderFields: React.FC<Props> = (props) => {
DefaultComponent={FieldComponent}
componentProps={{
...field,
path: field.path || field.name,
path: field.path || (isFieldAffectingData ? field.name : undefined),
fieldTypes,
admin: {
...(field.admin || {}),

View File

@@ -7,7 +7,7 @@ import DraggableSection from '../../DraggableSection';
import reducer from '../rowReducer';
import { useForm } from '../../Form/context';
import buildStateFromSchema from '../../Form/buildStateFromSchema';
import useFieldType from '../../useFieldType';
import useField from '../../useField';
import Error from '../../Error';
import { array } from '../../../../../fields/validations';
import Banner from '../../../elements/Banner';
@@ -66,7 +66,7 @@ const ArrayFieldType: React.FC<Props> = (props) => {
errorMessage,
value,
setValue,
} = useFieldType({
} = useField({
path,
validate: memoizedValidate,
disableFormData,

View File

@@ -1,7 +1,8 @@
import { Data } from '../../Form/types';
import { ArrayField, Labels, Field, Description } from '../../../../../fields/config/types';
import { ArrayField, Labels, Field } from '../../../../../fields/config/types';
import { FieldTypes } from '..';
import { FieldPermissions } from '../../../../../auth/types';
import { Description } from '../../FieldDescription/types';
export type Props = Omit<ArrayField, 'type'> & {
path?: string

View File

@@ -12,7 +12,7 @@ import { useForm } from '../../Form/context';
import buildStateFromSchema from '../../Form/buildStateFromSchema';
import DraggableSection from '../../DraggableSection';
import Error from '../../Error';
import useFieldType from '../../useFieldType';
import useField from '../../useField';
import Popup from '../../../elements/Popup';
import BlockSelector from './BlockSelector';
import { blocks as blocksValidator } from '../../../../../fields/validations';
@@ -76,7 +76,7 @@ const Blocks: React.FC<Props> = (props) => {
errorMessage,
value,
setValue,
} = useFieldType({
} = useField({
path,
validate: memoizedValidate,
disableFormData,
@@ -110,7 +110,7 @@ const Blocks: React.FC<Props> = (props) => {
if (preferencesKey) {
const preferences: DocumentPreferences = await getPreference(preferencesKey);
const preferencesToSet = preferences || { fields: { } };
const preferencesToSet = preferences || { fields: {} };
let newCollapsedState = preferencesToSet?.fields?.[path]?.collapsed
.filter((filterID) => (rows.find((row) => row.id === filterID)))
|| [];

View File

@@ -1,7 +1,8 @@
import { Data } from '../../Form/types';
import { BlockField, Labels, Block, Description } from '../../../../../fields/config/types';
import { BlockField, Labels, Block } from '../../../../../fields/config/types';
import { FieldTypes } from '..';
import { FieldPermissions } from '../../../../../auth/types';
import { Description } from '../../FieldDescription/types';
export type Props = Omit<BlockField, 'type'> & {
path?: string

View File

@@ -1,5 +1,5 @@
import React, { useCallback } from 'react';
import useFieldType from '../../useFieldType';
import useField from '../../useField';
import withCondition from '../../withCondition';
import Error from '../../Error';
import { checkbox } from '../../../../../fields/validations';
@@ -41,7 +41,7 @@ const Checkbox: React.FC<Props> = (props) => {
showError,
errorMessage,
setValue,
} = useFieldType({
} = useField({
path,
validate: memoizedValidate,
disableFormData,

View File

@@ -3,7 +3,7 @@ import Editor from 'react-simple-code-editor';
import { highlight, languages } from 'prismjs/components/prism-core';
import 'prismjs/components/prism-clike';
import 'prismjs/components/prism-javascript';
import useFieldType from '../../useFieldType';
import useField from '../../useField';
import withCondition from '../../withCondition';
import Label from '../../Label';
import Error from '../../Error';
@@ -52,7 +52,7 @@ const Code: React.FC<Props> = (props) => {
showError,
setValue,
errorMessage,
} = useFieldType({
} = useField({
path,
validate: memoizedValidate,
enableDebouncedValue: true,

View File

@@ -1,9 +1,10 @@
import React, { Suspense, lazy } from 'react';
import Loading from '../../../elements/Loading';
import { Props } from './types';
const Code = lazy(() => import('./Code'));
const CodeField: React.FC = (props) => (
const CodeField: React.FC<Props> = (props) => (
<Suspense fallback={<Loading />}>
<Code {...props} />
</Suspense>

View File

@@ -1,5 +1,5 @@
import React, { useCallback } from 'react';
import useFieldType from '../../useFieldType';
import useField from '../../useField';
import Label from '../../Label';
import Error from '../../Error';
import { useWatchForm } from '../../Form/context';
@@ -23,7 +23,7 @@ const ConfirmPassword: React.FC = () => {
showError,
setValue,
errorMessage,
} = useFieldType({
} = useField({
path: 'confirm-password',
disableFormData: true,
validate,

View File

@@ -2,7 +2,7 @@ import React, { useCallback } from 'react';
import DatePicker from '../../../elements/DatePicker';
import withCondition from '../../withCondition';
import useFieldType from '../../useFieldType';
import useField from '../../useField';
import Label from '../../Label';
import Error from '../../Error';
import FieldDescription from '../../FieldDescription';
@@ -43,7 +43,7 @@ const DateTime: React.FC<Props> = (props) => {
showError,
errorMessage,
setValue,
} = useFieldType({
} = useField({
path,
validate: memoizedValidate,
condition,

View File

@@ -2,6 +2,7 @@
.field-type.email {
margin-bottom: $baseline;
position: relative;
input {
@include formInput;

View File

@@ -1,6 +1,6 @@
import React, { useCallback } from 'react';
import withCondition from '../../withCondition';
import useFieldType from '../../useFieldType';
import useField from '../../useField';
import Label from '../../Label';
import Error from '../../Error';
import FieldDescription from '../../FieldDescription';
@@ -34,7 +34,7 @@ const Email: React.FC<Props> = (props) => {
return validationResult;
}, [validate, required]);
const fieldType = useFieldType({
const fieldType = useField({
path,
validate: memoizedValidate,
enableDebouncedValue: true,

View File

@@ -5,6 +5,7 @@ import FieldDescription from '../../FieldDescription';
import FieldTypeGutter from '../../FieldTypeGutter';
import { NegativeFieldGutterProvider } from '../../FieldTypeGutter/context';
import { Props } from './types';
import { fieldAffectsData } from '../../../../../fields/config/types';
import './index.scss';
@@ -41,7 +42,7 @@ const Group: React.FC<Props> = (props) => {
width,
}}
>
{ !hideGutter && (<FieldTypeGutter />) }
{!hideGutter && (<FieldTypeGutter />)}
<div className={`${baseClass}__content-wrapper`}>
{(label || description) && (
@@ -63,7 +64,7 @@ const Group: React.FC<Props> = (props) => {
fieldTypes={fieldTypes}
fieldSchema={fields.map((subField) => ({
...subField,
path: `${path}${subField.name ? `.${subField.name}` : ''}`,
path: `${path}${fieldAffectsData(subField) ? `.${subField.name}` : ''}`,
}))}
/>
</NegativeFieldGutterProvider>

View File

@@ -1,5 +1,5 @@
import React, { useEffect } from 'react';
import useFieldType from '../../useFieldType';
import useField from '../../useField';
import withCondition from '../../withCondition';
import { Props } from './types';
@@ -13,7 +13,7 @@ const HiddenInput: React.FC<Props> = (props) => {
const path = pathFromProps || name;
const { value, setValue } = useFieldType({
const { value, setValue } = useField({
path,
});

View File

@@ -1,5 +1,5 @@
import React, { useCallback } from 'react';
import useFieldType from '../../useFieldType';
import useField from '../../useField';
import Label from '../../Label';
import Error from '../../Error';
import FieldDescription from '../../FieldDescription';
@@ -41,7 +41,7 @@ const NumberField: React.FC<Props> = (props) => {
showError,
setValue,
errorMessage,
} = useFieldType({
} = useField({
path,
validate: memoizedValidate,
enableDebouncedValue: true,

View File

@@ -1,5 +1,5 @@
import React, { useCallback } from 'react';
import useFieldType from '../../useFieldType';
import useField from '../../useField';
import Label from '../../Label';
import Error from '../../Error';
import withCondition from '../../withCondition';
@@ -33,7 +33,7 @@ const Password: React.FC<Props> = (props) => {
formProcessing,
setValue,
errorMessage,
} = useFieldType({
} = useField({
path,
validate: memoizedValidate,
enableDebouncedValue: true,

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { Description, Validate } from '../../../../../fields/config/types';
import { Validate } from '../../../../../fields/config/types';
import { Description } from '../../FieldDescription/types';
export type Props = {
autoComplete?: string

View File

@@ -1,5 +1,5 @@
import React, { useCallback } from 'react';
import useFieldType from '../../useFieldType';
import useField from '../../useField';
import Label from '../../Label';
import Error from '../../Error';
import FieldDescription from '../../FieldDescription';
@@ -41,7 +41,7 @@ const PointField: React.FC<Props> = (props) => {
showError,
setValue,
errorMessage,
} = useFieldType<[number, number]>({
} = useField<[number, number]>({
path,
validate: memoizedValidate,
enableDebouncedValue: true,

View File

@@ -1,6 +1,6 @@
import React, { useCallback } from 'react';
import useFieldType from '../../useFieldType';
import useField from '../../useField';
import withCondition from '../../withCondition';
import Error from '../../Error';
import Label from '../../Label';
@@ -44,7 +44,7 @@ const RadioGroup: React.FC<Props> = (props) => {
showError,
errorMessage,
setValue,
} = useFieldType({
} = useField({
path,
validate: memoizedValidate,
condition,

View File

@@ -2,11 +2,10 @@ import React, {
useCallback, useEffect, useState, useReducer,
} from 'react';
import { useConfig } from '@payloadcms/config-provider';
import some from 'async-some';
import withCondition from '../../withCondition';
import ReactSelect from '../../../elements/ReactSelect';
import { Value } from '../../../elements/ReactSelect/types';
import useFieldType from '../../useFieldType';
import useField from '../../useField';
import Label from '../../Label';
import Error from '../../Error';
import FieldDescription from '../../FieldDescription';
@@ -14,7 +13,7 @@ import { relationship } from '../../../../../fields/validations';
import { PaginatedDocs } from '../../../../../collections/config/types';
import { useFormProcessing } from '../../Form/context';
import optionsReducer from './optionsReducer';
import { Props, OptionsPage, Option, ValueWithRelation } from './types';
import { Props, Option, ValueWithRelation } from './types';
import useDebounce from '../../../../hooks/useDebounce';
import './index.scss';
@@ -57,9 +56,9 @@ const Relationship: React.FC<Props> = (props) => {
const [lastFullyLoadedRelation, setLastFullyLoadedRelation] = useState(-1);
const [lastLoadedPage, setLastLoadedPage] = useState(1);
const [search, setSearch] = useState('');
const [errorLoading, setErrorLoading] = useState(false);
const [errorLoading, setErrorLoading] = useState('');
const [hasLoadedFirstOptions, setHasLoadedFirstOptions] = useState(false);
const debouncedSearch = useDebounce(search, 120);
const debouncedSearch = useDebounce(search, 300);
const memoizedValidate = useCallback((value) => {
const validationResult = validate(value, { required });
@@ -71,7 +70,7 @@ const Relationship: React.FC<Props> = (props) => {
showError,
errorMessage,
setValue,
} = useFieldType({
} = useField({
path: path || name,
validate: memoizedValidate,
condition,
@@ -82,65 +81,54 @@ const Relationship: React.FC<Props> = (props) => {
dispatchOptions({ type: 'ADD', data, relation, hasMultipleRelations, collection });
}, [collections, hasMultipleRelations]);
const getResults = useCallback(({ relations: relationsArg, lastLoadedPage: lastLoadedPageArg }) => {
if (relationsArg.length > 0) {
some(relationsArg, async (relation, callback) => {
const collection = collections.find((coll) => coll.slug === relation);
const fieldToSearch = collection?.admin?.useAsTitle || 'id';
const searchParam = search ? `&where[${fieldToSearch}][like]=${search}` : '';
const response = await fetch(`${serverURL}${api}/${relation}?limit=${maxResultsPerRequest}&page=${lastLoadedPageArg}&depth=0${searchParam}`);
const data: PaginatedDocs = await response.json();
const getResults = useCallback(async ({
lastFullyLoadedRelation: lastFullyLoadedRelationArg,
lastLoadedPage: lastLoadedPageArg,
search: searchArg,
}) => {
let lastLoadedPageToUse = typeof lastLoadedPageArg !== 'undefined' ? lastLoadedPageArg : 1;
const lastFullyLoadedRelationToUse = typeof lastFullyLoadedRelationArg !== 'undefined' ? lastFullyLoadedRelationArg : -1;
if (response.ok) {
if (data.hasNextPage) {
return callback(false, {
data,
relation,
});
}
return callback({ relation, data });
}
return setErrorLoading(true);
}, (lastPage: OptionsPage, nextPage: OptionsPage) => {
if (nextPage) {
const { data, relation } = nextPage;
addOptions(data, relation);
setLastLoadedPage((l) => l + 1);
} else {
const relations = Array.isArray(relationTo) ? relationTo : [relationTo];
const { data, relation } = lastPage;
addOptions(data, relation);
setLastFullyLoadedRelation(relations.indexOf(relation));
setLastLoadedPage(1);
}
});
}
}, [addOptions, api, collections, relationTo, search, serverURL]);
const getNextOptions = useCallback((params = {} as Record<string, unknown>) => {
const clear = params?.clear;
const relations = Array.isArray(relationTo) ? relationTo : [relationTo];
const relationsToFetch = lastFullyLoadedRelationToUse === -1 ? relations : relations.slice(lastFullyLoadedRelationToUse + 1);
if (clear) {
dispatchOptions({
type: 'CLEAR',
required,
});
setLastFullyLoadedRelation(-1);
}
let resultsFetched = 0;
if (!errorLoading) {
const relationsToSearch = lastFullyLoadedRelation === -1 ? relations : relations.slice(lastFullyLoadedRelation + 1);
relationsToFetch.reduce(async (priorRelation, relation) => {
await priorRelation;
getResults({
relations: relationsToSearch,
lastLoadedPage,
});
if (resultsFetched < 10) {
const collection = collections.find((coll) => coll.slug === relation);
const fieldToSearch = collection?.admin?.useAsTitle || 'id';
const searchParam = searchArg ? `&where[${fieldToSearch}][like]=${searchArg}` : '';
const response = await fetch(`${serverURL}${api}/${relation}?limit=${maxResultsPerRequest}&page=${lastLoadedPageToUse}&depth=0${searchParam}`);
if (response.ok) {
const data: PaginatedDocs<any> = await response.json();
if (data.docs.length > 0) {
resultsFetched += data.docs.length;
addOptions(data, relation);
setLastLoadedPage(data.page);
if (!data.nextPage) {
setLastFullyLoadedRelation(relations.indexOf(relation));
// If there are more relations to search, need to reset lastLoadedPage to 1
// both locally within function and state
if (relations.indexOf(relation) + 1 < relations.length) {
lastLoadedPageToUse = 1;
}
}
}
} else {
setErrorLoading('An error has occurred.');
}
}
}, Promise.resolve());
}
}, [errorLoading, required, lastFullyLoadedRelation, relationTo, getResults, lastLoadedPage]);
}, [addOptions, api, collections, serverURL, errorLoading, relationTo]);
const findOptionsByValue = useCallback((): Option | Option[] => {
if (value) {
@@ -218,6 +206,26 @@ const Relationship: React.FC<Props> = (props) => {
}
}, [addOptions, api, errorLoading, serverURL]);
// ///////////////////////////
// Get results when search input changes
// ///////////////////////////
useEffect(() => {
dispatchOptions({
type: 'CLEAR',
required,
});
setHasLoadedFirstOptions(true);
setLastLoadedPage(1);
setLastFullyLoadedRelation(-1);
getResults({ search: debouncedSearch });
}, [getResults, debouncedSearch, relationTo, required]);
// ///////////////////////////
// Format options once first options have been retrieved
// ///////////////////////////
useEffect(() => {
if (value && hasLoadedFirstOptions) {
if (hasMany) {
@@ -247,89 +255,6 @@ const Relationship: React.FC<Props> = (props) => {
}
}, [addOptionByID, findOptionsByValue, hasMany, hasMultipleRelations, relationTo, value, hasLoadedFirstOptions]);
useEffect(() => {
const getFirstResults = async () => {
dispatchOptions({
type: 'CLEAR',
required,
});
setLastLoadedPage(1);
setLastFullyLoadedRelation(-1);
setHasLoadedFirstOptions(false);
const relations = Array.isArray(relationTo) ? relationTo : [relationTo];
const res = await fetch(`${serverURL}${api}/${relations[0]}?limit=${maxResultsPerRequest}&depth=0`);
if (res.ok) {
const data: PaginatedDocs = await res.json();
addOptions(data, relations[0]);
if (!data.hasNextPage) {
setLastFullyLoadedRelation(0);
if (relations[1]) {
const secondRes = await fetch(`${serverURL}${api}/${relations[1]}?limit=${maxResultsPerRequest}&depth=0`);
if (res.ok) {
const secondData: PaginatedDocs = await secondRes.json();
addOptions(secondData, relations[1]);
if (!secondData.hasNextPage) {
setLastFullyLoadedRelation(1);
if (relations[2]) {
const thirdRes = await fetch(`${serverURL}${api}/${relations[2]}?limit=${maxResultsPerRequest}&depth=0`);
if (res.ok) {
const thirdData: PaginatedDocs = await thirdRes.json();
addOptions(thirdData, relations[2]);
if (!thirdData.hasNextPage) {
setLastFullyLoadedRelation(2);
} else {
setLastLoadedPage(2);
}
}
}
} else {
setLastLoadedPage(2);
}
}
}
} else {
setLastLoadedPage(2);
}
setHasLoadedFirstOptions(true);
}
};
getFirstResults();
}, [addOptions, api, required, relationTo, serverURL]);
useEffect(() => {
if (debouncedSearch) {
dispatchOptions({
type: 'CLEAR',
required,
});
setLastLoadedPage(1);
setLastFullyLoadedRelation(-1);
const relations = Array.isArray(relationTo) ? relationTo : [relationTo];
getResults({
relations,
lastLoadedPage: 1,
});
}
}, [getResults, debouncedSearch, relationTo, required]);
const classes = [
'field-type',
baseClass,
@@ -382,7 +307,9 @@ const Relationship: React.FC<Props> = (props) => {
setValue(selected.value);
}
} : undefined}
onMenuScrollToBottom={getNextOptions}
onMenuScrollToBottom={() => {
getResults({ lastFullyLoadedRelation, lastLoadedPage: lastLoadedPage + 1 });
}}
value={valueToRender}
showError={showError}
disabled={formProcessing}

View File

@@ -1,11 +1,6 @@
import { PaginatedDocs, SanitizedCollectionConfig } from '../../../../../collections/config/types';
import { RelationshipField } from '../../../../../fields/config/types';
export type OptionsPage = {
relation: string
data: PaginatedDocs
}
export type Props = Omit<RelationshipField, 'type'> & {
path?: string
}
@@ -24,7 +19,7 @@ type CLEAR = {
type ADD = {
type: 'ADD'
data: PaginatedDocs
data: PaginatedDocs<any>
relation: string
hasMultipleRelations: boolean
collection: SanitizedCollectionConfig

View File

@@ -4,7 +4,7 @@ import { createEditor, Transforms, Node, Element as SlateElement, Text, BaseEdit
import { ReactEditor, Editable, withReact, Slate } from 'slate-react';
import { HistoryEditor, withHistory } from 'slate-history';
import { richText } from '../../../../../fields/validations';
import useFieldType from '../../useFieldType';
import useField from '../../useField';
import withCondition from '../../withCondition';
import Label from '../../Label';
import Error from '../../Error';
@@ -20,17 +20,16 @@ import withHTML from './plugins/withHTML';
import { Props } from './types';
import { RichTextElement, RichTextLeaf } from '../../../../../fields/config/types';
import listTypes from './elements/listTypes';
import mergeCustomFunctions from './mergeCustomFunctions';
import withEnterBreakOut from './plugins/withEnterBreakOut';
import './index.scss';
const defaultElements: RichTextElement[] = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'link', 'relationship', 'upload'];
const defaultLeaves: RichTextLeaf[] = ['bold', 'italic', 'underline', 'strikethrough', 'code'];
const enterBreakOutTypes = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'];
const baseClass = 'rich-text';
type CustomText = { text: string; [x: string]: unknown }
type CustomText = { text: string;[x: string]: unknown }
type CustomElement = { type: string; children: CustomText[] }
@@ -116,7 +115,7 @@ const RichText: React.FC<Props> = (props) => {
return validationResult;
}, [validate, required]);
const fieldType = useFieldType({
const fieldType = useField({
path,
validate: memoizedValidate,
stringify: true,
@@ -138,7 +137,7 @@ const RichText: React.FC<Props> = (props) => {
].filter(Boolean).join(' ');
const editor = useMemo(() => {
let CreatedEditor = withHTML(
let CreatedEditor = withEnterBreakOut(
withHistory(
withReact(
createEditor(),
@@ -149,6 +148,8 @@ const RichText: React.FC<Props> = (props) => {
CreatedEditor = enablePlugins(CreatedEditor, elements);
CreatedEditor = enablePlugins(CreatedEditor, leaves);
CreatedEditor = withHTML(CreatedEditor);
return CreatedEditor;
}, [elements, leaves]);
@@ -193,7 +194,7 @@ const RichText: React.FC<Props> = (props) => {
}}
>
<div className={`${baseClass}__wrap`}>
{ !hideGutter && (<FieldTypeGutter />) }
{!hideGutter && (<FieldTypeGutter />)}
<Error
showError={showError}
message={errorMessage}
@@ -271,7 +272,7 @@ const RichText: React.FC<Props> = (props) => {
if (SlateElement.isElement(selectedElement)) {
// Allow hard enter to "break out" of certain elements
if (enterBreakOutTypes.includes(String(selectedElement.type))) {
if (editor.shouldBreakOutOnEnter(selectedElement)) {
event.preventDefault();
const selectedLeaf = Node.descendant(editor, editor.selection.anchor.path);

View File

@@ -14,8 +14,6 @@
}
.rich-text-link__popup-wrap {
position: absolute;
z-index: $z-page-content;
cursor: pointer;
.tooltip {
@@ -24,11 +22,20 @@
}
.rich-text-link__button {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
@extend %btn-reset;
font-size: inherit;
font-weight: inherit;
color: inherit;
letter-spacing: inherit;
line-height: inherit;
position: relative;
z-index: 2;
text-decoration: underline;
cursor: text;
&--open {
z-index: $z-modal + 1;
}
}
.rich-text-link__url-wrap {

View File

@@ -1,16 +1,13 @@
import React, { Fragment, useCallback, useEffect, useRef, useState } from 'react';
import React, { Fragment, useCallback, useEffect, useState } from 'react';
import { ReactEditor, useSlate } from 'slate-react';
import { useWindowInfo } from '@faceless-ui/window-info';
import { Transforms } from 'slate';
import ElementButton from '../Button';
import { withLinks, wrapLink } from './utilities';
import LinkIcon from '../../../../../icons/Link';
import Portal from '../../../../../utilities/Portal';
import Popup from '../../../../../elements/Popup';
import Button from '../../../../../elements/Button';
import Check from '../../../../../icons/Check';
import Error from '../../../../Error';
import getOffsetTop from '../../../../../../utilities/getOffsetTop';
import './index.scss';
@@ -18,32 +15,11 @@ const baseClass = 'rich-text-link';
const Link = ({ attributes, children, element }) => {
const editor = useSlate();
const linkRef = useRef(null);
const { height: windowHeight, width: windowWidth } = useWindowInfo();
const [left, setLeft] = useState(0);
const [top, setTop] = useState(0);
const [error, setError] = useState(false);
const [width, setWidth] = useState(0);
const [height, setHeight] = useState(0);
const [open, setOpen] = useState(!element.url);
const [url, setURL] = useState(element.url);
const [newTab, setNewTab] = useState(Boolean(element.newTab));
const calculatePosition = useCallback(() => {
if (linkRef?.current) {
const rect = linkRef.current.getBoundingClientRect();
const offsetTop = getOffsetTop(linkRef.current);
setTop(offsetTop);
setLeft(rect.left);
setWidth(rect.width);
setHeight(rect.height);
}
}, []);
useEffect(() => {
calculatePosition();
}, [children, calculatePosition, windowHeight, windowWidth]);
useEffect(() => {
const path = ReactEditor.findPath(editor, element);
@@ -54,88 +30,92 @@ const Link = ({ attributes, children, element }) => {
);
}, [url, newTab, editor, element]);
const handleToggleOpen = useCallback((toggleResult) => {
setOpen(toggleResult);
}, []);
return (
<span
className={baseClass}
{...attributes}
>
<span ref={linkRef}>
<Portal>
<div
className={`${baseClass}__popup-wrap`}
style={{
width,
height,
top,
left,
}}
>
<Popup
initActive={url === undefined}
buttonType="custom"
button={<span className={`${baseClass}__button`} />}
size="small"
color="dark"
horizontalAlign="center"
onToggleOpen={calculatePosition}
render={({ close }) => (
<Fragment>
<div className={`${baseClass}__url-wrap`}>
<input
value={url || ''}
className={`${baseClass}__url`}
placeholder="Enter a URL"
onChange={(e) => {
const { value } = e.target;
<span
style={{ userSelect: 'none' }}
contentEditable={false}
>
<Popup
initActive={url === undefined}
buttonType="none"
size="small"
color="dark"
horizontalAlign="center"
forceOpen={open}
onToggleOpen={handleToggleOpen}
render={({ close }) => (
<Fragment>
<div className={`${baseClass}__url-wrap`}>
<input
value={url || ''}
className={`${baseClass}__url`}
placeholder="Enter a URL"
onChange={(e) => {
const { value } = e.target;
if (value && error) {
setError(false);
}
if (value && error) {
setError(false);
}
setURL(value);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
close();
}
}}
/>
<Button
className={`${baseClass}__confirm`}
buttonStyle="none"
icon="chevron"
onClick={(e) => {
e.preventDefault();
setURL(value);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
close();
}
}}
/>
<Button
className={`${baseClass}__confirm`}
buttonStyle="none"
icon="chevron"
onClick={(e) => {
e.preventDefault();
if (url) {
close();
} else {
setError(true);
}
}}
/>
{error && (
<Error
showError={error}
message="Please enter a valid URL."
/>
)}
</div>
<Button
className={[`${baseClass}__new-tab`, newTab && `${baseClass}__new-tab--checked`].filter(Boolean).join(' ')}
buttonStyle="none"
onClick={() => setNewTab(!newTab)}
>
<Check />
Open link in new tab
</Button>
</Fragment>
)}
/>
</div>
</Portal>
{children}
if (url) {
close();
} else {
setError(true);
}
}}
/>
{error && (
<Error
showError={error}
message="Please enter a valid URL."
/>
)}
</div>
<Button
className={[`${baseClass}__new-tab`, newTab && `${baseClass}__new-tab--checked`].filter(Boolean).join(' ')}
buttonStyle="none"
onClick={() => setNewTab(!newTab)}
>
<Check />
Open link in new tab
</Button>
</Fragment>
)}
/>
</span>
<button
className={[
`${baseClass}__button`,
open && `${baseClass}__button--open`,
].filter(Boolean).join(' ')}
type="button"
onClick={() => setOpen(true)}
>
{children}
</button>
</span>
);
};

View File

@@ -15,6 +15,7 @@ import Label from '../../../../../Label';
import MinimalTemplate from '../../../../../../templates/Minimal';
import Button from '../../../../../../elements/Button';
import { SanitizedCollectionConfig } from '../../../../../../../../collections/config/types';
import PerPage from '../../../../../../elements/PerPage';
import './index.scss';
import '../modal.scss';
@@ -52,14 +53,19 @@ const UploadButton: React.FC<{path: string}> = ({ path }) => {
const [renderModal, setRenderModal] = useState(false);
const [modalCollectionOption, setModalCollectionOption] = useState<{ label: string, value: string}>(() => {
const firstAvailableCollection = collections.find(({ admin: { enableRichTextRelationship }, upload }) => (Boolean(upload) && enableRichTextRelationship));
return { label: firstAvailableCollection.labels.singular, value: firstAvailableCollection.slug };
if (firstAvailableCollection) {
return { label: firstAvailableCollection.labels.singular, value: firstAvailableCollection.slug };
}
return undefined;
});
const [modalCollection, setModalCollection] = useState<SanitizedCollectionConfig>(() => collections.find(({ admin: { enableRichTextRelationship }, upload }) => (Boolean(upload) && enableRichTextRelationship)));
const [listControls, setListControls] = useState<{where?: unknown}>({});
const [page, setPage] = useState(null);
const [fields, setFields] = useState(() => (modalCollection ? formatFields(modalCollection) : undefined));
const [limit, setLimit] = useState<number>();
const [sort, setSort] = useState(null);
const [fields, setFields] = useState(formatFields(modalCollection));
const [hasEnabledCollections] = useState(() => collections.find(({ upload, admin: { enableRichTextRelationship } }) => upload && enableRichTextRelationship));
const [where, setWhere] = useState(null);
const [page, setPage] = useState(null);
const modalSlug = `${path}-add-upload`;
const moreThanOneAvailableCollection = availableCollections.length > 1;
@@ -70,7 +76,9 @@ const UploadButton: React.FC<{path: string}> = ({ path }) => {
const [{ data }, { setParams }] = usePayloadAPI(apiURL, {});
useEffect(() => {
setFields(formatFields(modalCollection));
if (modalCollection) {
setFields(formatFields(modalCollection));
}
}, [modalCollection]);
useEffect(() => {
@@ -84,20 +92,26 @@ const UploadButton: React.FC<{path: string}> = ({ path }) => {
page?: number
sort?: string
where?: unknown
limit?: number
} = {};
if (page) params.page = page;
if (listControls?.where) params.where = listControls.where;
if (where) params.where = where;
if (sort) params.sort = sort;
if (limit) params.limit = limit;
setParams(params);
}, [setParams, page, listControls, sort]);
}, [setParams, page, sort, where, limit]);
useEffect(() => {
setModalCollection(collections.find(({ slug }) => modalCollectionOption.value === slug));
if (modalCollectionOption) {
setModalCollection(collections.find(({ slug }) => modalCollectionOption.value === slug));
}
}, [modalCollectionOption, collections]);
if (!hasEnabledCollections) return null;
if (!modalCollection) {
return null;
}
return (
<Fragment>
@@ -144,14 +158,15 @@ const UploadButton: React.FC<{path: string}> = ({ path }) => {
</div>
)}
<ListControls
handleChange={setListControls}
collection={{
...modalCollection,
fields,
}}
enableColumns={false}
setSort={setSort}
enableSort
modifySearchQuery={false}
handleSortChange={setSort}
handleWhereChange={setWhere}
/>
<UploadGallery
docs={data?.docs}
@@ -167,7 +182,7 @@ const UploadButton: React.FC<{path: string}> = ({ path }) => {
closeAll();
}}
/>
<div className={`${baseClass}__page-controls`}>
<div className={`${baseModalClass}__page-controls`}>
<Paginator
limit={data.limit}
totalPages={data.totalPages}
@@ -181,15 +196,23 @@ const UploadButton: React.FC<{path: string}> = ({ path }) => {
disableHistoryChange
/>
{data?.totalDocs > 0 && (
<div className={`${baseClass}__page-info`}>
{data.page}
-
{data.totalPages > 1 ? data.limit : data.totalDocs}
{' '}
of
{' '}
{data.totalDocs}
</div>
<Fragment>
<div className={`${baseModalClass}__page-info`}>
{data.page}
-
{data.totalPages > 1 ? data.limit : data.totalDocs}
{' '}
of
{' '}
{data.totalDocs}
</div>
<PerPage
collection={modalCollection}
limit={limit}
modifySearchParams={false}
handleChange={setLimit}
/>
</Fragment>
)}
</div>
</MinimalTemplate>

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