Compare commits
139 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2faade7a03 | ||
|
|
89682cf034 | ||
|
|
82c69a17b9 | ||
|
|
f0f4dc12e5 | ||
|
|
727fbeceb4 | ||
|
|
5127826de0 | ||
|
|
ded891e390 | ||
|
|
34f416aace | ||
|
|
bc753951a0 | ||
|
|
d6d76d4088 | ||
|
|
70b58e2826 | ||
|
|
43a25195b7 | ||
|
|
e01173dd51 | ||
|
|
77a208fff7 | ||
|
|
cef06e3c79 | ||
|
|
a0fb48c9a3 | ||
|
|
6b150e01d3 | ||
|
|
820b6ad4c7 | ||
|
|
bb18e8250c | ||
|
|
b99eb8ba73 | ||
|
|
f258c5904e | ||
|
|
57cab22387 | ||
|
|
7050b5285e | ||
|
|
38ee73ba2e | ||
|
|
96421b3d59 | ||
|
|
0245747020 | ||
|
|
4affdc3a93 | ||
|
|
ccbe9f5137 | ||
|
|
23f7efe7d1 | ||
|
|
051b7d45be | ||
|
|
3540a188a4 | ||
|
|
da6e1df293 | ||
|
|
01429b6570 | ||
|
|
cdd55a1c6b | ||
|
|
40ca3dae61 | ||
|
|
5d43262f42 | ||
|
|
bd373598b5 | ||
|
|
26aaef8851 | ||
|
|
6fd5ac2c08 | ||
|
|
21a810c38c | ||
|
|
91fae55d90 | ||
|
|
d9e1b5ede3 | ||
|
|
99a3386dd6 | ||
|
|
c49c9b0328 | ||
|
|
d151003eb6 | ||
|
|
3436e6173f | ||
|
|
b2fe27dda5 | ||
|
|
ed5a5ebe7e | ||
|
|
2ca76ba8ce | ||
|
|
6dd1b0e033 | ||
|
|
5a965d2263 | ||
|
|
3ab9d9e740 | ||
|
|
438b6b3e51 | ||
|
|
40899c211b | ||
|
|
b2c5b7e575 | ||
|
|
291c193ad4 | ||
|
|
7c6424ff35 | ||
|
|
a7525e2931 | ||
|
|
e7b1adf4ed | ||
|
|
f67286be7b | ||
|
|
e3e41c3621 | ||
|
|
72fc413764 | ||
|
|
463c4e60de | ||
|
|
e06df905c5 | ||
|
|
8987ce1f69 | ||
|
|
7337169342 | ||
|
|
bee18a5e99 | ||
|
|
abf61d0734 | ||
|
|
20d4e72a95 | ||
|
|
056f078615 | ||
|
|
94c2b8d80b | ||
|
|
0eceb8d76c | ||
|
|
37b21b0762 | ||
|
|
40b33d9f5e | ||
|
|
7303312142 | ||
|
|
57c0346a00 | ||
|
|
6b14984352 | ||
|
|
4c85747849 | ||
|
|
a870cc7036 | ||
|
|
b4c15ed3f3 | ||
|
|
a0b38f6832 | ||
|
|
83f41df82f | ||
|
|
5b36bd7b43 | ||
|
|
d443ea582c | ||
|
|
881952e1cc | ||
|
|
9d7feb9796 | ||
|
|
bc6c892e0a | ||
|
|
48315b0e67 | ||
|
|
c35009f14c | ||
|
|
935a483eaa | ||
|
|
badbdca351 | ||
|
|
c02e8f14c7 | ||
|
|
92cb30e921 | ||
|
|
edb723a4fb | ||
|
|
dbac0724ad | ||
|
|
328585edbd | ||
|
|
914cca6b92 | ||
|
|
e3b05f9076 | ||
|
|
86e88d998f | ||
|
|
6d50afd864 | ||
|
|
4527dda08c | ||
|
|
cc4d1fd045 | ||
|
|
3b99deda45 | ||
|
|
900f05eefd | ||
|
|
716c05f5d8 | ||
|
|
b22c8963cb | ||
|
|
eb05b47c54 | ||
|
|
ca91f47d32 | ||
|
|
5040ee629f | ||
|
|
5be09ffc78 | ||
|
|
4c87123514 | ||
|
|
f57f81a3cb | ||
|
|
423ca01ab1 | ||
|
|
9eedce7345 | ||
|
|
a2df67eccd | ||
|
|
3908c012f9 | ||
|
|
ecda271258 | ||
|
|
84f6a9d659 | ||
|
|
7d49302ffa | ||
|
|
f3455aafe9 | ||
|
|
fcd9c28871 | ||
|
|
a6fc1fdc58 | ||
|
|
630fa68714 | ||
|
|
ef4f284fb0 | ||
|
|
5a63f11ed7 | ||
|
|
6807637e25 | ||
|
|
d88ce2d342 | ||
|
|
b257e01c8d | ||
|
|
c132f2ff10 | ||
|
|
d0259ceecd | ||
|
|
fd4fbe8c8b | ||
|
|
4432031341 | ||
|
|
932628bc14 | ||
|
|
27117292f3 | ||
|
|
3715e011c9 | ||
|
|
2eb81546c3 | ||
|
|
bbdeebd1d4 | ||
|
|
5056e18734 | ||
|
|
e3229c55f3 |
10
.eslintrc.js
10
.eslintrc.js
@@ -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',
|
||||
|
||||
2
.github/workflows/tests.yml
vendored
2
.github/workflows/tests.yml
vendored
@@ -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
3
.gitignore
vendored
@@ -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
15
.vscode/launch.json
vendored
@@ -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/**"
|
||||
]
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
152
CHANGELOG.md
152
CHANGELOG.md
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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';
|
||||
|
||||
7
demo/client/components/DemoUIField/Cell.tsx
Normal file
7
demo/client/components/DemoUIField/Cell.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
const DemoUIFieldCell: React.FC = () => (
|
||||
<p>Demo UI Field Cell</p>
|
||||
);
|
||||
|
||||
export default DemoUIFieldCell;
|
||||
7
demo/client/components/DemoUIField/Field.tsx
Normal file
7
demo/client/components/DemoUIField/Field.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
const DemoUIField: React.FC = () => (
|
||||
<p>Demo UI Field</p>
|
||||
);
|
||||
|
||||
export default DemoUIField;
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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: {
|
||||
|
||||
@@ -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'>,
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -15,7 +15,6 @@ const RelationshipA: CollectionConfig = {
|
||||
label: 'Post',
|
||||
type: 'relationship',
|
||||
relationTo: 'relationship-b',
|
||||
localized: true,
|
||||
},
|
||||
{
|
||||
name: 'LocalizedPost',
|
||||
|
||||
@@ -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
721
demo/payload-types.ts
Normal 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];
|
||||
}
|
||||
@@ -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: () => (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
```
|
||||
|
||||
@@ -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';
|
||||
```
|
||||
|
||||
@@ -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—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';
|
||||
|
||||
```
|
||||
|
||||
@@ -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
54
docs/fields/ui.mdx
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
```
|
||||
@@ -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
|
||||
---
|
||||
|
||||
@@ -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...
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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...
|
||||
}
|
||||
```
|
||||
|
||||
114
docs/typescript/generating-types.mdx
Normal file
114
docs/typescript/generating-types.mdx
Normal 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.
|
||||
36
docs/typescript/overview.mdx
Normal file
36
docs/typescript/overview.mdx
Normal 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)
|
||||
@@ -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:
|
||||
|
||||
|
||||
25
package.json
25
package.json
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -2,5 +2,6 @@ import { SanitizedCollectionConfig } from '../../../../collections/config/types'
|
||||
|
||||
export type Props = {
|
||||
collection: SanitizedCollectionConfig,
|
||||
handleChange: (columns) => void,
|
||||
columns: string[]
|
||||
setColumns: (columns: string[]) => void,
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -6,4 +6,5 @@ export type Props = {
|
||||
width?: number,
|
||||
height?: number,
|
||||
sizes?: unknown,
|
||||
url?: string
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
|
||||
38
src/admin/components/elements/PerPage/index.scss
Normal file
38
src/admin/components/elements/PerPage/index.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
86
src/admin/components/elements/PerPage/index.tsx
Normal file
86
src/admin/components/elements/PerPage/index.tsx
Normal 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;
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'],
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -17,4 +17,5 @@ export type Props = {
|
||||
isDisabled?: boolean
|
||||
onInputChange?: (val: string) => void
|
||||
onMenuScrollToBottom?: () => void
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { Where } from '../../../../types';
|
||||
|
||||
export type Props = {
|
||||
fieldName?: string,
|
||||
fieldLabel?: string,
|
||||
handleChange: (any) => void,
|
||||
modifySearchQuery?: boolean
|
||||
handleChange?: (where: Where) => void
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
export type Props = {
|
||||
label: string,
|
||||
handleChange: (sort) => void,
|
||||
name: string,
|
||||
disable?: boolean,
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -100,7 +100,7 @@ const fieldTypeConditions = {
|
||||
operators: [...base],
|
||||
},
|
||||
relationship: {
|
||||
component: 'Text',
|
||||
component: 'Relationship',
|
||||
operators: [...base],
|
||||
},
|
||||
select: {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 || {}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)))
|
||||
|| [];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
.field-type.email {
|
||||
margin-bottom: $baseline;
|
||||
position: relative;
|
||||
|
||||
input {
|
||||
@include formInput;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user