Merge branch 'master' of https://github.com/payloadcms/payload into feat/form-builder-example
This commit is contained in:
42
.github/ISSUE_TEMPLATE/1.bug_report.yml
vendored
Normal file
42
.github/ISSUE_TEMPLATE/1.bug_report.yml
vendored
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
name: Bug Report
|
||||||
|
description: Create a bug report for the Payload CMS
|
||||||
|
labels: ["possible-bug"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
*Note:* Feature requests should be opened as [discussions](https://github.com/payloadcms/payload/discussions/new?category=feature-requests-ideas).
|
||||||
|
- type: input
|
||||||
|
id: reproduction-link
|
||||||
|
attributes:
|
||||||
|
label: Link to reproduction
|
||||||
|
description: Please add a link to a reproduction. See the fork [reproduction-guide](https://github.com/payloadcms/payload/blob/master/.github/reproduction-guide.md) for more information.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: To Reproduce
|
||||||
|
description: Steps to reproduce the behavior, please provide a clear description of how to reproduce the issue, based on the linked minimal reproduction. Screenshots can be provided in the issue body below. If using code blocks, make sure that [syntax highlighting is correct](https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/creating-and-highlighting-code-blocks#syntax-highlighting) and double check that the rendered preview is not broken.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Describe the Bug
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: Payload Version
|
||||||
|
description: What version of Payload are you running?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: Before submitting the issue, go through the steps you've written down to make sure the steps provided are detailed and clear.
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: Contributors should be able to follow the steps provided in order to reproduce the bug.
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: These steps are used to add integration tests to ensure the same issue does not happen again. Thanks in advance!
|
||||||
22
.github/ISSUE_TEMPLATE/BUG_REPORT.md
vendored
22
.github/ISSUE_TEMPLATE/BUG_REPORT.md
vendored
@@ -1,22 +0,0 @@
|
|||||||
---
|
|
||||||
name: Bug Report
|
|
||||||
about: Create a bug report for Payload
|
|
||||||
labels: 'possible-bug'
|
|
||||||
---
|
|
||||||
|
|
||||||
# Bug Report
|
|
||||||
|
|
||||||
<!--- Provide a general summary of the issue in the Title above -->
|
|
||||||
|
|
||||||
## Steps to Reproduce
|
|
||||||
|
|
||||||
<!--- Steps to reproduce this bug. Include any code, if relevant -->
|
|
||||||
|
|
||||||
1.
|
|
||||||
2.
|
|
||||||
3.
|
|
||||||
|
|
||||||
## Other Details
|
|
||||||
|
|
||||||
<!--- Payload version, browser, etc -->
|
|
||||||
<!--- Possible solution if you're familiar with the code -->
|
|
||||||
3
.github/ISSUE_TEMPLATE/config.yml
vendored
3
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,8 +1,5 @@
|
|||||||
blank_issues_enabled: false
|
blank_issues_enabled: false
|
||||||
contact_links:
|
contact_links:
|
||||||
- name: Security Vulnerability
|
|
||||||
url: https://github.com/payloadcms/payload/blob/master/SECURITY.md
|
|
||||||
about: See instructions to privately disclose any security concerns
|
|
||||||
- name: Feature Request
|
- name: Feature Request
|
||||||
url: https://github.com/payloadcms/payload/discussions
|
url: https://github.com/payloadcms/payload/discussions
|
||||||
about: Suggest an idea to improve Payload in our GitHub Discussions
|
about: Suggest an idea to improve Payload in our GitHub Discussions
|
||||||
|
|||||||
58
.github/reproduction-guide.md
vendored
Normal file
58
.github/reproduction-guide.md
vendored
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# Reproduction Guide
|
||||||
|
|
||||||
|
1. [fork](https://github.com/payloadcms/payload/fork) this repo
|
||||||
|
2. run `yarn` to install dependencies
|
||||||
|
3. open up the `test/_community` directory
|
||||||
|
4. add any necessary `collections/globals/fields` in this directory to recreate the issue you are experiencing
|
||||||
|
5. run `yarn dev _community` to start the admin panel
|
||||||
|
|
||||||
|
**NOTE:** The goal is to isolate the problem by reducing the number of `collections/globals/fields` you add to the `test/_community` folder. This folder is _not_ meant for you to copy your project into, but rather recreate the issue you are experiencing with minimal config.
|
||||||
|
|
||||||
|
## Example test directory file tree
|
||||||
|
```text
|
||||||
|
.
|
||||||
|
├── config.ts
|
||||||
|
├── int.spec.ts
|
||||||
|
├── e2e.spec.ts
|
||||||
|
└── payload-types.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
- `config.ts` - This is the _granular_ Payload config for testing. It should be as lightweight as possible. Reference existing configs for an example
|
||||||
|
- `int.spec.ts` [Optional] - This is the test file run by jest. Any test file must have a `*int.spec.ts` suffix.
|
||||||
|
- `e2e.spec.ts` [Optional] - This is the end-to-end test file that will load up the admin UI using the above config and run Playwright tests.
|
||||||
|
- `payload-types.ts` - Generated types from `config.ts`. Generate this file by running `yarn dev:generate-types _community`.
|
||||||
|
|
||||||
|
The directory split up in this way specifically to reduce friction when creating tests and to add the ability to boot up Payload with that specific config. You should modify the files in `test/_community` to get started.
|
||||||
|
|
||||||
|
<br />
|
||||||
|
|
||||||
|
## Testing is optional but encouraged
|
||||||
|
An issue does not need to have failing tests — reproduction steps with your forked repo are enough at this point. Some people like to dive deeper and we want to give you the guidance/tools to do so. Read more below:
|
||||||
|
|
||||||
|
### Running integration tests (Payload API tests)
|
||||||
|
First install [Jest Runner for VSVode](https://marketplace.visualstudio.com/items?itemName=firsttris.vscode-jest-runner).
|
||||||
|
|
||||||
|
There are a couple ways run integration tests:
|
||||||
|
|
||||||
|
- **Granularly** - you can run individual tests in vscode by installing the Jest Runner plugin and using that to run individual tests. Clicking the `debug` button will run the test in debug mode allowing you to set break points.
|
||||||
|
|
||||||
|
<img src="https://raw.githubusercontent.com/payloadcms/payload/master/src/admin/assets/images/github/int-debug.png" />
|
||||||
|
|
||||||
|
- **Manually** - you can run all int tests in the `/test/_community/int.spec.ts` file by running the following command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn test:int _community
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running E2E tests (Admin Panel UI tests)
|
||||||
|
The easiest way to run E2E tests is to install
|
||||||
|
- [Playwright Test for VSCode](https://marketplace.visualstudio.com/items?itemName=ms-playwright.playwright)
|
||||||
|
- [Playwright Runner](https://marketplace.visualstudio.com/items?itemName=ortoni.ortoni)
|
||||||
|
|
||||||
|
Once they are installed you can open the `testing` tab in vscode sidebar and drill down to the test you want to run, i.e. `/test/_community/e2e.spec.ts`
|
||||||
|
|
||||||
|
<img src="https://raw.githubusercontent.com/payloadcms/payload/master/src/admin/assets/images/github/e2e-debug.png" />
|
||||||
|
|
||||||
|
|
||||||
|
#### Notes
|
||||||
|
- It is recommended to add the test credentials (located in `test/credentials.ts`) to your autofill for `localhost:3000/admin` as this will be required on every nodemon restart. The default credentials are `dev@payloadcms.com` as email and `test` as password.
|
||||||
88
CHANGELOG.md
88
CHANGELOG.md
@@ -1,5 +1,93 @@
|
|||||||
|
|
||||||
|
|
||||||
|
## [1.6.32](https://github.com/payloadcms/payload/compare/v1.6.31...v1.6.32) (2023-04-05)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* only uses sharp if required ([f9f6ec4](https://github.com/payloadcms/payload/commit/f9f6ec47d9a4f9ed94b7f7a4d50f13a8ee881ad0))
|
||||||
|
|
||||||
|
## [1.6.31](https://github.com/payloadcms/payload/compare/v1.6.30...v1.6.31) (2023-04-04)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* ensures select hasMany does not get mutated on patch operations ([3a6acf3](https://github.com/payloadcms/payload/commit/3a6acf322b5546ca3cd1d4dcb093af6e3b6ed086))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* improves required type accuracy ([a9cd23a](https://github.com/payloadcms/payload/commit/a9cd23a883d89c8deb3c1b5386decd50516d69fd))
|
||||||
|
|
||||||
|
## [1.6.30](https://github.com/payloadcms/payload/compare/v1.6.29...v1.6.30) (2023-04-03)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* incorrect type local api using delete with where ([de5ceb2](https://github.com/payloadcms/payload/commit/de5ceb2aca624f702ea39556ffe2f689701615c1))
|
||||||
|
* originalDoc being mutated in beforeChange field hooks ([888bbf2](https://github.com/payloadcms/payload/commit/888bbf28e0b793a2298e27a7e1df235d78b0a718))
|
||||||
|
|
||||||
|
## [1.6.29](https://github.com/payloadcms/payload/compare/v1.6.28...v1.6.29) (2023-03-31)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* update and delete local API return types ([#2434](https://github.com/payloadcms/payload/issues/2434)) ([02410a0](https://github.com/payloadcms/payload/commit/02410a0be38004b90d19207071569294fd104a66))
|
||||||
|
|
||||||
|
## [1.6.28](https://github.com/payloadcms/payload/compare/v1.6.27...v1.6.28) (2023-03-28)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* potential memory leak with `probe-image-size` ([8eea0d6](https://github.com/payloadcms/payload/commit/8eea0d6cf41dd6360d713f463ad1b48ba253a9e7))
|
||||||
|
|
||||||
|
## [1.6.27](https://github.com/payloadcms/payload/compare/v1.6.26...v1.6.27) (2023-03-27)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* [#2355](https://github.com/payloadcms/payload/issues/2355), select field not fully visible on small screens in certain scenarios ([07eb8dd](https://github.com/payloadcms/payload/commit/07eb8dd7d252043c00b79d532736896134204c4c))
|
||||||
|
* [#2384](https://github.com/payloadcms/payload/issues/2384), preserves manually set verified from admin UI ([72a8b1e](https://github.com/payloadcms/payload/commit/72a8b1eebe6c3b45663a14fa7488772ad13f975d))
|
||||||
|
* hide fields with admin.hidden attribute ([ad25b09](https://github.com/payloadcms/payload/commit/ad25b096b6efa7e0cba647e82e29e36f7a95934a))
|
||||||
|
* make update typing a deep partial ([#2407](https://github.com/payloadcms/payload/issues/2407)) ([e8dc7d4](https://github.com/payloadcms/payload/commit/e8dc7d462e21d1021275a95fbf62094f290e37ce))
|
||||||
|
* restoring version did not correctly create new version from result ([6ca12b1](https://github.com/payloadcms/payload/commit/6ca12b1cc06554b04f3055df8f01d7eee1c09169))
|
||||||
|
* textarea field overlap in UI ([1c8cf24](https://github.com/payloadcms/payload/commit/1c8cf24ba623746c160007d7c09b3160f2aae8d3))
|
||||||
|
|
||||||
|
## [1.6.25](https://github.com/payloadcms/payload/compare/v1.6.24...v1.6.25) (2023-03-24)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* upload field select existing file ([#2392](https://github.com/payloadcms/payload/issues/2392)) ([38e917a](https://github.com/payloadcms/payload/commit/38e917a3dfa70ac3234915a6c8f7424eb22cb000))
|
||||||
|
|
||||||
|
## [1.6.24](https://github.com/payloadcms/payload/compare/v1.6.23...v1.6.24) (2023-03-23)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* bulk-operations ([#2346](https://github.com/payloadcms/payload/issues/2346)) ([0fedbab](https://github.com/payloadcms/payload/commit/0fedbabe9e975f375dc12447fcdab4119bc6a4c4))
|
||||||
|
|
||||||
|
## [1.6.23](https://github.com/payloadcms/payload/compare/v1.6.22...v1.6.23) (2023-03-22)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* [#2315](https://github.com/payloadcms/payload/issues/2315) - deleting files if overwriteExistingFiles is true ([4d578f1](https://github.com/payloadcms/payload/commit/4d578f1bfd05efab5cc8db95895eabb776b2d9d1))
|
||||||
|
* [#2363](https://github.com/payloadcms/payload/issues/2363) version tabs and select field comparisons ([#2364](https://github.com/payloadcms/payload/issues/2364)) ([21b8da7](https://github.com/payloadcms/payload/commit/21b8da7f415cdace9f7d5898c98f9c7a6bb39107))
|
||||||
|
* allows base64 thumbnails ([#2361](https://github.com/payloadcms/payload/issues/2361)) ([e09ebff](https://github.com/payloadcms/payload/commit/e09ebfffa0a7a7fdb3469f272de0e6930d97a336))
|
||||||
|
* DateField admin type ([#2256](https://github.com/payloadcms/payload/issues/2256)) ([fb2fd3e](https://github.com/payloadcms/payload/commit/fb2fd3e9b7e302d8069bfcb6f3cb698ac7abf0ca))
|
||||||
|
* fallback to default locale showing on non-localized fields ([#2316](https://github.com/payloadcms/payload/issues/2316)) ([e1a6e08](https://github.com/payloadcms/payload/commit/e1a6e08aa140cf21597d6009b811f7fdd2106f4f))
|
||||||
|
* Fix missing Spanish translations ([#2372](https://github.com/payloadcms/payload/issues/2372)) ([c0ff75c](https://github.com/payloadcms/payload/commit/c0ff75c1647a36219549e20fc081883f8cf1d7e4))
|
||||||
|
* relationship field useAsTitle [#2333](https://github.com/payloadcms/payload/issues/2333) ([#2350](https://github.com/payloadcms/payload/issues/2350)) ([10dd819](https://github.com/payloadcms/payload/commit/10dd819863ecac4a5cea2e13f820df2224ac57f4))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* adds title attribute to ThumbnailCard ([#2368](https://github.com/payloadcms/payload/issues/2368)) ([a8766d0](https://github.com/payloadcms/payload/commit/a8766d00a8365c8e6ffe507944fbe49aaa39d4bd))
|
||||||
|
* exposes defaultSort property for collection list view ([#2382](https://github.com/payloadcms/payload/issues/2382)) ([1f480c4](https://github.com/payloadcms/payload/commit/1f480c4cd5673a6fe08360183fe1c7c1d4e05de0))
|
||||||
|
|
||||||
|
## [1.6.22](https://github.com/payloadcms/payload/compare/v1.6.21...v1.6.22) (2023-03-15)
|
||||||
|
|
||||||
## [1.6.21](https://github.com/payloadcms/payload/compare/v1.6.20...v1.6.21) (2023-03-15)
|
## [1.6.21](https://github.com/payloadcms/payload/compare/v1.6.20...v1.6.21) (2023-03-15)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
64
ISSUE_GUIDE.md
Normal file
64
ISSUE_GUIDE.md
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# Reporting an issue
|
||||||
|
|
||||||
|
To report an issue, please follow the steps below:
|
||||||
|
|
||||||
|
1. Fork this repository
|
||||||
|
2. Add necessary collections/globals/fields to the `test/_community` directory to recreate the issue you are experiencing
|
||||||
|
3. Create an issue and add a link to your forked repo
|
||||||
|
|
||||||
|
**The goal is to isolate the problem by reducing the number of fields/collections you add to the test/_community folder. This folder is not meant for you to copy your project into, but to recreate the issue you are experiencing with minimal config.**
|
||||||
|
|
||||||
|
## Test directory file tree explanation
|
||||||
|
```text
|
||||||
|
.
|
||||||
|
├── config.ts
|
||||||
|
├── int.spec.ts
|
||||||
|
├── e2e.spec.ts
|
||||||
|
└── payload-types.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
- `config.ts` - This is the _granular_ Payload config for testing. It should be as lightweight as possible. Reference existing configs for an example
|
||||||
|
- `int.spec.ts` [Optional] - This is the test file run by jest. Any test file must have a `*int.spec.ts` suffix.
|
||||||
|
- `e2e.spec.ts` [Optional] - This is the end-to-end test file that will load up the admin UI using the above config and run Playwright tests.
|
||||||
|
- `payload-types.ts` - Generated types from `config.ts`. Generate this file by running `yarn dev:generate-types _community`.
|
||||||
|
|
||||||
|
The directory split up in this way specifically to reduce friction when creating tests and to add the ability to boot up Payload with that specific config. You should modify the files in `test/_community` to get started.
|
||||||
|
|
||||||
|
## How to start test collection admin UI
|
||||||
|
To start the admin panel so you can manually recreate your issue, you can run the following command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# This command will start up Payload using your config
|
||||||
|
# NOTE: it will wipe the test database on restart
|
||||||
|
yarn dev _community
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Testing is optional but encouraged
|
||||||
|
An issue does not need to have failing tests — reproduction steps with your forked repo are enough at this point. Some people like to dive deeper and we want to give you the guidance/tools to do so. Read more below.
|
||||||
|
|
||||||
|
### How to run integration tests (Payload API tests)
|
||||||
|
There are a couple ways to do this:
|
||||||
|
|
||||||
|
- **Granularly** - you can run individual tests in vscode by installing the Jest Runner plugin and using that to run individual tests. Clicking the `debug` button will run the test in debug mode allowing you to set break points.
|
||||||
|
|
||||||
|
<img src="https://raw.githubusercontent.com/payloadcms/payload/master/src/admin/assets/images/github/int-debug.png" />
|
||||||
|
|
||||||
|
- **Manually** - you can run all int tests in the `/test/_community/int.spec.ts` file by running the following command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn test:int _community
|
||||||
|
```
|
||||||
|
|
||||||
|
### How to run E2E tests (Admin Panel UI tests)
|
||||||
|
The easiest way to run E2E tests is to install
|
||||||
|
- [Playwright Test for VSCode](https://marketplace.visualstudio.com/items?itemName=ms-playwright.playwright)
|
||||||
|
- [Playwright Runner](https://marketplace.visualstudio.com/items?itemName=ortoni.ortoni)
|
||||||
|
|
||||||
|
Once they are installed you can open the `testing` tab in vscode sidebar and drill down to the test you want to run, i.e. `/test/_community/e2e.spec.ts`
|
||||||
|
|
||||||
|
<img src="https://raw.githubusercontent.com/payloadcms/payload/master/src/admin/assets/images/github/e2e-debug.png" />
|
||||||
|
|
||||||
|
|
||||||
|
#### Notes
|
||||||
|
- It is recommended to add the test credentials (located in `test/credentials.ts`) to your autofill for `localhost:3000/admin` as this will be required on every nodemon restart. The default credentials are `dev@payloadcms.com` as email and `test` as password.
|
||||||
@@ -49,8 +49,6 @@ The directory split up in this way specifically to reduce friction when creating
|
|||||||
|
|
||||||
The following command will start Payload with your config: `yarn dev my-test-dir`. This command will start up Payload using your config and refresh a test database on every restart.
|
The following command will start Payload with your config: `yarn dev my-test-dir`. This command will start up Payload using your config and refresh a test database on every restart.
|
||||||
|
|
||||||
When switching between test directories, you will want to remove your `node_modules/.cache ` manually or by running `yarn clean:cache`.
|
|
||||||
|
|
||||||
NOTE: It is recommended to add the test credentials (located in `test/credentials.ts`) to your autofill for `localhost:3000/admin` as this will be required on every nodemon restart. The default credentials are `dev@payloadcms.com` as E-Mail and `test` as password.
|
NOTE: It is recommended to add the test credentials (located in `test/credentials.ts`) to your autofill for `localhost:3000/admin` as this will be required on every nodemon restart. The default credentials are `dev@payloadcms.com` as E-Mail and `test` as password.
|
||||||
|
|
||||||
## Pull Requests
|
## Pull Requests
|
||||||
|
|||||||
@@ -166,7 +166,6 @@ The `useDocumentInfo` hook provides lots of information about the document curre
|
|||||||
|---------------------------|--------------------------------------------------------------------------------------------------------------------| |
|
|---------------------------|--------------------------------------------------------------------------------------------------------------------| |
|
||||||
| **`collection`** | If the doc is a collection, its collection config will be returned |
|
| **`collection`** | If the doc is a collection, its collection config will be returned |
|
||||||
| **`global`** | If the doc is a global, its global config will be returned |
|
| **`global`** | If the doc is a global, its global config will be returned |
|
||||||
| **`type`** | The type of document being edited (collection or global) |
|
|
||||||
| **`id`** | If the doc is a collection, its ID will be returned |
|
| **`id`** | If the doc is a collection, its ID will be returned |
|
||||||
| **`preferencesKey`** | The `preferences` key to use when interacting with document-level user preferences |
|
| **`preferencesKey`** | The `preferences` key to use when interacting with document-level user preferences |
|
||||||
| **`versions`** | Versions of the current doc |
|
| **`versions`** | Versions of the current doc |
|
||||||
|
|||||||
@@ -11,38 +11,46 @@ Because Payload uses your existing Express server, you are free to add whatever
|
|||||||
This approach has a ton of benefits - it's great for isolation of concerns and limiting scope, but it also means that your additional routes won't have access to Payload's user authentication.
|
This approach has a ton of benefits - it's great for isolation of concerns and limiting scope, but it also means that your additional routes won't have access to Payload's user authentication.
|
||||||
|
|
||||||
<Banner type="success">
|
<Banner type="success">
|
||||||
You can make full use of Payload's built-in authentication within your own custom Express endpoints by adding Payload's authentication middleware.
|
You can make full use of Payload's built-in authentication within your own
|
||||||
|
custom Express endpoints by adding Payload's authentication middleware.
|
||||||
|
</Banner>
|
||||||
|
|
||||||
|
<Banner type="warning">
|
||||||
|
Payload must be initialized before the `payload.authenticate` middleware can
|
||||||
|
be used. This is done by calling `payload.init()` prior to adding the
|
||||||
|
middleware.
|
||||||
</Banner>
|
</Banner>
|
||||||
|
|
||||||
Example in `server.js`:
|
Example in `server.js`:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import express from 'express';
|
import express from "express";
|
||||||
import payload from 'payload';
|
import payload from "payload";
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
payload.init({
|
payload.init({
|
||||||
secret: 'PAYLOAD_SECRET_KEY',
|
secret: "PAYLOAD_SECRET_KEY",
|
||||||
mongoURL: 'mongodb://localhost/payload',
|
mongoURL: "mongodb://localhost/payload",
|
||||||
express: app,
|
express: app,
|
||||||
});
|
});
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Note: Payload must be initialized before the `payload.authenticate` middleware can be used
|
||||||
router.use(payload.authenticate); // highlight-line
|
router.use(payload.authenticate); // highlight-line
|
||||||
|
|
||||||
router.get('/', (req, res) => {
|
router.get("/", (req, res) => {
|
||||||
if (req.user) {
|
if (req.user) {
|
||||||
return res.send(`Authenticated successfully as ${req.user.email}.`);
|
return res.send(`Authenticated successfully as ${req.user.email}.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.send('Not authenticated');
|
return res.send("Not authenticated");
|
||||||
});
|
});
|
||||||
|
|
||||||
app.use('/some-route-here', router);
|
app.use("/some-route-here", router);
|
||||||
|
|
||||||
app.listen(3000, async () => {
|
app.listen(3000, async () => {
|
||||||
payload.logger.info(`listening on ${3000}...`);
|
payload.logger.info(`listening on ${3000}...`);
|
||||||
});
|
});
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ It's often best practice to write your Collections in separate files and then im
|
|||||||
## Options
|
## Options
|
||||||
|
|
||||||
| Option | Description |
|
| Option | Description |
|
||||||
|------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
|--------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
| **`slug`** * | Unique, URL-friendly string that will act as an identifier for this Collection. |
|
| **`slug`** * | Unique, URL-friendly string that will act as an identifier for this Collection. |
|
||||||
| **`fields`** * | Array of field types that will determine the structure and functionality of the data stored within this Collection. [Click here](/docs/fields/overview) for a full list of field types as well as how to configure them. |
|
| **`fields`** * | Array of field types that will determine the structure and functionality of the data stored within this Collection. [Click here](/docs/fields/overview) for a full list of field types as well as how to configure them. |
|
||||||
| **`labels`** | Singular and plural labels for use in identifying this Collection throughout Payload. Auto-generated from slug if not defined. |
|
| **`labels`** | Singular and plural labels for use in identifying this Collection throughout Payload. Auto-generated from slug if not defined. |
|
||||||
@@ -27,6 +27,8 @@ It's often best practice to write your Collections in separate files and then im
|
|||||||
| **`endpoints`** | Add custom routes to the REST API. [More](/docs/rest-api/overview#custom-endpoints) |
|
| **`endpoints`** | Add custom routes to the REST API. [More](/docs/rest-api/overview#custom-endpoints) |
|
||||||
| **`graphQL`** | An object with `singularName` and `pluralName` strings used in schema generation. Auto-generated from slug if not defined. |
|
| **`graphQL`** | An object with `singularName` and `pluralName` strings used in schema generation. Auto-generated from slug if not defined. |
|
||||||
| **`typescript`** | An object with property `interface` as the text used in schema generation. Auto-generated from slug if not defined. |
|
| **`typescript`** | An object with property `interface` as the text used in schema generation. Auto-generated from slug if not defined. |
|
||||||
|
| **`defaultSort`** | Pass a top-level field to sort by default in the collection List view. Prefix the name of the field with a minus symbol ("-") to sort in descending order. |
|
||||||
|
| **`pagination`** | Set pagination-specific options for this collection. [More](#pagination) |
|
||||||
|
|
||||||
*\* An asterisk denotes that a property is required.*
|
*\* An asterisk denotes that a property is required.*
|
||||||
|
|
||||||
@@ -111,6 +113,15 @@ const Posts: CollectionConfig = {
|
|||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Pagination
|
||||||
|
|
||||||
|
Here are a few options that you can specify options for pagination on a collection-by-collection basis:
|
||||||
|
|
||||||
|
| Option | Description |
|
||||||
|
| --------------------------- | -------------|
|
||||||
|
| `defaultLimit` | Integer that specifies the default per-page limit that should be used. Defaults to 10. |
|
||||||
|
| `limits` | Provide an array of integers to use as per-page options for admins to choose from in the List view. |
|
||||||
|
|
||||||
### Access control
|
### Access control
|
||||||
|
|
||||||
You can specify extremely granular access control (what users can do with documents in a collection) on a collection by collection basis. To learn more, go to the [Access Control](/docs/access-control/overview) docs.
|
You can specify extremely granular access control (what users can do with documents in a collection) on a collection by collection basis. To learn more, go to the [Access Control](/docs/access-control/overview) docs.
|
||||||
|
|||||||
@@ -72,6 +72,15 @@ All field types with a `name` property support the `localized` property—even t
|
|||||||
Enabling localization for field types that support nested fields will automatically create localized "sets" of all fields contained within the field. For example, if you have a page layout using a blocks field type, you have the choice of either localizing the full layout, by enabling localization on the top-level blocks field, or only certain fields within the layout.
|
Enabling localization for field types that support nested fields will automatically create localized "sets" of all fields contained within the field. For example, if you have a page layout using a blocks field type, you have the choice of either localizing the full layout, by enabling localization on the top-level blocks field, or only certain fields within the layout.
|
||||||
</Banner>
|
</Banner>
|
||||||
|
|
||||||
|
<Banner type="warning">
|
||||||
|
<strong>Important:</strong>
|
||||||
|
<br />
|
||||||
|
When converting an existing field to or from `localized: true` the data
|
||||||
|
structure in the document will change for this field and so existing data for
|
||||||
|
this field will be lost. Before changing the localization setting on fields
|
||||||
|
with existing data, you may need to consider a field migration strategy.
|
||||||
|
</Banner>
|
||||||
|
|
||||||
### Retrieving localized docs
|
### Retrieving localized docs
|
||||||
|
|
||||||
When retrieving documents, you can specify which locale you'd like to receive as well as which fallback locale should be used.
|
When retrieving documents, you can specify which locale you'd like to receive as well as which fallback locale should be used.
|
||||||
|
|||||||
@@ -104,6 +104,8 @@ require('dotenv').config()
|
|||||||
// the rest of your `server.js` file goes here
|
// the rest of your `server.js` file goes here
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Note that if you rely on any environment variables in your config itself, you should also call `dotenv()` at the top of your config itself as well. There's no harm in calling it in both your server and your config itself!
|
||||||
|
|
||||||
**Here is an example project structure w/ `dotenv` and an `.env` file:**
|
**Here is an example project structure w/ `dotenv` and an `.env` file:**
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -36,45 +36,72 @@ _\* An asterisk denotes that a property is required._
|
|||||||
|
|
||||||
In addition to the default [field admin config](/docs/fields/overview#admin-config), you can customize the following fields that will adjust how the component displays in the admin panel via the `date` property.
|
In addition to the default [field admin config](/docs/fields/overview#admin-config), you can customize the following fields that will adjust how the component displays in the admin panel via the `date` property.
|
||||||
|
|
||||||
| Option | Description |
|
| Property | Description |
|
||||||
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------------------ | ------------------------------------------------------------------------------------------- |
|
||||||
| **`pickerAppearance`** | Determines the appearance of the datepicker: `dayAndTime` `timeOnly` `dayOnly` `monthOnly`. Defaults to a calendar day picker - see `displayFormat`. |
|
|
||||||
| **`displayFormat`** | Determines how the date is presented. dayAndTime default to `MMM d, yyy h:mm a` timeOnly defaults to `h:mm a` dayOnly defaults to `dd` and monthOnly defaults to `MMMM`. Defaults to `MMM d, yyy` when `pickerAppearance` is not set. |
|
|
||||||
| **`placeholder`** | Placeholder text for the field. |
|
| **`placeholder`** | Placeholder text for the field. |
|
||||||
| **`monthsToShow`** | Number of months to display max is 2. Defaults to 1. |
|
| **`date`** | Pass options to customize date field appearance. |
|
||||||
| **`minDate`** | Passed directly to [react-datepicker](https://github.com/Hacker0x01/react-datepicker/blob/master/docs/datepicker.md). |
|
| **`date.displayFormat`** | Format date to be shown in field **cell**. |
|
||||||
| **`maxDate`** | Passed directly to [react-datepicker](https://github.com/Hacker0x01/react-datepicker/blob/master/docs/datepicker.md). |
|
| **`date.pickerAppearance`** \* | Determines the appearance of the datepicker: `dayAndTime` `timeOnly` `dayOnly` `monthOnly`. |
|
||||||
| **`minTime`** | Passed directly to [react-datepicker](https://github.com/Hacker0x01/react-datepicker/blob/master/docs/datepicker.md). |
|
| **`date.monthsToShow`** \* | Number of months to display max is 2. Defaults to 1. |
|
||||||
| **`maxTime`** | Passed directly to [react-datepicker](https://github.com/Hacker0x01/react-datepicker/blob/master/docs/datepicker.md). |
|
| **`date.minDate`** \* | Min date value to allow. |
|
||||||
| **`timeIntervals`** | Passed directly to [react-datepicker](https://github.com/Hacker0x01/react-datepicker/blob/master/docs/datepicker.md). Defaults to 30 minutes. |
|
| **`date.maxDate`** \* | Max date value to allow. |
|
||||||
| **`timeFormat`** | Passed directly to [react-datepicker](https://github.com/Hacker0x01/react-datepicker/blob/master/docs/datepicker.md). Defaults to `'h:mm aa'`. |
|
| **`date.minTime`** \* | Min time value to allow. |
|
||||||
|
| **`date.maxTime`** \* | Max date value to allow. |
|
||||||
|
| **`date.timeIntervals`** \* | Time intervals to display. Defaults to 30 minutes. |
|
||||||
|
| **`date.timeFormat`** \* | Determines time format. Defaults to `'h:mm aa'`. |
|
||||||
|
|
||||||
_\* An asterisk denotes that a property is required._
|
_\* This property is passed directly to [react-datepicker](https://github.com/Hacker0x01/react-datepicker/blob/master/docs/datepicker.md). ._
|
||||||
|
|
||||||
Common use cases for customizing the `date` property are to restrict your field to only show time or day input—but lots more can be done.
|
#### Display Format and Picker Appearance
|
||||||
|
|
||||||
|
These properties only affect how the date is displayed in the UI. The full date is always stored in the format `YYYY-MM-DDTHH:mm:ss.SSSZ` (e.g. `1999-01-01T8:00:00.000+05:00`).
|
||||||
|
|
||||||
|
`displayFormat` determines how the date is presented in the field **cell**, you can pass any valid (unicode date format)[https://date-fns.org/v2.29.3/docs/format].
|
||||||
|
|
||||||
|
`pickerAppearance` sets the appearance of the **react datepicker**, the options available are `dayAndTime`, `dayOnly`, `timeOnly`, and `monthOnly`. By default, the datepicker will display `dayOnly`.
|
||||||
|
|
||||||
|
When only `pickerAppearance` is set, an equivalent format will be rendered in the date field cell. To overwrite this format, set `displayFormat`.
|
||||||
|
|
||||||
### Example
|
### Example
|
||||||
|
|
||||||
`collections/ExampleCollection.ts`
|
`collections/ExampleCollection.ts`
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { CollectionConfig } from 'payload/types';
|
import { CollectionConfig } from "payload/types";
|
||||||
|
|
||||||
const ExampleCollection: CollectionConfig = {
|
const ExampleCollection: CollectionConfig = {
|
||||||
slug: 'example-collection',
|
slug: "example-collection",
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
name: 'time', // required
|
name: "dateOnly",
|
||||||
type: 'date', // required
|
type: "date",
|
||||||
label: 'Event Start Time',
|
|
||||||
defaultValue: '1988-11-05T8:00:00.000+05:00',
|
|
||||||
admin: {
|
admin: {
|
||||||
date: {
|
date: {
|
||||||
// All config options above should be placed here
|
pickerAppearance: "dayOnly",
|
||||||
pickerAppearance: 'timeOnly',
|
displayFormat: "d MMM yyy",
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
]
|
{
|
||||||
|
name: "timeOnly",
|
||||||
|
type: "date",
|
||||||
|
admin: {
|
||||||
|
date: {
|
||||||
|
pickerAppearance: "timeOnly",
|
||||||
|
displayFormat: "h:mm:ss a",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "monthOnly",
|
||||||
|
type: "date",
|
||||||
|
admin: {
|
||||||
|
date: {
|
||||||
|
pickerAppearance: "monthOnly",
|
||||||
|
displayFormat: "MMMM yyyy",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ Example:
|
|||||||
In addition to each field's base configuration, you can define specific traits and properties for fields that only have effect on how they are rendered in the Admin panel. The following properties are available for all fields within the `admin` property:
|
In addition to each field's base configuration, you can define specific traits and properties for fields that only have effect on how they are rendered in the Admin panel. The following properties are available for all fields within the `admin` property:
|
||||||
|
|
||||||
| Option | Description |
|
| Option | Description |
|
||||||
| ------------- | -------------|
|
|-------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
| `condition` | You can programmatically show / hide fields based on what other fields are doing. [Click here](#conditional-logic) for more info. |
|
| `condition` | You can programmatically show / hide fields based on what other fields are doing. [Click here](#conditional-logic) for more info. |
|
||||||
| `components` | All field components can be completely and easily swapped out for custom components that you define. [Click here](#custom-components) for more info. |
|
| `components` | All field components can be completely and easily swapped out for custom components that you define. [Click here](#custom-components) for more info. |
|
||||||
| `description` | Helper text to display with the field to provide more information for the editor user. [Click here](#description) for more info. |
|
| `description` | Helper text to display with the field to provide more information for the editor user. [Click here](#description) for more info. |
|
||||||
@@ -166,6 +166,7 @@ In addition to each field's base configuration, you can define specific traits a
|
|||||||
| `className` | Attach a CSS class name to the root DOM element of a field. |
|
| `className` | Attach a CSS class name to the root DOM element of a field. |
|
||||||
| `readOnly` | Setting a field to `readOnly` has no effect on the API whatsoever but disables the admin component's editability to prevent editors from modifying the field's value. |
|
| `readOnly` | Setting a field to `readOnly` has no effect on the API whatsoever but disables the admin component's editability to prevent editors from modifying the field's value. |
|
||||||
| `disabled` | If a field is `disabled`, it is completely omitted from the Admin panel. |
|
| `disabled` | If a field is `disabled`, it is completely omitted from the Admin panel. |
|
||||||
|
| `disableBulkEdit` | Set `disableBulkEdit` to `true` to prevent fields from appearing in the select options when making edits for multiple documents. |
|
||||||
| `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. |
|
| `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
|
### Custom components
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ keywords: textarea, fields, config, configuration, documentation, Content Manage
|
|||||||
| **`hooks`** | Provide field-based hooks to control logic for this field. [More](/docs/fields/overview#field-level-hooks) |
|
| **`hooks`** | Provide field-based hooks to control logic for this field. [More](/docs/fields/overview#field-level-hooks) |
|
||||||
| **`access`** | Provide field-based access control to denote what users can see and do with this field's data. [More](/docs/fields/overview#field-level-access-control) |
|
| **`access`** | Provide field-based access control to denote what users can see and do with this field's data. [More](/docs/fields/overview#field-level-access-control) |
|
||||||
| **`hidden`** | Restrict this field's visibility from all APIs entirely. Will still be saved to the database, but will not appear in any API or the Admin panel. |
|
| **`hidden`** | Restrict this field's visibility from all APIs entirely. Will still be saved to the database, but will not appear in any API or the Admin panel. |
|
||||||
| **`defaultValue`** | Provide data to be used for this field's default value. [More](/docs/fields/overview#default-values) [More](/docs/fields/overview#default-values) |
|
| **`defaultValue`** | Provide data to be used for this field's default value. [More](/docs/fields/overview#default-values)|
|
||||||
| **`localized`** | Enable localization for this field. Requires [localization to be enabled](/docs/configuration/localization) in the Base config. |
|
| **`localized`** | Enable localization for this field. Requires [localization to be enabled](/docs/configuration/localization) in the Base config. |
|
||||||
| **`required`** | Require this field to have a value. |
|
| **`required`** | Require this field to have a value. |
|
||||||
| **`admin`** | Admin-specific configuration. See below for [more detail](#admin-config). |
|
| **`admin`** | Admin-specific configuration. See below for [more detail](#admin-config). |
|
||||||
|
|||||||
@@ -79,7 +79,13 @@ const beforeOperationHook: CollectionBeforeOperationHook = async ({
|
|||||||
|
|
||||||
### beforeValidate
|
### beforeValidate
|
||||||
|
|
||||||
Runs before the `create` and `update` operations. This hook allows you to add or format data before the incoming data is validated.
|
Runs before the `create` and `update` operations. This hook allows you to add or format data before the incoming data is validated server-side.
|
||||||
|
|
||||||
|
Please do note that this does not run before the client-side validation. If you added a `validate` function, this would be the lifecycle:
|
||||||
|
|
||||||
|
1. `validate` runs on the client
|
||||||
|
2. if successful, `beforeValidate` runs on the server
|
||||||
|
3. `validate` runs on the server
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { CollectionBeforeOperationHook } from 'payload/types';
|
import { CollectionBeforeOperationHook } from 'payload/types';
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ Field Hooks receive one `args` argument that contains the following properties:
|
|||||||
| **`previousSiblingDoc`** | The sibling data from the previous document in `afterChange` hook. |
|
| **`previousSiblingDoc`** | The sibling data from the previous document in `afterChange` hook. |
|
||||||
| **`req`** | The Express `request` object. It is mocked for Local API operations. |
|
| **`req`** | The Express `request` object. It is mocked for Local API operations. |
|
||||||
| **`value`** | The value of the field. |
|
| **`value`** | The value of the field. |
|
||||||
|
| **`previousValue`** | The previous value of the field, before changes were applied, only in `afterChange` hooks. |
|
||||||
|
|
||||||
#### Return value
|
#### Return value
|
||||||
|
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ const result = await payload.findByID({
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Update
|
#### Update by ID
|
||||||
|
|
||||||
```js
|
```js
|
||||||
// Result will be the updated Post document.
|
// Result will be the updated Post document.
|
||||||
@@ -193,6 +193,44 @@ const result = await payload.update({
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Update Many
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Result will be an object with:
|
||||||
|
// {
|
||||||
|
// docs: [], // each document that was updated
|
||||||
|
// errors: [], // each error also includes the id of the document
|
||||||
|
// }
|
||||||
|
const result = await payload.update({
|
||||||
|
collection: "posts", // required
|
||||||
|
where: {
|
||||||
|
// required
|
||||||
|
fieldName: { equals: 'value' },
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
// required
|
||||||
|
title: "sure",
|
||||||
|
description: "maybe",
|
||||||
|
},
|
||||||
|
depth: 0,
|
||||||
|
locale: "en",
|
||||||
|
fallbackLocale: false,
|
||||||
|
user: dummyUser,
|
||||||
|
overrideAccess: false,
|
||||||
|
showHiddenFields: true,
|
||||||
|
|
||||||
|
// If your collection supports uploads, you can upload
|
||||||
|
// a file directly through the Local API by providing
|
||||||
|
// its full, absolute file path.
|
||||||
|
filePath: path.resolve(__dirname, "./path-to-image.jpg"),
|
||||||
|
|
||||||
|
// If you are uploading a file and would like to replace
|
||||||
|
// the existing file instead of generating a new filename,
|
||||||
|
// you can set the following property to `true`
|
||||||
|
overwriteExistingFiles: true,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
#### Delete
|
#### Delete
|
||||||
|
|
||||||
```js
|
```js
|
||||||
@@ -209,6 +247,29 @@ const result = await payload.delete({
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Delete Many
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Result will be an object with:
|
||||||
|
// {
|
||||||
|
// docs: [], // each document that is now deleted
|
||||||
|
// errors: [], // any errors that occurred, including the id of the errored on document
|
||||||
|
// }
|
||||||
|
const result = await payload.delete({
|
||||||
|
collection: "posts", // required
|
||||||
|
where: {
|
||||||
|
// required
|
||||||
|
fieldName: { equals: 'value' },
|
||||||
|
},
|
||||||
|
depth: 0,
|
||||||
|
locale: "en",
|
||||||
|
fallbackLocale: false,
|
||||||
|
user: dummyUser,
|
||||||
|
overrideAccess: false,
|
||||||
|
showHiddenFields: true,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
## Auth Operations
|
## Auth Operations
|
||||||
|
|
||||||
If a collection has [`Authentication`](/docs/authentication/overview) enabled, additional Local API operations will be available:
|
If a collection has [`Authentication`](/docs/authentication/overview) enabled, additional Local API operations will be available:
|
||||||
|
|||||||
@@ -27,12 +27,14 @@ Note: Collection slugs must be formatted in kebab-case
|
|||||||
**All CRUD operations are exposed as follows:**
|
**All CRUD operations are exposed as follows:**
|
||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
| -------- | --------------------------- | -------------------------------------- |
|
|----------|-------------------------------|--------------------------------------------------|
|
||||||
| `GET` | `/api/{collection-slug}` | Find paginated documents |
|
| `GET` | `/api/{collection-slug}` | Find paginated documents |
|
||||||
| `GET` | `/api/{collection-slug}/:id` | Find a specific document by ID |
|
| `GET` | `/api/{collection-slug}/:id` | Find a specific document by ID |
|
||||||
| `POST` | `/api/{collection-slug}` | Create a new document |
|
| `POST` | `/api/{collection-slug}` | Create a new document |
|
||||||
|
| `PATCH` | `/api/{collection-slug}` | Update all documents matching the `where` query |
|
||||||
| `PATCH` | `/api/{collection-slug}/:id` | Update a document by ID |
|
| `PATCH` | `/api/{collection-slug}/:id` | Update a document by ID |
|
||||||
| `DELETE` | `/api/{collection-slug}/:id` | Delete an existing document by ID |
|
| `DELETE` | `/api/{collection-slug}` | Delete all documents matching the `where` query |
|
||||||
|
| `DELETE` | `/api/{collection-sldug}/:id` | Delete an existing document by ID |
|
||||||
|
|
||||||
##### Additional `find` query parameters
|
##### Additional `find` query parameters
|
||||||
|
|
||||||
|
|||||||
11
package.json
11
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "payload",
|
"name": "payload",
|
||||||
"version": "1.6.21",
|
"version": "1.6.32",
|
||||||
"description": "Node, React and MongoDB Headless CMS and Application Framework",
|
"description": "Node, React and MongoDB Headless CMS and Application Framework",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -186,10 +186,10 @@
|
|||||||
"url-loader": "^4.1.1",
|
"url-loader": "^4.1.1",
|
||||||
"use-context-selector": "^1.4.1",
|
"use-context-selector": "^1.4.1",
|
||||||
"uuid": "^8.3.2",
|
"uuid": "^8.3.2",
|
||||||
"webpack": "^5.75.0",
|
"webpack": "^5.76.0",
|
||||||
"webpack-bundle-analyzer": "^4.7.0",
|
"webpack-bundle-analyzer": "^4.8.0",
|
||||||
"webpack-cli": "^4.10.0",
|
"webpack-cli": "^4.10.0",
|
||||||
"webpack-dev-middleware": "^4.3.0",
|
"webpack-dev-middleware": "6.0.1",
|
||||||
"webpack-hot-middleware": "^2.25.3"
|
"webpack-hot-middleware": "^2.25.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -252,9 +252,8 @@
|
|||||||
"@types/shelljs": "^0.8.11",
|
"@types/shelljs": "^0.8.11",
|
||||||
"@types/testing-library__jest-dom": "^5.14.5",
|
"@types/testing-library__jest-dom": "^5.14.5",
|
||||||
"@types/uuid": "^8.3.4",
|
"@types/uuid": "^8.3.4",
|
||||||
"@types/webpack": "4.41.33",
|
|
||||||
"@types/webpack-bundle-analyzer": "^4.6.0",
|
"@types/webpack-bundle-analyzer": "^4.6.0",
|
||||||
"@types/webpack-dev-middleware": "4.3.0",
|
"@types/webpack-dev-middleware": "^5.3.0",
|
||||||
"@types/webpack-env": "^1.18.0",
|
"@types/webpack-env": "^1.18.0",
|
||||||
"@types/webpack-hot-middleware": "2.25.6",
|
"@types/webpack-hot-middleware": "2.25.6",
|
||||||
"@typescript-eslint/eslint-plugin": "^4.33.0",
|
"@typescript-eslint/eslint-plugin": "^4.33.0",
|
||||||
|
|||||||
BIN
src/admin/assets/images/github/e2e-debug.png
Normal file
BIN
src/admin/assets/images/github/e2e-debug.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
BIN
src/admin/assets/images/github/int-debug.png
Normal file
BIN
src/admin/assets/images/github/int-debug.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
@@ -14,7 +14,6 @@ import Version from './views/Version';
|
|||||||
import { DocumentInfoProvider } from './utilities/DocumentInfo';
|
import { DocumentInfoProvider } from './utilities/DocumentInfo';
|
||||||
import { useLocale } from './utilities/Locale';
|
import { useLocale } from './utilities/Locale';
|
||||||
import { LoadingOverlayToggle } from './elements/Loading';
|
import { LoadingOverlayToggle } from './elements/Loading';
|
||||||
import { TableColumnsProvider } from './elements/TableColumns';
|
|
||||||
|
|
||||||
const Dashboard = lazy(() => import('./views/Dashboard'));
|
const Dashboard = lazy(() => import('./views/Dashboard'));
|
||||||
const ForgotPassword = lazy(() => import('./views/ForgotPassword'));
|
const ForgotPassword = lazy(() => import('./views/ForgotPassword'));
|
||||||
@@ -189,12 +188,10 @@ const Routes = () => {
|
|||||||
render={(routeProps) => {
|
render={(routeProps) => {
|
||||||
if (permissions?.collections?.[collection.slug]?.read?.permission) {
|
if (permissions?.collections?.[collection.slug]?.read?.permission) {
|
||||||
return (
|
return (
|
||||||
<TableColumnsProvider collection={collection}>
|
|
||||||
<List
|
<List
|
||||||
{...routeProps}
|
{...routeProps}
|
||||||
collection={collection}
|
collection={collection}
|
||||||
/>
|
/>
|
||||||
</TableColumnsProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,10 +273,15 @@ const Routes = () => {
|
|||||||
render={(routeProps) => {
|
render={(routeProps) => {
|
||||||
if (permissions?.collections?.[collection.slug]?.readVersions?.permission) {
|
if (permissions?.collections?.[collection.slug]?.readVersions?.permission) {
|
||||||
return (
|
return (
|
||||||
|
<DocumentInfoProvider
|
||||||
|
collection={collection}
|
||||||
|
id={routeProps.match.params.id}
|
||||||
|
>
|
||||||
<Version
|
<Version
|
||||||
{...routeProps}
|
{...routeProps}
|
||||||
collection={collection}
|
collection={collection}
|
||||||
/>
|
/>
|
||||||
|
</DocumentInfoProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { ShimmerEffect } from '../ShimmerEffect';
|
|||||||
const baseClass = 'code-editor';
|
const baseClass = 'code-editor';
|
||||||
|
|
||||||
const CodeEditor: React.FC<Props> = (props) => {
|
const CodeEditor: React.FC<Props> = (props) => {
|
||||||
const { readOnly, className, options, ...rest } = props;
|
const { readOnly, className, options, height, ...rest } = props;
|
||||||
|
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ const CodeEditor: React.FC<Props> = (props) => {
|
|||||||
<Editor
|
<Editor
|
||||||
className={classes}
|
className={classes}
|
||||||
theme={theme === 'dark' ? 'vs-dark' : 'vs'}
|
theme={theme === 'dark' ? 'vs-dark' : 'vs'}
|
||||||
loading={<ShimmerEffect height="35vh" />}
|
loading={<ShimmerEffect height={height} />}
|
||||||
options={
|
options={
|
||||||
{
|
{
|
||||||
detectIndentation: true,
|
detectIndentation: true,
|
||||||
@@ -37,6 +37,7 @@ const CodeEditor: React.FC<Props> = (props) => {
|
|||||||
...options,
|
...options,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
height={height}
|
||||||
{...rest}
|
{...rest}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useId } from 'react';
|
import React, { useId, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import Pill from '../Pill';
|
import Pill from '../Pill';
|
||||||
import Plus from '../../icons/Plus';
|
import Plus from '../../icons/Plus';
|
||||||
@@ -49,6 +49,8 @@ const ColumnSelector: React.FC<Props> = (props) => {
|
|||||||
name,
|
name,
|
||||||
} = col;
|
} = col;
|
||||||
|
|
||||||
|
if (col.accessor === '_select') return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pill
|
<Pill
|
||||||
draggable
|
draggable
|
||||||
|
|||||||
@@ -23,9 +23,6 @@ const DeleteDocument: React.FC<Props> = (props) => {
|
|||||||
buttonId,
|
buttonId,
|
||||||
collection,
|
collection,
|
||||||
collection: {
|
collection: {
|
||||||
admin: {
|
|
||||||
useAsTitle,
|
|
||||||
},
|
|
||||||
slug,
|
slug,
|
||||||
labels: {
|
labels: {
|
||||||
singular,
|
singular,
|
||||||
@@ -39,7 +36,7 @@ const DeleteDocument: React.FC<Props> = (props) => {
|
|||||||
const { toggleModal } = useModal();
|
const { toggleModal } = useModal();
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const { t, i18n } = useTranslation('general');
|
const { t, i18n } = useTranslation('general');
|
||||||
const title = useTitle(useAsTitle, collection.slug) || id;
|
const title = useTitle(collection);
|
||||||
const titleToRender = titleFromProps || title;
|
const titleToRender = titleFromProps || title;
|
||||||
|
|
||||||
const modalSlug = `delete-${id}`;
|
const modalSlug = `delete-${id}`;
|
||||||
|
|||||||
17
src/admin/components/elements/DeleteMany/index.scss
Normal file
17
src/admin/components/elements/DeleteMany/index.scss
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
@import '../../../scss/styles.scss';
|
||||||
|
|
||||||
|
.delete-documents {
|
||||||
|
@include blur-bg;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
&__template {
|
||||||
|
z-index: 1;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
margin-right: $baseline;
|
||||||
|
}
|
||||||
|
}
|
||||||
120
src/admin/components/elements/DeleteMany/index.tsx
Normal file
120
src/admin/components/elements/DeleteMany/index.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import React, { useState, useCallback } from 'react';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
import { Modal, useModal } from '@faceless-ui/modal';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useConfig } from '../../utilities/Config';
|
||||||
|
import Button from '../Button';
|
||||||
|
import MinimalTemplate from '../../templates/Minimal';
|
||||||
|
import { requests } from '../../../api';
|
||||||
|
import { Props } from './types';
|
||||||
|
import { SelectAllStatus, useSelection } from '../../views/collections/List/SelectionProvider';
|
||||||
|
import { getTranslation } from '../../../../utilities/getTranslation';
|
||||||
|
import Pill from '../Pill';
|
||||||
|
import { useAuth } from '../../utilities/Auth';
|
||||||
|
|
||||||
|
import './index.scss';
|
||||||
|
|
||||||
|
const baseClass = 'delete-documents';
|
||||||
|
|
||||||
|
const DeleteMany: React.FC<Props> = (props) => {
|
||||||
|
const {
|
||||||
|
resetParams,
|
||||||
|
collection: {
|
||||||
|
slug,
|
||||||
|
labels: {
|
||||||
|
plural,
|
||||||
|
},
|
||||||
|
} = {},
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const { permissions } = useAuth();
|
||||||
|
const { serverURL, routes: { api } } = useConfig();
|
||||||
|
const { toggleModal } = useModal();
|
||||||
|
const { selectAll, count, getQueryParams, toggleAll } = useSelection();
|
||||||
|
const { t, i18n } = useTranslation('general');
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
|
const collectionPermissions = permissions?.collections?.[slug];
|
||||||
|
const hasDeletePermission = collectionPermissions?.delete?.permission;
|
||||||
|
|
||||||
|
const modalSlug = `delete-${slug}`;
|
||||||
|
|
||||||
|
const addDefaultError = useCallback(() => {
|
||||||
|
toast.error(t('error:unknown'));
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(() => {
|
||||||
|
setDeleting(true);
|
||||||
|
requests.delete(`${serverURL}${api}/${slug}${getQueryParams()}`, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept-Language': i18n.language,
|
||||||
|
},
|
||||||
|
}).then(async (res) => {
|
||||||
|
try {
|
||||||
|
const json = await res.json();
|
||||||
|
toggleModal(modalSlug);
|
||||||
|
if (res.status < 400) {
|
||||||
|
toast.success(json.message || t('deletedSuccessfully'), { autoClose: 3000 });
|
||||||
|
toggleAll();
|
||||||
|
resetParams({ page: selectAll ? 1 : undefined });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (json.errors) {
|
||||||
|
toast.error(json.message);
|
||||||
|
} else {
|
||||||
|
addDefaultError();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch (e) {
|
||||||
|
return addDefaultError();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [addDefaultError, api, getQueryParams, i18n.language, modalSlug, resetParams, selectAll, serverURL, slug, t, toggleAll, toggleModal]);
|
||||||
|
|
||||||
|
if (selectAll === SelectAllStatus.None || !hasDeletePermission) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment>
|
||||||
|
<Pill
|
||||||
|
className={`${baseClass}__toggle`}
|
||||||
|
onClick={() => {
|
||||||
|
setDeleting(false);
|
||||||
|
toggleModal(modalSlug);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('delete')}
|
||||||
|
</Pill>
|
||||||
|
<Modal
|
||||||
|
slug={modalSlug}
|
||||||
|
className={baseClass}
|
||||||
|
>
|
||||||
|
<MinimalTemplate className={`${baseClass}__template`}>
|
||||||
|
<h1>{t('confirmDeletion')}</h1>
|
||||||
|
<p>
|
||||||
|
{t('aboutToDeleteCount', { label: getTranslation(plural, i18n), count })}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
id="confirm-cancel"
|
||||||
|
buttonStyle="secondary"
|
||||||
|
type="button"
|
||||||
|
onClick={deleting ? undefined : () => toggleModal(modalSlug)}
|
||||||
|
>
|
||||||
|
{t('cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={deleting ? undefined : handleDelete}
|
||||||
|
id="confirm-delete"
|
||||||
|
>
|
||||||
|
{deleting ? t('deleting') : t('confirm')}
|
||||||
|
</Button>
|
||||||
|
</MinimalTemplate>
|
||||||
|
</Modal>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DeleteMany;
|
||||||
8
src/admin/components/elements/DeleteMany/types.ts
Normal file
8
src/admin/components/elements/DeleteMany/types.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { SanitizedCollectionConfig } from '../../../../collections/config/types';
|
||||||
|
import type { Props as ListProps } from '../../views/collections/List/types';
|
||||||
|
|
||||||
|
export type Props = {
|
||||||
|
collection: SanitizedCollectionConfig,
|
||||||
|
title?: string,
|
||||||
|
resetParams: ListProps['resetParams'],
|
||||||
|
}
|
||||||
190
src/admin/components/elements/EditMany/index.scss
Normal file
190
src/admin/components/elements/EditMany/index.scss
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
@import '../../../scss/styles.scss';
|
||||||
|
|
||||||
|
.edit-many {
|
||||||
|
|
||||||
|
&__toggle {
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: base(1);
|
||||||
|
display: inline-flex;
|
||||||
|
background: var(--theme-elevation-150);
|
||||||
|
color: var(--theme-elevation-800);
|
||||||
|
border-radius: $style-radius-s;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
border: 0;
|
||||||
|
padding: 0 base(.25);
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:active,
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--theme-elevation-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background: var(--theme-elevation-100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__form {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__main {
|
||||||
|
width: calc(100% - #{base(15)});
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
margin-top: base(2.5);
|
||||||
|
margin-bottom: base(1);
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
margin: 0;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__close {
|
||||||
|
border: 0;
|
||||||
|
background-color: transparent;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
width: base(1);
|
||||||
|
height: base(1);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: base(2.75);
|
||||||
|
height: base(2.75);
|
||||||
|
position: relative;
|
||||||
|
left: base(-.825);
|
||||||
|
top: base(-.825);
|
||||||
|
|
||||||
|
.stroke {
|
||||||
|
stroke-width: 2px;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__edit {
|
||||||
|
padding-top: base(1);
|
||||||
|
padding-bottom: base(2);
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__sidebar-wrap {
|
||||||
|
position: fixed;
|
||||||
|
width: base(15);
|
||||||
|
height: 100%;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
overflow: visible;
|
||||||
|
border-left: 1px solid var(--theme-elevation-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__sidebar {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__sidebar-sticky-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__collection-actions,
|
||||||
|
&__meta,
|
||||||
|
&__sidebar-fields {
|
||||||
|
padding-left: base(1.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__document-actions {
|
||||||
|
padding-right: $baseline;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: var(--z-nav);
|
||||||
|
|
||||||
|
> * {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include mid-break {
|
||||||
|
@include blur-bg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__document-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: base(1);
|
||||||
|
gap: base(.5);
|
||||||
|
|
||||||
|
.form-submit {
|
||||||
|
width: calc(50% - #{base(1)});
|
||||||
|
|
||||||
|
@include mid-break {
|
||||||
|
width: auto;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
width: 100%;
|
||||||
|
padding-left: base(.5);
|
||||||
|
padding-right: base(.5);
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@include mid-break {
|
||||||
|
&__main {
|
||||||
|
width: 100%;
|
||||||
|
min-height: initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__sidebar-wrap {
|
||||||
|
position: static;
|
||||||
|
width: 100%;
|
||||||
|
height: initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__form {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__edit {
|
||||||
|
padding-top: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__document-actions {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: auto;
|
||||||
|
z-index: var(--z-nav);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__document-actions,
|
||||||
|
&__sidebar-fields {
|
||||||
|
padding-left: var(--gutter-h);
|
||||||
|
padding-right: var(--gutter-h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
204
src/admin/components/elements/EditMany/index.tsx
Normal file
204
src/admin/components/elements/EditMany/index.tsx
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
import React, { useCallback, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useModal } from '@faceless-ui/modal';
|
||||||
|
import { useConfig } from '../../utilities/Config';
|
||||||
|
import { Drawer, DrawerToggler } from '../Drawer';
|
||||||
|
import { Props } from './types';
|
||||||
|
import { SelectAllStatus, useSelection } from '../../views/collections/List/SelectionProvider';
|
||||||
|
import { getTranslation } from '../../../../utilities/getTranslation';
|
||||||
|
import { useAuth } from '../../utilities/Auth';
|
||||||
|
import { FieldSelect } from '../FieldSelect';
|
||||||
|
import FormSubmit from '../../forms/Submit';
|
||||||
|
import Form from '../../forms/Form';
|
||||||
|
import { useForm } from '../../forms/Form/context';
|
||||||
|
import RenderFields from '../../forms/RenderFields';
|
||||||
|
import { OperationContext } from '../../utilities/OperationProvider';
|
||||||
|
import fieldTypes from '../../forms/field-types';
|
||||||
|
import X from '../../icons/X';
|
||||||
|
|
||||||
|
import './index.scss';
|
||||||
|
|
||||||
|
const baseClass = 'edit-many';
|
||||||
|
|
||||||
|
const Submit: React.FC<{disabled: boolean, action: string}> = ({ action, disabled }) => {
|
||||||
|
const { submit } = useForm();
|
||||||
|
const { t } = useTranslation('general');
|
||||||
|
|
||||||
|
const save = useCallback(() => {
|
||||||
|
submit({
|
||||||
|
skipValidation: true,
|
||||||
|
method: 'PATCH',
|
||||||
|
action,
|
||||||
|
});
|
||||||
|
}, [action, submit]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormSubmit
|
||||||
|
className={`${baseClass}__save`}
|
||||||
|
onClick={save}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{t('save')}
|
||||||
|
</FormSubmit>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const Publish: React.FC<{disabled: boolean, action: string}> = ({ action, disabled }) => {
|
||||||
|
const { submit } = useForm();
|
||||||
|
const { t } = useTranslation('version');
|
||||||
|
|
||||||
|
const save = useCallback(() => {
|
||||||
|
submit({
|
||||||
|
skipValidation: true,
|
||||||
|
method: 'PATCH',
|
||||||
|
overrides: {
|
||||||
|
_status: 'published',
|
||||||
|
},
|
||||||
|
action,
|
||||||
|
});
|
||||||
|
}, [action, submit]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormSubmit
|
||||||
|
className={`${baseClass}__publish`}
|
||||||
|
onClick={save}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{t('publishChanges')}
|
||||||
|
</FormSubmit>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const SaveDraft: React.FC<{disabled: boolean, action: string}> = ({ action, disabled }) => {
|
||||||
|
const { submit } = useForm();
|
||||||
|
const { t } = useTranslation('version');
|
||||||
|
|
||||||
|
const save = useCallback(() => {
|
||||||
|
submit({
|
||||||
|
skipValidation: true,
|
||||||
|
method: 'PATCH',
|
||||||
|
overrides: {
|
||||||
|
_status: 'draft',
|
||||||
|
},
|
||||||
|
action,
|
||||||
|
});
|
||||||
|
}, [action, submit]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormSubmit
|
||||||
|
className={`${baseClass}__draft`}
|
||||||
|
onClick={save}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{t('saveDraft')}
|
||||||
|
</FormSubmit>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const EditMany: React.FC<Props> = (props) => {
|
||||||
|
const {
|
||||||
|
resetParams,
|
||||||
|
collection,
|
||||||
|
collection: {
|
||||||
|
slug,
|
||||||
|
labels: {
|
||||||
|
plural,
|
||||||
|
},
|
||||||
|
fields,
|
||||||
|
} = {},
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const { permissions } = useAuth();
|
||||||
|
const { closeModal } = useModal();
|
||||||
|
const { serverURL, routes: { api } } = useConfig();
|
||||||
|
const { selectAll, count, getQueryParams } = useSelection();
|
||||||
|
const { t, i18n } = useTranslation('general');
|
||||||
|
const [selected, setSelected] = useState([]);
|
||||||
|
|
||||||
|
const collectionPermissions = permissions?.collections?.[slug];
|
||||||
|
const hasUpdatePermission = collectionPermissions?.update?.permission;
|
||||||
|
|
||||||
|
const drawerSlug = `edit-${slug}`;
|
||||||
|
|
||||||
|
if (selectAll === SelectAllStatus.None || !hasUpdatePermission) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSuccess = () => {
|
||||||
|
resetParams({ page: selectAll === SelectAllStatus.AllAvailable ? 1 : undefined });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={baseClass}>
|
||||||
|
<DrawerToggler
|
||||||
|
slug={drawerSlug}
|
||||||
|
className={`${baseClass}__toggle`}
|
||||||
|
aria-label={t('edit')}
|
||||||
|
onClick={() => {
|
||||||
|
setSelected([]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('edit')}
|
||||||
|
</DrawerToggler>
|
||||||
|
<Drawer
|
||||||
|
slug={drawerSlug}
|
||||||
|
header={null}
|
||||||
|
>
|
||||||
|
<OperationContext.Provider value="update">
|
||||||
|
<Form
|
||||||
|
className={`${baseClass}__form`}
|
||||||
|
onSuccess={onSuccess}
|
||||||
|
>
|
||||||
|
<div className={`${baseClass}__main`}>
|
||||||
|
<div className={`${baseClass}__header`}>
|
||||||
|
<h2 className={`${baseClass}__header__title`}>
|
||||||
|
{t('editingLabel', { label: getTranslation(plural, i18n), count })}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
className={`${baseClass}__header__close`}
|
||||||
|
id={`close-drawer__${drawerSlug}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => closeModal(drawerSlug)}
|
||||||
|
aria-label={t('close')}
|
||||||
|
>
|
||||||
|
<X />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<FieldSelect
|
||||||
|
fields={fields}
|
||||||
|
setSelected={setSelected}
|
||||||
|
/>
|
||||||
|
<RenderFields
|
||||||
|
fieldTypes={fieldTypes}
|
||||||
|
fieldSchema={selected}
|
||||||
|
/>
|
||||||
|
<div className={`${baseClass}__sidebar-wrap`}>
|
||||||
|
<div className={`${baseClass}__sidebar`}>
|
||||||
|
<div className={`${baseClass}__sidebar-sticky-wrap`}>
|
||||||
|
<div className={`${baseClass}__document-actions`}>
|
||||||
|
<Submit
|
||||||
|
action={`${serverURL}${api}/${slug}${getQueryParams()}`}
|
||||||
|
disabled={selected.length === 0}
|
||||||
|
/>
|
||||||
|
{ collection.versions && (
|
||||||
|
<React.Fragment>
|
||||||
|
<Publish
|
||||||
|
action={`${serverURL}${api}/${slug}${getQueryParams()}`}
|
||||||
|
disabled={selected.length === 0}
|
||||||
|
/>
|
||||||
|
<SaveDraft
|
||||||
|
action={`${serverURL}${api}/${slug}${getQueryParams()}`}
|
||||||
|
disabled={selected.length === 0}
|
||||||
|
/>
|
||||||
|
</React.Fragment>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</OperationContext.Provider>
|
||||||
|
</Drawer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EditMany;
|
||||||
7
src/admin/components/elements/EditMany/types.ts
Normal file
7
src/admin/components/elements/EditMany/types.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { SanitizedCollectionConfig } from '../../../../collections/config/types';
|
||||||
|
import type { Props as ListProps } from '../../views/collections/List/types';
|
||||||
|
|
||||||
|
export type Props = {
|
||||||
|
collection: SanitizedCollectionConfig,
|
||||||
|
resetParams: ListProps['resetParams'],
|
||||||
|
}
|
||||||
5
src/admin/components/elements/FieldSelect/index.scss
Normal file
5
src/admin/components/elements/FieldSelect/index.scss
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
@import '../../../scss/styles.scss';
|
||||||
|
|
||||||
|
.field-select {
|
||||||
|
margin-bottom: base(1);
|
||||||
|
}
|
||||||
101
src/admin/components/elements/FieldSelect/index.tsx
Normal file
101
src/admin/components/elements/FieldSelect/index.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
Field, fieldAffectsData,
|
||||||
|
fieldHasSubFields, FieldWithPath,
|
||||||
|
tabHasName,
|
||||||
|
} from '../../../../fields/config/types';
|
||||||
|
import ReactSelect from '../ReactSelect';
|
||||||
|
import { getTranslation } from '../../../../utilities/getTranslation';
|
||||||
|
import Label from '../../forms/Label';
|
||||||
|
import { useForm } from '../../forms/Form/context';
|
||||||
|
import { createNestedFieldPath } from '../../forms/Form/createNestedFieldPath';
|
||||||
|
|
||||||
|
import './index.scss';
|
||||||
|
|
||||||
|
const baseClass = 'field-select';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
fields: Field[];
|
||||||
|
setSelected: (fields: FieldWithPath[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const combineLabel = (prefix, field, i18n): string => (
|
||||||
|
`${prefix === '' ? '' : `${prefix} > `}${getTranslation(field.label || field.name, i18n) || ''}`
|
||||||
|
);
|
||||||
|
const reduceFields = (fields: Field[], i18n, path = '', labelPrefix = ''): {label: string, value: FieldWithPath}[] => (
|
||||||
|
fields.reduce((fieldsToUse, field) => {
|
||||||
|
// escape for a variety of reasons
|
||||||
|
if (fieldAffectsData(field) && (field.admin?.disableBulkEdit || field.unique || field.hidden || field.admin?.hidden || field.admin?.readOnly)) {
|
||||||
|
return fieldsToUse;
|
||||||
|
}
|
||||||
|
if (!(field.type === 'array' || field.type === 'blocks') && fieldHasSubFields(field)) {
|
||||||
|
return [
|
||||||
|
...fieldsToUse,
|
||||||
|
...reduceFields(field.fields, i18n, createNestedFieldPath(path, field), combineLabel(labelPrefix, field, i18n)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (field.type === 'tabs') {
|
||||||
|
return [
|
||||||
|
...fieldsToUse,
|
||||||
|
...field.tabs.reduce((tabFields, tab) => {
|
||||||
|
return [
|
||||||
|
...tabFields,
|
||||||
|
...(reduceFields(tab.fields, i18n, tabHasName(tab) ? createNestedFieldPath(path, field) : path, combineLabel(labelPrefix, field, i18n))),
|
||||||
|
];
|
||||||
|
}, []),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
const formattedField = {
|
||||||
|
label: combineLabel(labelPrefix, field, i18n),
|
||||||
|
value: {
|
||||||
|
...field,
|
||||||
|
path: createNestedFieldPath(path, field),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return [
|
||||||
|
...fieldsToUse,
|
||||||
|
formattedField,
|
||||||
|
];
|
||||||
|
}, []));
|
||||||
|
export const FieldSelect: React.FC<Props> = ({
|
||||||
|
fields,
|
||||||
|
setSelected,
|
||||||
|
}) => {
|
||||||
|
const { t, i18n } = useTranslation('general');
|
||||||
|
const [options] = useState(() => reduceFields(fields, i18n));
|
||||||
|
const { getFields, dispatchFields } = useForm();
|
||||||
|
const handleChange = (selected) => {
|
||||||
|
const activeFields = getFields();
|
||||||
|
if (selected === null) {
|
||||||
|
setSelected([]);
|
||||||
|
} else {
|
||||||
|
setSelected(selected.map(({ value }) => value));
|
||||||
|
}
|
||||||
|
// remove deselected values from form state
|
||||||
|
if (selected === null || Object.keys(activeFields).length > selected.length) {
|
||||||
|
Object.keys(activeFields).forEach((path) => {
|
||||||
|
if (selected === null || !selected.find((field) => {
|
||||||
|
return field.value.path === path;
|
||||||
|
})) {
|
||||||
|
dispatchFields({
|
||||||
|
type: 'REMOVE',
|
||||||
|
path,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={baseClass}>
|
||||||
|
<Label label={t('fields:selectFieldsToEdit')} />
|
||||||
|
<ReactSelect
|
||||||
|
options={options}
|
||||||
|
isMulti
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -5,6 +5,8 @@
|
|||||||
|
|
||||||
&__wrap {
|
&__wrap {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background-color: var(--theme-elevation-50);
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-filter {
|
.search-filter {
|
||||||
@@ -21,26 +23,30 @@
|
|||||||
|
|
||||||
&__buttons-wrap {
|
&__buttons-wrap {
|
||||||
display: flex;
|
display: flex;
|
||||||
margin-left: - base(.5);
|
align-items: center;
|
||||||
margin-right: - base(.5);
|
margin-right: base(.5);
|
||||||
width: calc(100% + #{base(1)});
|
|
||||||
|
.btn, .pill {
|
||||||
|
margin: 0 0 0 base(.5);
|
||||||
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
margin: 0 base(.5);
|
background-color: var(--theme-elevation-100);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 base(.25);
|
||||||
|
border-radius: $style-radius-s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--theme-elevation-200);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__toggle-columns,
|
&__buttons-active {
|
||||||
&__toggle-where,
|
|
||||||
&__toggle-sort {
|
|
||||||
min-width: 140px;
|
|
||||||
|
|
||||||
&.btn--style-primary {
|
|
||||||
svg {
|
svg {
|
||||||
transform: rotate(180deg);
|
transform: rotate(180deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.column-selector,
|
.column-selector,
|
||||||
.where-builder,
|
.where-builder,
|
||||||
@@ -48,25 +54,10 @@
|
|||||||
margin-top: base(1);
|
margin-top: base(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@include mid-break {
|
|
||||||
&__buttons {
|
|
||||||
margin-left: base(.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
&__buttons-wrap {
|
|
||||||
margin-left: - base(.25);
|
|
||||||
margin-right: - base(.25);
|
|
||||||
width: calc(100% + #{base(0.5)});
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
margin: 0 base(.25);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@include small-break {
|
@include small-break {
|
||||||
&__wrap {
|
&__wrap {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
background-color: unset;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-filter {
|
.search-filter {
|
||||||
@@ -74,11 +65,23 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__buttons {
|
&__buttons-wrap {
|
||||||
margin: 0;
|
margin-left: - base(.25);
|
||||||
|
margin-right: - base(.25);
|
||||||
|
width: calc(100% + #{base(0.5)});
|
||||||
|
|
||||||
|
.pill {
|
||||||
|
margin: 0 base(.25);
|
||||||
|
padding: base(.5) base(1);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__buttons {
|
&__buttons {
|
||||||
|
margin: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import AnimateHeight from 'react-animate-height';
|
import AnimateHeight from 'react-animate-height';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useWindowInfo } from '@faceless-ui/window-info';
|
||||||
import { fieldAffectsData } from '../../../../fields/config/types';
|
import { fieldAffectsData } from '../../../../fields/config/types';
|
||||||
import SearchFilter from '../SearchFilter';
|
import SearchFilter from '../SearchFilter';
|
||||||
import ColumnSelector from '../ColumnSelector';
|
import ColumnSelector from '../ColumnSelector';
|
||||||
@@ -13,6 +14,12 @@ import validateWhereQuery from '../WhereBuilder/validateWhereQuery';
|
|||||||
import flattenFields from '../../../../utilities/flattenTopLevelFields';
|
import flattenFields from '../../../../utilities/flattenTopLevelFields';
|
||||||
import { getTextFieldsToBeSearched } from './getTextFieldsToBeSearched';
|
import { getTextFieldsToBeSearched } from './getTextFieldsToBeSearched';
|
||||||
import { getTranslation } from '../../../../utilities/getTranslation';
|
import { getTranslation } from '../../../../utilities/getTranslation';
|
||||||
|
import Pill from '../Pill';
|
||||||
|
import Chevron from '../../icons/Chevron';
|
||||||
|
import EditMany from '../EditMany';
|
||||||
|
import DeleteMany from '../DeleteMany';
|
||||||
|
import PublishMany from '../PublishMany';
|
||||||
|
import UnpublishMany from '../UnpublishMany';
|
||||||
|
|
||||||
import './index.scss';
|
import './index.scss';
|
||||||
|
|
||||||
@@ -26,6 +33,7 @@ const ListControls: React.FC<Props> = (props) => {
|
|||||||
handleSortChange,
|
handleSortChange,
|
||||||
handleWhereChange,
|
handleWhereChange,
|
||||||
modifySearchQuery = true,
|
modifySearchQuery = true,
|
||||||
|
resetParams,
|
||||||
collection: {
|
collection: {
|
||||||
fields,
|
fields,
|
||||||
admin: {
|
admin: {
|
||||||
@@ -45,6 +53,7 @@ const ListControls: React.FC<Props> = (props) => {
|
|||||||
const [textFieldsToBeSearched] = useState(getTextFieldsToBeSearched(listSearchableFields, fields));
|
const [textFieldsToBeSearched] = useState(getTextFieldsToBeSearched(listSearchableFields, fields));
|
||||||
const [visibleDrawer, setVisibleDrawer] = useState<'where' | 'sort' | 'columns'>(shouldInitializeWhereOpened ? 'where' : undefined);
|
const [visibleDrawer, setVisibleDrawer] = useState<'where' | 'sort' | 'columns'>(shouldInitializeWhereOpened ? 'where' : undefined);
|
||||||
const { t, i18n } = useTranslation('general');
|
const { t, i18n } = useTranslation('general');
|
||||||
|
const { breakpoints: { s: smallBreak } } = useWindowInfo();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={baseClass}>
|
<div className={baseClass}>
|
||||||
@@ -58,26 +67,44 @@ const ListControls: React.FC<Props> = (props) => {
|
|||||||
/>
|
/>
|
||||||
<div className={`${baseClass}__buttons`}>
|
<div className={`${baseClass}__buttons`}>
|
||||||
<div className={`${baseClass}__buttons-wrap`}>
|
<div className={`${baseClass}__buttons-wrap`}>
|
||||||
|
{ !smallBreak && (
|
||||||
|
<React.Fragment>
|
||||||
|
<EditMany
|
||||||
|
collection={collection}
|
||||||
|
resetParams={resetParams}
|
||||||
|
/>
|
||||||
|
<PublishMany
|
||||||
|
collection={collection}
|
||||||
|
resetParams={resetParams}
|
||||||
|
/>
|
||||||
|
<UnpublishMany
|
||||||
|
collection={collection}
|
||||||
|
resetParams={resetParams}
|
||||||
|
/>
|
||||||
|
<DeleteMany
|
||||||
|
collection={collection}
|
||||||
|
resetParams={resetParams}
|
||||||
|
/>
|
||||||
|
</React.Fragment>
|
||||||
|
)}
|
||||||
{enableColumns && (
|
{enableColumns && (
|
||||||
<Button
|
<Pill
|
||||||
className={`${baseClass}__toggle-columns`}
|
pillStyle="dark"
|
||||||
buttonStyle={visibleDrawer === 'columns' ? undefined : 'secondary'}
|
className={`${baseClass}__toggle-columns ${visibleDrawer === 'columns' ? `${baseClass}__buttons-active` : ''}`}
|
||||||
onClick={() => setVisibleDrawer(visibleDrawer !== 'columns' ? 'columns' : undefined)}
|
onClick={() => setVisibleDrawer(visibleDrawer !== 'columns' ? 'columns' : undefined)}
|
||||||
icon="chevron"
|
icon={<Chevron />}
|
||||||
iconStyle="none"
|
|
||||||
>
|
>
|
||||||
{t('columns')}
|
{t('columns')}
|
||||||
</Button>
|
</Pill>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Pill
|
||||||
className={`${baseClass}__toggle-where`}
|
pillStyle="dark"
|
||||||
buttonStyle={visibleDrawer === 'where' ? undefined : 'secondary'}
|
className={`${baseClass}__toggle-where ${visibleDrawer === 'where' ? `${baseClass}__buttons-active` : ''}`}
|
||||||
onClick={() => setVisibleDrawer(visibleDrawer !== 'where' ? 'where' : undefined)}
|
onClick={() => setVisibleDrawer(visibleDrawer !== 'where' ? 'where' : undefined)}
|
||||||
icon="chevron"
|
icon={<Chevron />}
|
||||||
iconStyle="none"
|
|
||||||
>
|
>
|
||||||
{t('filters')}
|
{t('filters')}
|
||||||
</Button>
|
</Pill>
|
||||||
{enableSort && (
|
{enableSort && (
|
||||||
<Button
|
<Button
|
||||||
className={`${baseClass}__toggle-sort`}
|
className={`${baseClass}__toggle-sort`}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Where } from '../../../../types';
|
import { Where } from '../../../../types';
|
||||||
import { SanitizedCollectionConfig } from '../../../../collections/config/types';
|
import { SanitizedCollectionConfig } from '../../../../collections/config/types';
|
||||||
import { Column } from '../Table/types';
|
import { Column } from '../Table/types';
|
||||||
|
import type { Props as ListProps } from '../../views/collections/List/types';
|
||||||
|
|
||||||
export type Props = {
|
export type Props = {
|
||||||
enableColumns?: boolean
|
enableColumns?: boolean
|
||||||
@@ -9,6 +10,7 @@ export type Props = {
|
|||||||
handleSortChange?: (sort: string) => void
|
handleSortChange?: (sort: string) => void
|
||||||
handleWhereChange?: (where: Where) => void
|
handleWhereChange?: (where: Where) => void
|
||||||
collection: SanitizedCollectionConfig
|
collection: SanitizedCollectionConfig
|
||||||
|
resetParams?: ListProps['resetParams']
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ListControls = {
|
export type ListControls = {
|
||||||
|
|||||||
@@ -222,16 +222,6 @@ export const ListDrawerContent: React.FC<ListDrawerProps> = ({
|
|||||||
hasCreatePermission,
|
hasCreatePermission,
|
||||||
disableEyebrow: true,
|
disableEyebrow: true,
|
||||||
modifySearchParams: false,
|
modifySearchParams: false,
|
||||||
onCardClick: (doc) => {
|
|
||||||
if (typeof onSelect === 'function') {
|
|
||||||
onSelect({
|
|
||||||
docID: doc.id,
|
|
||||||
collectionConfig: selectedCollectionConfig,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
closeModal(drawerSlug);
|
|
||||||
},
|
|
||||||
disableCardLink: true,
|
|
||||||
handleSortChange: setSort,
|
handleSortChange: setSort,
|
||||||
handleWhereChange: setWhere,
|
handleWhereChange: setWhere,
|
||||||
handlePageChange: setPage,
|
handlePageChange: setPage,
|
||||||
|
|||||||
@@ -20,15 +20,11 @@
|
|||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
|
||||||
.pill {
|
button .pill {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
margin-top: base(0.25);
|
margin-top: base(0.25);
|
||||||
margin-left: base(0.5);
|
margin-left: base(0.5);
|
||||||
|
|
||||||
@include mid-break {
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import './index.scss';
|
|||||||
|
|
||||||
export const baseClass = 'list-drawer';
|
export const baseClass = 'list-drawer';
|
||||||
|
|
||||||
const formatListDrawerSlug = ({
|
export const formatListDrawerSlug = ({
|
||||||
depth,
|
depth,
|
||||||
uuid,
|
uuid,
|
||||||
}: {
|
}: {
|
||||||
|
|||||||
19
src/admin/components/elements/ListSelection/index.scss
Normal file
19
src/admin/components/elements/ListSelection/index.scss
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
@import '../../../scss/styles.scss';
|
||||||
|
|
||||||
|
.list-selection {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--theme-elevation-500);
|
||||||
|
|
||||||
|
&__button {
|
||||||
|
color: var(--theme-elevation-500);
|
||||||
|
background: unset;
|
||||||
|
border: none;
|
||||||
|
text-decoration: underline;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@include small-break {
|
||||||
|
margin-bottom: base(.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
41
src/admin/components/elements/ListSelection/index.tsx
Normal file
41
src/admin/components/elements/ListSelection/index.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import React, { Fragment } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { SelectAllStatus, useSelection } from '../../views/collections/List/SelectionProvider';
|
||||||
|
|
||||||
|
import './index.scss';
|
||||||
|
|
||||||
|
const baseClass = 'list-selection';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
const ListSelection: React.FC<Props> = ({ label }) => {
|
||||||
|
const { toggleAll, count, totalDocs, selectAll } = useSelection();
|
||||||
|
const { t } = useTranslation('general');
|
||||||
|
|
||||||
|
if (count === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={baseClass}>
|
||||||
|
<span>{t('selectedCount', { label, count })}</span>
|
||||||
|
{ selectAll !== SelectAllStatus.AllAvailable && (
|
||||||
|
<Fragment>
|
||||||
|
{' '}
|
||||||
|
—
|
||||||
|
<button
|
||||||
|
className={`${baseClass}__button`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleAll(true)}
|
||||||
|
aria-label={t('selectAll', { label, count })}
|
||||||
|
>
|
||||||
|
{t('selectAll', { label, count: totalDocs })}
|
||||||
|
</button>
|
||||||
|
</Fragment>
|
||||||
|
) }
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ListSelection;
|
||||||
17
src/admin/components/elements/PublishMany/index.scss
Normal file
17
src/admin/components/elements/PublishMany/index.scss
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
@import '../../../scss/styles.scss';
|
||||||
|
|
||||||
|
.publish-many {
|
||||||
|
@include blur-bg;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
&__template {
|
||||||
|
z-index: 1;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
margin-right: $baseline;
|
||||||
|
}
|
||||||
|
}
|
||||||
123
src/admin/components/elements/PublishMany/index.tsx
Normal file
123
src/admin/components/elements/PublishMany/index.tsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import React, { useState, useCallback } from 'react';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
import { Modal, useModal } from '@faceless-ui/modal';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useConfig } from '../../utilities/Config';
|
||||||
|
import Button from '../Button';
|
||||||
|
import MinimalTemplate from '../../templates/Minimal';
|
||||||
|
import { requests } from '../../../api';
|
||||||
|
import { Props } from './types';
|
||||||
|
import { SelectAllStatus, useSelection } from '../../views/collections/List/SelectionProvider';
|
||||||
|
import { getTranslation } from '../../../../utilities/getTranslation';
|
||||||
|
import Pill from '../Pill';
|
||||||
|
import { useAuth } from '../../utilities/Auth';
|
||||||
|
|
||||||
|
import './index.scss';
|
||||||
|
|
||||||
|
const baseClass = 'publish-many';
|
||||||
|
|
||||||
|
const PublishMany: React.FC<Props> = (props) => {
|
||||||
|
const {
|
||||||
|
resetParams,
|
||||||
|
collection: {
|
||||||
|
slug,
|
||||||
|
labels: {
|
||||||
|
plural,
|
||||||
|
},
|
||||||
|
versions,
|
||||||
|
} = {},
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const { serverURL, routes: { api } } = useConfig();
|
||||||
|
const { permissions } = useAuth();
|
||||||
|
const { toggleModal } = useModal();
|
||||||
|
const { t, i18n } = useTranslation('version');
|
||||||
|
const { selectAll, count, getQueryParams } = useSelection();
|
||||||
|
const [submitted, setSubmitted] = useState(false);
|
||||||
|
|
||||||
|
const collectionPermissions = permissions?.collections?.[slug];
|
||||||
|
const hasPermission = collectionPermissions?.update?.permission;
|
||||||
|
|
||||||
|
const modalSlug = `publish-${slug}`;
|
||||||
|
|
||||||
|
const addDefaultError = useCallback(() => {
|
||||||
|
toast.error(t('error:unknown'));
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
const handlePublish = useCallback(() => {
|
||||||
|
setSubmitted(true);
|
||||||
|
requests.patch(`${serverURL}${api}/${slug}${getQueryParams({ _status: { not_equals: 'published' } })}`, {
|
||||||
|
body: JSON.stringify({
|
||||||
|
_status: 'published',
|
||||||
|
}),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept-Language': i18n.language,
|
||||||
|
},
|
||||||
|
}).then(async (res) => {
|
||||||
|
try {
|
||||||
|
const json = await res.json();
|
||||||
|
toggleModal(modalSlug);
|
||||||
|
if (res.status < 400) {
|
||||||
|
toast.success(t('general:updatedSuccessfully'));
|
||||||
|
resetParams({ page: selectAll ? 1 : undefined });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (json.errors) {
|
||||||
|
json.errors.forEach((error) => toast.error(error.message));
|
||||||
|
} else {
|
||||||
|
addDefaultError();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch (e) {
|
||||||
|
return addDefaultError();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [addDefaultError, api, getQueryParams, i18n.language, modalSlug, resetParams, selectAll, serverURL, slug, t, toggleModal]);
|
||||||
|
|
||||||
|
if (!(versions?.drafts) || (selectAll === SelectAllStatus.None || !hasPermission)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment>
|
||||||
|
<Pill
|
||||||
|
className={`${baseClass}__toggle`}
|
||||||
|
onClick={() => {
|
||||||
|
setSubmitted(false);
|
||||||
|
toggleModal(modalSlug);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('publish')}
|
||||||
|
</Pill>
|
||||||
|
<Modal
|
||||||
|
slug={modalSlug}
|
||||||
|
className={baseClass}
|
||||||
|
>
|
||||||
|
<MinimalTemplate className={`${baseClass}__template`}>
|
||||||
|
<h1>{t('confirmPublish')}</h1>
|
||||||
|
<p>
|
||||||
|
{t('aboutToPublishSelection', { label: getTranslation(plural, i18n) })}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
id="confirm-cancel"
|
||||||
|
buttonStyle="secondary"
|
||||||
|
type="button"
|
||||||
|
onClick={submitted ? undefined : () => toggleModal(modalSlug)}
|
||||||
|
>
|
||||||
|
{t('general:cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={submitted ? undefined : handlePublish}
|
||||||
|
id="confirm-publish"
|
||||||
|
>
|
||||||
|
{submitted ? t('publishing') : t('general:confirm')}
|
||||||
|
</Button>
|
||||||
|
</MinimalTemplate>
|
||||||
|
</Modal>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PublishMany;
|
||||||
7
src/admin/components/elements/PublishMany/types.ts
Normal file
7
src/admin/components/elements/PublishMany/types.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { SanitizedCollectionConfig } from '../../../../collections/config/types';
|
||||||
|
import type { Props as ListProps } from '../../views/collections/List/types';
|
||||||
|
|
||||||
|
export type Props = {
|
||||||
|
collection: SanitizedCollectionConfig,
|
||||||
|
resetParams: ListProps['resetParams'],
|
||||||
|
}
|
||||||
@@ -58,6 +58,7 @@ const SelectAdapter: React.FC<Props> = (props) => {
|
|||||||
isClearable={isClearable}
|
isClearable={isClearable}
|
||||||
filterOption={filterOption}
|
filterOption={filterOption}
|
||||||
onMenuOpen={onMenuOpen}
|
onMenuOpen={onMenuOpen}
|
||||||
|
menuPlacement="auto"
|
||||||
selectProps={{
|
selectProps={{
|
||||||
...selectProps,
|
...selectProps,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -7,13 +7,12 @@ const baseClass = 'render-title';
|
|||||||
|
|
||||||
const RenderTitle: React.FC<Props> = (props) => {
|
const RenderTitle: React.FC<Props> = (props) => {
|
||||||
const {
|
const {
|
||||||
useAsTitle,
|
|
||||||
collection,
|
collection,
|
||||||
title: titleFromProps,
|
title: titleFromProps,
|
||||||
data,
|
data,
|
||||||
fallback = '[untitled]',
|
fallback = '[untitled]',
|
||||||
} = props;
|
} = props;
|
||||||
const titleFromForm = useTitle(useAsTitle, collection);
|
const titleFromForm = useTitle(collection);
|
||||||
|
|
||||||
let title = titleFromForm;
|
let title = titleFromForm;
|
||||||
if (!title) title = data?.id;
|
if (!title) title = data?.id;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { SanitizedCollectionConfig } from '../../../../collections/config/types';
|
||||||
|
|
||||||
export type Props = {
|
export type Props = {
|
||||||
useAsTitle?: string
|
useAsTitle?: string
|
||||||
data?: {
|
data?: {
|
||||||
@@ -5,5 +7,5 @@ export type Props = {
|
|||||||
}
|
}
|
||||||
title?: string
|
title?: string
|
||||||
fallback?: string
|
fallback?: string
|
||||||
collection?: string
|
collection?: SanitizedCollectionConfig
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,20 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
right: base(.5);
|
left: base(.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
&__input {
|
&__input {
|
||||||
@include formInput;
|
@include formInput;
|
||||||
|
box-shadow: none;
|
||||||
|
padding-left: base(2);
|
||||||
|
background-color: var(--theme-elevation-50);
|
||||||
|
border: none;
|
||||||
|
|
||||||
|
&:not(:disabled) {
|
||||||
|
&:hover, &:focus {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const Status: React.FC<Props> = () => {
|
|||||||
global,
|
global,
|
||||||
id,
|
id,
|
||||||
getVersions,
|
getVersions,
|
||||||
|
docPermissions,
|
||||||
} = useDocumentInfo();
|
} = useDocumentInfo();
|
||||||
const { toggleModal } = useModal();
|
const { toggleModal } = useModal();
|
||||||
const {
|
const {
|
||||||
@@ -114,12 +115,14 @@ const Status: React.FC<Props> = () => {
|
|||||||
}
|
}
|
||||||
}, [collection, global, publishedDoc, serverURL, api, id, i18n, locale, resetForm, getVersions, t, toggleModal, revertModalSlug, unPublishModalSlug]);
|
}, [collection, global, publishedDoc, serverURL, api, id, i18n, locale, resetForm, getVersions, t, toggleModal, revertModalSlug, unPublishModalSlug]);
|
||||||
|
|
||||||
|
const canUpdate = docPermissions?.update?.permission;
|
||||||
|
|
||||||
if (statusToRender) {
|
if (statusToRender) {
|
||||||
return (
|
return (
|
||||||
<div className={baseClass}>
|
<div className={baseClass}>
|
||||||
<div className={`${baseClass}__value-wrap`}>
|
<div className={`${baseClass}__value-wrap`}>
|
||||||
<span className={`${baseClass}__value`}>{t(statusToRender)}</span>
|
<span className={`${baseClass}__value`}>{t(statusToRender)}</span>
|
||||||
{statusToRender === 'published' && (
|
{canUpdate && statusToRender === 'published' && (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
—
|
—
|
||||||
<Button
|
<Button
|
||||||
@@ -152,7 +155,7 @@ const Status: React.FC<Props> = () => {
|
|||||||
</Modal>
|
</Modal>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
)}
|
)}
|
||||||
{statusToRender === 'changed' && (
|
{canUpdate && statusToRender === 'changed' && (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
—
|
—
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,53 +1,25 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { TFunction } from 'react-i18next';
|
|
||||||
import Cell from '../../views/collections/List/Cell';
|
import Cell from '../../views/collections/List/Cell';
|
||||||
import SortColumn from '../SortColumn';
|
import SortColumn from '../SortColumn';
|
||||||
import { SanitizedCollectionConfig } from '../../../../collections/config/types';
|
import { SanitizedCollectionConfig } from '../../../../collections/config/types';
|
||||||
import { Column } from '../Table/types';
|
import { Column } from '../Table/types';
|
||||||
import { Field, fieldIsPresentationalOnly } from '../../../../fields/config/types';
|
import { fieldIsPresentationalOnly } from '../../../../fields/config/types';
|
||||||
import flattenFields from '../../../../utilities/flattenTopLevelFields';
|
import flattenFields from '../../../../utilities/flattenTopLevelFields';
|
||||||
import { Props as CellProps } from '../../views/collections/List/Cell/types';
|
import { Props as CellProps } from '../../views/collections/List/Cell/types';
|
||||||
|
import SelectAll from '../../views/collections/List/SelectAll';
|
||||||
|
import SelectRow from '../../views/collections/List/SelectRow';
|
||||||
|
|
||||||
const buildColumns = ({
|
const buildColumns = ({
|
||||||
collection,
|
collection,
|
||||||
columns,
|
columns,
|
||||||
t,
|
|
||||||
cellProps,
|
cellProps,
|
||||||
}: {
|
}: {
|
||||||
collection: SanitizedCollectionConfig,
|
collection: SanitizedCollectionConfig,
|
||||||
columns: Pick<Column, 'accessor' | 'active'>[],
|
columns: Pick<Column, 'accessor' | 'active'>[],
|
||||||
t: TFunction,
|
|
||||||
cellProps: Partial<CellProps>[]
|
cellProps: Partial<CellProps>[]
|
||||||
}): Column[] => {
|
}): Column[] => {
|
||||||
// only insert each base field if it doesn't already exist in the collection
|
|
||||||
const baseFields: Field[] = [
|
|
||||||
{
|
|
||||||
name: 'id',
|
|
||||||
type: 'text',
|
|
||||||
label: 'ID',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'updatedAt',
|
|
||||||
type: 'date',
|
|
||||||
label: t('updatedAt'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'createdAt',
|
|
||||||
type: 'date',
|
|
||||||
label: t('createdAt'),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const combinedFields = baseFields.reduce((acc, field) => {
|
|
||||||
// if the field already exists in the collection, don't add it
|
|
||||||
if (acc.find((f) => 'name' in f && 'name' in field && f.name === field.name)) return acc;
|
|
||||||
return [...acc, field];
|
|
||||||
}, collection.fields);
|
|
||||||
|
|
||||||
const flattenedFields = flattenFields(combinedFields, true);
|
|
||||||
|
|
||||||
// sort the fields to the order of activeColumns
|
// sort the fields to the order of activeColumns
|
||||||
const sortedFields = flattenedFields.sort((a, b) => {
|
const sortedFields = flattenFields(collection.fields, true).sort((a, b) => {
|
||||||
const aIndex = columns.findIndex((column) => column.accessor === a.name);
|
const aIndex = columns.findIndex((column) => column.accessor === a.name);
|
||||||
const bIndex = columns.findIndex((column) => column.accessor === b.name);
|
const bIndex = columns.findIndex((column) => column.accessor === b.name);
|
||||||
if (aIndex === -1 && bIndex === -1) return 0;
|
if (aIndex === -1 && bIndex === -1) return 0;
|
||||||
@@ -58,10 +30,14 @@ const buildColumns = ({
|
|||||||
|
|
||||||
const firstActiveColumn = sortedFields.find((field) => columns.find((column) => column.accessor === field.name)?.active);
|
const firstActiveColumn = sortedFields.find((field) => columns.find((column) => column.accessor === field.name)?.active);
|
||||||
|
|
||||||
const cols: Column[] = sortedFields.map((field, colIndex) => {
|
let colIndex = -1;
|
||||||
|
const cols: Column[] = sortedFields.map((field) => {
|
||||||
const isActive = columns.find((column) => column.accessor === field.name)?.active || false;
|
const isActive = columns.find((column) => column.accessor === field.name)?.active || false;
|
||||||
const isFirstActive = firstActiveColumn?.name === field.name;
|
const isFirstActive = firstActiveColumn?.name === field.name;
|
||||||
|
if (isActive) {
|
||||||
|
colIndex += 1;
|
||||||
|
}
|
||||||
|
const props = cellProps?.[colIndex] || {};
|
||||||
return {
|
return {
|
||||||
active: isActive,
|
active: isActive,
|
||||||
accessor: field.name,
|
accessor: field.name,
|
||||||
@@ -89,7 +65,7 @@ const buildColumns = ({
|
|||||||
rowData={rowData}
|
rowData={rowData}
|
||||||
cellData={cellData}
|
cellData={cellData}
|
||||||
link={isFirstActive}
|
link={isFirstActive}
|
||||||
{...(cellProps?.[colIndex] || {})}
|
{...(props)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -97,6 +73,25 @@ const buildColumns = ({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (cellProps?.[0]?.link !== false) {
|
||||||
|
cols.unshift({
|
||||||
|
active: true,
|
||||||
|
label: null,
|
||||||
|
name: '',
|
||||||
|
accessor: '_select',
|
||||||
|
components: {
|
||||||
|
Heading: (
|
||||||
|
<SelectAll />
|
||||||
|
),
|
||||||
|
renderCell: (rowData) => (
|
||||||
|
<SelectRow
|
||||||
|
id={rowData.id}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return cols;
|
return cols;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { TFunction } from 'react-i18next';
|
|
||||||
import { SanitizedCollectionConfig } from '../../../../collections/config/types';
|
import { SanitizedCollectionConfig } from '../../../../collections/config/types';
|
||||||
import { Column } from '../Table/types';
|
import { Column } from '../Table/types';
|
||||||
import buildColumns from './buildColumns';
|
import buildColumns from './buildColumns';
|
||||||
@@ -9,7 +8,6 @@ type TOGGLE = {
|
|||||||
type: 'toggle',
|
type: 'toggle',
|
||||||
payload: {
|
payload: {
|
||||||
column: string
|
column: string
|
||||||
t: TFunction
|
|
||||||
collection: SanitizedCollectionConfig
|
collection: SanitizedCollectionConfig
|
||||||
cellProps: Partial<CellProps>[]
|
cellProps: Partial<CellProps>[]
|
||||||
}
|
}
|
||||||
@@ -19,7 +17,6 @@ type SET = {
|
|||||||
type: 'set',
|
type: 'set',
|
||||||
payload: {
|
payload: {
|
||||||
columns: Pick<Column, 'accessor' | 'active'>[]
|
columns: Pick<Column, 'accessor' | 'active'>[]
|
||||||
t: TFunction
|
|
||||||
collection: SanitizedCollectionConfig
|
collection: SanitizedCollectionConfig
|
||||||
cellProps: Partial<CellProps>[]
|
cellProps: Partial<CellProps>[]
|
||||||
}
|
}
|
||||||
@@ -30,7 +27,6 @@ type MOVE = {
|
|||||||
payload: {
|
payload: {
|
||||||
fromIndex: number
|
fromIndex: number
|
||||||
toIndex: number
|
toIndex: number
|
||||||
t: TFunction
|
|
||||||
collection: SanitizedCollectionConfig
|
collection: SanitizedCollectionConfig
|
||||||
cellProps: Partial<CellProps>[]
|
cellProps: Partial<CellProps>[]
|
||||||
}
|
}
|
||||||
@@ -43,7 +39,6 @@ export const columnReducer = (state: Column[], action: Action): Column[] => {
|
|||||||
case 'toggle': {
|
case 'toggle': {
|
||||||
const {
|
const {
|
||||||
column,
|
column,
|
||||||
t,
|
|
||||||
collection,
|
collection,
|
||||||
cellProps,
|
cellProps,
|
||||||
} = action.payload;
|
} = action.payload;
|
||||||
@@ -62,7 +57,6 @@ export const columnReducer = (state: Column[], action: Action): Column[] => {
|
|||||||
return buildColumns({
|
return buildColumns({
|
||||||
columns: withToggledColumn,
|
columns: withToggledColumn,
|
||||||
collection,
|
collection,
|
||||||
t,
|
|
||||||
cellProps,
|
cellProps,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -70,7 +64,6 @@ export const columnReducer = (state: Column[], action: Action): Column[] => {
|
|||||||
const {
|
const {
|
||||||
fromIndex,
|
fromIndex,
|
||||||
toIndex,
|
toIndex,
|
||||||
t,
|
|
||||||
collection,
|
collection,
|
||||||
cellProps,
|
cellProps,
|
||||||
} = action.payload;
|
} = action.payload;
|
||||||
@@ -82,14 +75,12 @@ export const columnReducer = (state: Column[], action: Action): Column[] => {
|
|||||||
return buildColumns({
|
return buildColumns({
|
||||||
columns: withMovedColumn,
|
columns: withMovedColumn,
|
||||||
collection,
|
collection,
|
||||||
t,
|
|
||||||
cellProps,
|
cellProps,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
case 'set': {
|
case 'set': {
|
||||||
const {
|
const {
|
||||||
columns,
|
columns,
|
||||||
t,
|
|
||||||
collection,
|
collection,
|
||||||
cellProps,
|
cellProps,
|
||||||
} = action.payload;
|
} = action.payload;
|
||||||
@@ -97,7 +88,6 @@ export const columnReducer = (state: Column[], action: Action): Column[] => {
|
|||||||
return buildColumns({
|
return buildColumns({
|
||||||
columns,
|
columns,
|
||||||
collection,
|
collection,
|
||||||
t,
|
|
||||||
cellProps,
|
cellProps,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback, useEffect, useReducer, createContext, useContext, useRef } from 'react';
|
import React, { useCallback, useEffect, useReducer, createContext, useContext, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { SanitizedCollectionConfig } from '../../../../collections/config/types';
|
import { SanitizedCollectionConfig } from '../../../../collections/config/types';
|
||||||
import { usePreferences } from '../../utilities/Preferences';
|
import { usePreferences } from '../../utilities/Preferences';
|
||||||
@@ -8,6 +8,8 @@ import buildColumns from './buildColumns';
|
|||||||
import { Action, columnReducer } from './columnReducer';
|
import { Action, columnReducer } from './columnReducer';
|
||||||
import getInitialColumnState from './getInitialColumns';
|
import getInitialColumnState from './getInitialColumns';
|
||||||
import { Props as CellProps } from '../../views/collections/List/Cell/types';
|
import { Props as CellProps } from '../../views/collections/List/Cell/types';
|
||||||
|
import formatFields from '../../views/collections/List/formatFields';
|
||||||
|
import { Field } from '../../../../fields/config/types';
|
||||||
|
|
||||||
export interface ITableColumns {
|
export interface ITableColumns {
|
||||||
columns: Column[]
|
columns: Column[]
|
||||||
@@ -33,21 +35,22 @@ export const TableColumnsProvider: React.FC<{
|
|||||||
cellProps,
|
cellProps,
|
||||||
collection,
|
collection,
|
||||||
collection: {
|
collection: {
|
||||||
fields,
|
|
||||||
admin: {
|
admin: {
|
||||||
useAsTitle,
|
useAsTitle,
|
||||||
defaultColumns,
|
defaultColumns,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation('general');
|
|
||||||
const preferenceKey = `${collection.slug}-list`;
|
const preferenceKey = `${collection.slug}-list`;
|
||||||
const prevCollection = useRef<SanitizedCollectionConfig['slug']>();
|
const prevCollection = useRef<SanitizedCollectionConfig['slug']>();
|
||||||
const hasInitialized = useRef(false);
|
const hasInitialized = useRef(false);
|
||||||
const { getPreference, setPreference } = usePreferences();
|
const { getPreference, setPreference } = usePreferences();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [formattedFields] = useState<Field[]>(() => formatFields(collection, t));
|
||||||
|
|
||||||
const [tableColumns, dispatchTableColumns] = useReducer(columnReducer, {}, () => {
|
const [tableColumns, dispatchTableColumns] = useReducer(columnReducer, {}, () => {
|
||||||
const initialColumns = getInitialColumnState(fields, useAsTitle, defaultColumns);
|
const initialColumns = getInitialColumnState(formattedFields, useAsTitle, defaultColumns);
|
||||||
|
|
||||||
return buildColumns({
|
return buildColumns({
|
||||||
collection,
|
collection,
|
||||||
columns: initialColumns.map((column) => ({
|
columns: initialColumns.map((column) => ({
|
||||||
@@ -55,7 +58,6 @@ export const TableColumnsProvider: React.FC<{
|
|||||||
active: true,
|
active: true,
|
||||||
})),
|
})),
|
||||||
cellProps,
|
cellProps,
|
||||||
t,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -72,7 +74,7 @@ export const TableColumnsProvider: React.FC<{
|
|||||||
|
|
||||||
const currentPreferences = await getPreference<ListPreferences>(preferenceKey);
|
const currentPreferences = await getPreference<ListPreferences>(preferenceKey);
|
||||||
prevCollection.current = collection.slug;
|
prevCollection.current = collection.slug;
|
||||||
const initialColumns = getInitialColumnState(fields, useAsTitle, defaultColumns);
|
const initialColumns = getInitialColumnState(formattedFields, useAsTitle, defaultColumns);
|
||||||
const newCols = currentPreferences?.columns || initialColumns;
|
const newCols = currentPreferences?.columns || initialColumns;
|
||||||
|
|
||||||
dispatchTableColumns({
|
dispatchTableColumns({
|
||||||
@@ -89,8 +91,7 @@ export const TableColumnsProvider: React.FC<{
|
|||||||
}
|
}
|
||||||
return column;
|
return column;
|
||||||
}),
|
}),
|
||||||
t,
|
collection: { ...collection, fields: formatFields(collection, t) },
|
||||||
collection,
|
|
||||||
cellProps,
|
cellProps,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -100,7 +101,7 @@ export const TableColumnsProvider: React.FC<{
|
|||||||
};
|
};
|
||||||
|
|
||||||
sync();
|
sync();
|
||||||
}, [preferenceKey, setPreference, fields, tableColumns, getPreference, useAsTitle, defaultColumns, t, collection, cellProps]);
|
}, [preferenceKey, setPreference, tableColumns, getPreference, useAsTitle, defaultColumns, collection, cellProps, formattedFields, t]);
|
||||||
|
|
||||||
// /////////////////////////////////////
|
// /////////////////////////////////////
|
||||||
// Set preferences on column change
|
// Set preferences on column change
|
||||||
@@ -130,12 +131,11 @@ export const TableColumnsProvider: React.FC<{
|
|||||||
dispatchTableColumns({
|
dispatchTableColumns({
|
||||||
type: 'set',
|
type: 'set',
|
||||||
payload: {
|
payload: {
|
||||||
collection,
|
collection: { ...collection, fields: formatFields(collection, t) },
|
||||||
columns: columns.map((column) => ({
|
columns: columns.map((column) => ({
|
||||||
accessor: column,
|
accessor: column,
|
||||||
active: true,
|
active: true,
|
||||||
})),
|
})),
|
||||||
t,
|
|
||||||
// onSelect,
|
// onSelect,
|
||||||
cellProps,
|
cellProps,
|
||||||
},
|
},
|
||||||
@@ -153,8 +153,7 @@ export const TableColumnsProvider: React.FC<{
|
|||||||
payload: {
|
payload: {
|
||||||
fromIndex,
|
fromIndex,
|
||||||
toIndex,
|
toIndex,
|
||||||
collection,
|
collection: { ...collection, fields: formatFields(collection, t) },
|
||||||
t,
|
|
||||||
cellProps,
|
cellProps,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -165,8 +164,7 @@ export const TableColumnsProvider: React.FC<{
|
|||||||
type: 'toggle',
|
type: 'toggle',
|
||||||
payload: {
|
payload: {
|
||||||
column,
|
column,
|
||||||
collection,
|
collection: { ...collection, fields: formatFields(collection, t) },
|
||||||
t,
|
|
||||||
cellProps,
|
cellProps,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const Thumbnail: React.FC<Props> = (props) => {
|
|||||||
},
|
},
|
||||||
collection,
|
collection,
|
||||||
size,
|
size,
|
||||||
|
className = '',
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const thumbnailSRC = useThumbnail(collection, doc);
|
const thumbnailSRC = useThumbnail(collection, doc);
|
||||||
@@ -22,6 +23,7 @@ const Thumbnail: React.FC<Props> = (props) => {
|
|||||||
const classes = [
|
const classes = [
|
||||||
baseClass,
|
baseClass,
|
||||||
`${baseClass}--size-${size || 'medium'}`,
|
`${baseClass}--size-${size || 'medium'}`,
|
||||||
|
className,
|
||||||
].join(' ');
|
].join(' ');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -4,4 +4,5 @@ export type Props = {
|
|||||||
doc: Record<string, unknown>
|
doc: Record<string, unknown>
|
||||||
collection: SanitizedCollectionConfig
|
collection: SanitizedCollectionConfig
|
||||||
size?: 'small' | 'medium' | 'large' | 'expand',
|
size?: 'small' | 'medium' | 'large' | 'expand',
|
||||||
|
className?: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import React from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Props } from './types';
|
import { Props } from './types';
|
||||||
import Thumbnail from '../Thumbnail';
|
import Thumbnail from '../Thumbnail';
|
||||||
|
import { useConfig } from '../../utilities/Config';
|
||||||
|
import { formatUseAsTitle } from '../../../hooks/useTitle';
|
||||||
|
|
||||||
import './index.scss';
|
import './index.scss';
|
||||||
|
|
||||||
@@ -14,12 +16,13 @@ export const ThumbnailCard: React.FC<Props> = (props) => {
|
|||||||
doc,
|
doc,
|
||||||
collection,
|
collection,
|
||||||
thumbnail,
|
thumbnail,
|
||||||
label,
|
label: labelFromProps,
|
||||||
alignLabel,
|
alignLabel,
|
||||||
onKeyDown,
|
onKeyDown,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const { t } = useTranslation('general');
|
const { t, i18n } = useTranslation('general');
|
||||||
|
const config = useConfig();
|
||||||
|
|
||||||
const classes = [
|
const classes = [
|
||||||
baseClass,
|
baseClass,
|
||||||
@@ -28,10 +31,20 @@ export const ThumbnailCard: React.FC<Props> = (props) => {
|
|||||||
alignLabel && `${baseClass}--align-label-${alignLabel}`,
|
alignLabel && `${baseClass}--align-label-${alignLabel}`,
|
||||||
].filter(Boolean).join(' ');
|
].filter(Boolean).join(' ');
|
||||||
|
|
||||||
const title: any = doc?.[collection?.admin?.useAsTitle] || doc?.filename || `[${t('untitled')}]`;
|
let title = labelFromProps;
|
||||||
|
|
||||||
|
if (!title) {
|
||||||
|
title = formatUseAsTitle({
|
||||||
|
doc,
|
||||||
|
collection,
|
||||||
|
i18n,
|
||||||
|
config,
|
||||||
|
}) || doc?.filename as string || `[${t('untitled')}]`;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
title={title}
|
||||||
className={classes}
|
className={classes}
|
||||||
onClick={typeof onClick === 'function' ? onClick : undefined}
|
onClick={typeof onClick === 'function' ? onClick : undefined}
|
||||||
onKeyDown={typeof onKeyDown === 'function' ? onKeyDown : undefined}
|
onKeyDown={typeof onKeyDown === 'function' ? onKeyDown : undefined}
|
||||||
@@ -47,7 +60,7 @@ export const ThumbnailCard: React.FC<Props> = (props) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={`${baseClass}__label`}>
|
<div className={`${baseClass}__label`}>
|
||||||
{label || title}
|
{title}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
17
src/admin/components/elements/UnpublishMany/index.scss
Normal file
17
src/admin/components/elements/UnpublishMany/index.scss
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
@import '../../../scss/styles.scss';
|
||||||
|
|
||||||
|
.unpublish-many {
|
||||||
|
@include blur-bg;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
&__template {
|
||||||
|
z-index: 1;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
margin-right: $baseline;
|
||||||
|
}
|
||||||
|
}
|
||||||
123
src/admin/components/elements/UnpublishMany/index.tsx
Normal file
123
src/admin/components/elements/UnpublishMany/index.tsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import React, { useState, useCallback } from 'react';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
import { Modal, useModal } from '@faceless-ui/modal';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useConfig } from '../../utilities/Config';
|
||||||
|
import Button from '../Button';
|
||||||
|
import MinimalTemplate from '../../templates/Minimal';
|
||||||
|
import { requests } from '../../../api';
|
||||||
|
import { Props } from './types';
|
||||||
|
import { SelectAllStatus, useSelection } from '../../views/collections/List/SelectionProvider';
|
||||||
|
import { getTranslation } from '../../../../utilities/getTranslation';
|
||||||
|
import Pill from '../Pill';
|
||||||
|
import { useAuth } from '../../utilities/Auth';
|
||||||
|
|
||||||
|
import './index.scss';
|
||||||
|
|
||||||
|
const baseClass = 'unpublish-many';
|
||||||
|
|
||||||
|
const UnpublishMany: React.FC<Props> = (props) => {
|
||||||
|
const {
|
||||||
|
resetParams,
|
||||||
|
collection: {
|
||||||
|
slug,
|
||||||
|
labels: {
|
||||||
|
plural,
|
||||||
|
},
|
||||||
|
versions,
|
||||||
|
} = {},
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const { serverURL, routes: { api } } = useConfig();
|
||||||
|
const { permissions } = useAuth();
|
||||||
|
const { toggleModal } = useModal();
|
||||||
|
const { t, i18n } = useTranslation('version');
|
||||||
|
const { selectAll, count, getQueryParams } = useSelection();
|
||||||
|
const [submitted, setSubmitted] = useState(false);
|
||||||
|
|
||||||
|
const collectionPermissions = permissions?.collections?.[slug];
|
||||||
|
const hasPermission = collectionPermissions?.update?.permission;
|
||||||
|
|
||||||
|
const modalSlug = `unpublish-${slug}`;
|
||||||
|
|
||||||
|
const addDefaultError = useCallback(() => {
|
||||||
|
toast.error(t('error:unknown'));
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
const handleUnpublish = useCallback(() => {
|
||||||
|
setSubmitted(true);
|
||||||
|
requests.patch(`${serverURL}${api}/${slug}${getQueryParams({ _status: { not_equals: 'draft' } })}`, {
|
||||||
|
body: JSON.stringify({
|
||||||
|
_status: 'draft',
|
||||||
|
}),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept-Language': i18n.language,
|
||||||
|
},
|
||||||
|
}).then(async (res) => {
|
||||||
|
try {
|
||||||
|
const json = await res.json();
|
||||||
|
toggleModal(modalSlug);
|
||||||
|
if (res.status < 400) {
|
||||||
|
toast.success(t('general:updatedSuccessfully'));
|
||||||
|
resetParams({ page: selectAll ? 1 : undefined });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (json.errors) {
|
||||||
|
json.errors.forEach((error) => toast.error(error.message));
|
||||||
|
} else {
|
||||||
|
addDefaultError();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch (e) {
|
||||||
|
return addDefaultError();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [addDefaultError, api, getQueryParams, i18n.language, modalSlug, resetParams, selectAll, serverURL, slug, t, toggleModal]);
|
||||||
|
|
||||||
|
if (!(versions?.drafts) || (selectAll === SelectAllStatus.None || !hasPermission)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment>
|
||||||
|
<Pill
|
||||||
|
className={`${baseClass}__toggle`}
|
||||||
|
onClick={() => {
|
||||||
|
setSubmitted(false);
|
||||||
|
toggleModal(modalSlug);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('unpublish')}
|
||||||
|
</Pill>
|
||||||
|
<Modal
|
||||||
|
slug={modalSlug}
|
||||||
|
className={baseClass}
|
||||||
|
>
|
||||||
|
<MinimalTemplate className={`${baseClass}__template`}>
|
||||||
|
<h1>{t('confirmUnpublish')}</h1>
|
||||||
|
<p>
|
||||||
|
{t('aboutToUnpublishSelection', { label: getTranslation(plural, i18n) })}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
id="confirm-cancel"
|
||||||
|
buttonStyle="secondary"
|
||||||
|
type="button"
|
||||||
|
onClick={submitted ? undefined : () => toggleModal(modalSlug)}
|
||||||
|
>
|
||||||
|
{t('general:cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={submitted ? undefined : handleUnpublish}
|
||||||
|
id="confirm-unpublish"
|
||||||
|
>
|
||||||
|
{submitted ? t('unpublishing') : t('general:confirm')}
|
||||||
|
</Button>
|
||||||
|
</MinimalTemplate>
|
||||||
|
</Modal>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UnpublishMany;
|
||||||
7
src/admin/components/elements/UnpublishMany/types.ts
Normal file
7
src/admin/components/elements/UnpublishMany/types.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { SanitizedCollectionConfig } from '../../../../collections/config/types';
|
||||||
|
import type { Props as ListProps } from '../../views/collections/List/types';
|
||||||
|
|
||||||
|
export type Props = {
|
||||||
|
collection: SanitizedCollectionConfig,
|
||||||
|
resetParams: ListProps['resetParams'],
|
||||||
|
}
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
@import '../../../scss/styles.scss';
|
|
||||||
|
|
||||||
.upload-gallery {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: base(2) -#{base(.5)};
|
|
||||||
width: calc(100% + #{$baseline});
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
|
|
||||||
li {
|
|
||||||
min-width: 0;
|
|
||||||
width: 16.66%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.thumbnail-card {
|
|
||||||
margin: base(.5);
|
|
||||||
max-width: initial;
|
|
||||||
}
|
|
||||||
|
|
||||||
@include mid-break {
|
|
||||||
li {
|
|
||||||
width: 33.33%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@include small-break {
|
|
||||||
li {
|
|
||||||
width: 50%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Props } from './types';
|
|
||||||
import { ThumbnailCard } from '../ThumbnailCard';
|
|
||||||
|
|
||||||
import './index.scss';
|
|
||||||
|
|
||||||
const baseClass = 'upload-gallery';
|
|
||||||
|
|
||||||
const UploadGallery: React.FC<Props> = (props) => {
|
|
||||||
const { docs, onCardClick, collection } = props;
|
|
||||||
|
|
||||||
if (docs && docs.length > 0) {
|
|
||||||
return (
|
|
||||||
<ul className={baseClass}>
|
|
||||||
{docs.map((doc) => (
|
|
||||||
<li key={String(doc.id)}>
|
|
||||||
<ThumbnailCard
|
|
||||||
doc={doc}
|
|
||||||
collection={collection}
|
|
||||||
onClick={() => onCardClick(doc)}
|
|
||||||
/>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default UploadGallery;
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { SanitizedCollectionConfig } from '../../../../collections/config/types';
|
|
||||||
|
|
||||||
export type Props = {
|
|
||||||
docs?: Record<string, unknown>[],
|
|
||||||
collection: SanitizedCollectionConfig,
|
|
||||||
onCardClick: (doc) => void,
|
|
||||||
}
|
|
||||||
@@ -7,10 +7,11 @@ import { useConfig } from '../../utilities/Config';
|
|||||||
import { useForm } from '../Form/context';
|
import { useForm } from '../Form/context';
|
||||||
|
|
||||||
type NullifyLocaleFieldProps = {
|
type NullifyLocaleFieldProps = {
|
||||||
|
localized: boolean
|
||||||
path: string
|
path: string
|
||||||
fieldValue?: null | [] | number
|
fieldValue?: null | [] | number
|
||||||
}
|
}
|
||||||
export const NullifyLocaleField: React.FC<NullifyLocaleFieldProps> = ({ path, fieldValue }) => {
|
export const NullifyLocaleField: React.FC<NullifyLocaleFieldProps> = ({ localized, path, fieldValue }) => {
|
||||||
const { dispatchFields, setModified } = useForm();
|
const { dispatchFields, setModified } = useForm();
|
||||||
const currentLocale = useLocale();
|
const currentLocale = useLocale();
|
||||||
const { localization } = useConfig();
|
const { localization } = useConfig();
|
||||||
@@ -30,8 +31,8 @@ export const NullifyLocaleField: React.FC<NullifyLocaleFieldProps> = ({ path, fi
|
|||||||
setChecked(useFallback);
|
setChecked(useFallback);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (currentLocale === defaultLocale || (localization && !localization.fallback)) {
|
if (!localized || currentLocale === defaultLocale || (localization && !localization.fallback)) {
|
||||||
// hide when editing default locale or when fallback is disabled
|
// hide when field is not localized or editing default locale or when fallback is disabled
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ const ArrayFieldType: React.FC<Props> = (props) => {
|
|||||||
minRows,
|
minRows,
|
||||||
permissions,
|
permissions,
|
||||||
indexPath,
|
indexPath,
|
||||||
|
localized,
|
||||||
admin: {
|
admin: {
|
||||||
readOnly,
|
readOnly,
|
||||||
description,
|
description,
|
||||||
@@ -62,13 +63,12 @@ const ArrayFieldType: React.FC<Props> = (props) => {
|
|||||||
|
|
||||||
const CustomRowLabel = components?.RowLabel || undefined;
|
const CustomRowLabel = components?.RowLabel || undefined;
|
||||||
|
|
||||||
const { preferencesKey } = useDocumentInfo();
|
const { preferencesKey, id } = useDocumentInfo();
|
||||||
const { getPreference } = usePreferences();
|
const { getPreference } = usePreferences();
|
||||||
const { setPreference } = usePreferences();
|
const { setPreference } = usePreferences();
|
||||||
const [rows, dispatchRows] = useReducer(reducer, undefined);
|
const [rows, dispatchRows] = useReducer(reducer, undefined);
|
||||||
const formContext = useForm();
|
const formContext = useForm();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { id } = useDocumentInfo();
|
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const operation = useOperation();
|
const operation = useOperation();
|
||||||
const { t, i18n } = useTranslation('fields');
|
const { t, i18n } = useTranslation('fields');
|
||||||
@@ -260,6 +260,7 @@ const ArrayFieldType: React.FC<Props> = (props) => {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<NullifyLocaleField
|
<NullifyLocaleField
|
||||||
|
localized={localized}
|
||||||
path={path}
|
path={path}
|
||||||
fieldValue={value}
|
fieldValue={value}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
&__block {
|
&__block {
|
||||||
margin: base(0.5);
|
margin: base(0.5);
|
||||||
width: calc(12.5% - #{base(1)});
|
width: calc((100% / 6) - #{base(1)});
|
||||||
}
|
}
|
||||||
|
|
||||||
&__default-image {
|
&__default-image {
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ const BlocksField: React.FC<Props> = (props) => {
|
|||||||
validate = blocksValidator,
|
validate = blocksValidator,
|
||||||
permissions,
|
permissions,
|
||||||
indexPath,
|
indexPath,
|
||||||
|
localized,
|
||||||
admin: {
|
admin: {
|
||||||
readOnly,
|
readOnly,
|
||||||
description,
|
description,
|
||||||
@@ -64,13 +65,12 @@ const BlocksField: React.FC<Props> = (props) => {
|
|||||||
|
|
||||||
const path = pathFromProps || name;
|
const path = pathFromProps || name;
|
||||||
|
|
||||||
const { preferencesKey } = useDocumentInfo();
|
const { preferencesKey, id } = useDocumentInfo();
|
||||||
const { getPreference } = usePreferences();
|
const { getPreference } = usePreferences();
|
||||||
const { setPreference } = usePreferences();
|
const { setPreference } = usePreferences();
|
||||||
const [rows, dispatchRows] = useReducer(reducer, undefined);
|
const [rows, dispatchRows] = useReducer(reducer, undefined);
|
||||||
const formContext = useForm();
|
const formContext = useForm();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { id } = useDocumentInfo();
|
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const operation = useOperation();
|
const operation = useOperation();
|
||||||
const { dispatchFields, setModified } = formContext;
|
const { dispatchFields, setModified } = formContext;
|
||||||
@@ -257,6 +257,7 @@ const BlocksField: React.FC<Props> = (props) => {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<NullifyLocaleField
|
<NullifyLocaleField
|
||||||
|
localized={localized}
|
||||||
path={path}
|
path={path}
|
||||||
fieldValue={value}
|
fieldValue={value}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import Plus from '../../../../icons/Plus';
|
|||||||
import { getTranslation } from '../../../../../../utilities/getTranslation';
|
import { getTranslation } from '../../../../../../utilities/getTranslation';
|
||||||
import Tooltip from '../../../../elements/Tooltip';
|
import Tooltip from '../../../../elements/Tooltip';
|
||||||
import { useDocumentDrawer } from '../../../../elements/DocumentDrawer';
|
import { useDocumentDrawer } from '../../../../elements/DocumentDrawer';
|
||||||
|
import { useConfig } from '../../../../utilities/Config';
|
||||||
|
|
||||||
import './index.scss';
|
import './index.scss';
|
||||||
|
|
||||||
@@ -25,6 +26,8 @@ export const AddNewRelation: React.FC<Props> = ({ path, hasMany, relationTo, val
|
|||||||
const [popupOpen, setPopupOpen] = useState(false);
|
const [popupOpen, setPopupOpen] = useState(false);
|
||||||
const { t, i18n } = useTranslation('fields');
|
const { t, i18n } = useTranslation('fields');
|
||||||
const [showTooltip, setShowTooltip] = useState(false);
|
const [showTooltip, setShowTooltip] = useState(false);
|
||||||
|
const config = useConfig();
|
||||||
|
|
||||||
const [
|
const [
|
||||||
DocumentDrawer,
|
DocumentDrawer,
|
||||||
DocumentDrawerToggler,
|
DocumentDrawerToggler,
|
||||||
@@ -47,6 +50,7 @@ export const AddNewRelation: React.FC<Props> = ({ path, hasMany, relationTo, val
|
|||||||
],
|
],
|
||||||
sort: true,
|
sort: true,
|
||||||
i18n,
|
i18n,
|
||||||
|
config,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (hasMany) {
|
if (hasMany) {
|
||||||
@@ -56,7 +60,7 @@ export const AddNewRelation: React.FC<Props> = ({ path, hasMany, relationTo, val
|
|||||||
}
|
}
|
||||||
|
|
||||||
setSelectedCollection(undefined);
|
setSelectedCollection(undefined);
|
||||||
}, [relationTo, collectionConfig, dispatchOptions, i18n, hasMany, setValue, value]);
|
}, [relationTo, collectionConfig, dispatchOptions, i18n, hasMany, setValue, value, config]);
|
||||||
|
|
||||||
const onPopopToggle = useCallback((state) => {
|
const onPopopToggle = useCallback((state) => {
|
||||||
setPopupOpen(state);
|
setPopupOpen(state);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Value } from './types';
|
import { Value } from './types';
|
||||||
|
|
||||||
type RelationMap = {
|
type RelationMap = {
|
||||||
[relation: string]: unknown[]
|
[relation: string]: (string | number)[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateRelationMap = (args: {
|
type CreateRelationMap = (args: {
|
||||||
|
|||||||
@@ -56,13 +56,15 @@ const Relationship: React.FC<Props> = (props) => {
|
|||||||
} = {},
|
} = {},
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
|
const config = useConfig();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
serverURL,
|
serverURL,
|
||||||
routes: {
|
routes: {
|
||||||
api,
|
api,
|
||||||
},
|
},
|
||||||
collections,
|
collections,
|
||||||
} = useConfig();
|
} = config;
|
||||||
|
|
||||||
const { t, i18n } = useTranslation('fields');
|
const { t, i18n } = useTranslation('fields');
|
||||||
const { permissions } = useAuth();
|
const { permissions } = useAuth();
|
||||||
@@ -172,9 +174,19 @@ const Relationship: React.FC<Props> = (props) => {
|
|||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data: PaginatedDocs<unknown> = await response.json();
|
const data: PaginatedDocs<unknown> = await response.json();
|
||||||
|
|
||||||
if (data.docs.length > 0) {
|
if (data.docs.length > 0) {
|
||||||
resultsFetched += data.docs.length;
|
resultsFetched += data.docs.length;
|
||||||
dispatchOptions({ type: 'ADD', docs: data.docs, collection, sort, i18n });
|
|
||||||
|
dispatchOptions({
|
||||||
|
type: 'ADD',
|
||||||
|
docs: data.docs,
|
||||||
|
collection,
|
||||||
|
sort,
|
||||||
|
i18n,
|
||||||
|
config,
|
||||||
|
});
|
||||||
|
|
||||||
setLastLoadedPage(data.page);
|
setLastLoadedPage(data.page);
|
||||||
|
|
||||||
if (!data.nextPage) {
|
if (!data.nextPage) {
|
||||||
@@ -190,7 +202,15 @@ const Relationship: React.FC<Props> = (props) => {
|
|||||||
} else if (response.status === 403) {
|
} else if (response.status === 403) {
|
||||||
setLastFullyLoadedRelation(relations.indexOf(relation));
|
setLastFullyLoadedRelation(relations.indexOf(relation));
|
||||||
lastLoadedPageToUse = 1;
|
lastLoadedPageToUse = 1;
|
||||||
dispatchOptions({ type: 'ADD', docs: [], collection, sort, ids: relationMap[relation], i18n });
|
dispatchOptions({
|
||||||
|
type: 'ADD',
|
||||||
|
docs: [],
|
||||||
|
collection,
|
||||||
|
sort,
|
||||||
|
ids: relationMap[relation],
|
||||||
|
i18n,
|
||||||
|
config,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
setErrorLoading(t('error:unspecific'));
|
setErrorLoading(t('error:unspecific'));
|
||||||
}
|
}
|
||||||
@@ -211,6 +231,7 @@ const Relationship: React.FC<Props> = (props) => {
|
|||||||
t,
|
t,
|
||||||
i18n,
|
i18n,
|
||||||
locale,
|
locale,
|
||||||
|
config,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const updateSearch = useDebouncedCallback((searchArg: string, valueArg: Value | Value[]) => {
|
const updateSearch = useDebouncedCallback((searchArg: string, valueArg: Value | Value[]) => {
|
||||||
@@ -261,13 +282,24 @@ const Relationship: React.FC<Props> = (props) => {
|
|||||||
'Accept-Language': i18n.language,
|
'Accept-Language': i18n.language,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const collection = collections.find((coll) => coll.slug === relation);
|
const collection = collections.find((coll) => coll.slug === relation);
|
||||||
|
let docs = [];
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
dispatchOptions({ type: 'ADD', docs: data.docs, collection, sort: true, ids: idsToLoad, i18n });
|
docs = data.docs;
|
||||||
} else if (response.status === 403) {
|
|
||||||
dispatchOptions({ type: 'ADD', docs: [], collection, sort: true, ids: idsToLoad, i18n });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dispatchOptions({
|
||||||
|
type: 'ADD',
|
||||||
|
docs,
|
||||||
|
collection,
|
||||||
|
sort: true,
|
||||||
|
ids: idsToLoad,
|
||||||
|
i18n,
|
||||||
|
config,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, Promise.resolve());
|
}, Promise.resolve());
|
||||||
@@ -283,6 +315,7 @@ const Relationship: React.FC<Props> = (props) => {
|
|||||||
i18n,
|
i18n,
|
||||||
relationTo,
|
relationTo,
|
||||||
locale,
|
locale,
|
||||||
|
config,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Determine if we should switch to word boundary search
|
// Determine if we should switch to word boundary search
|
||||||
@@ -311,8 +344,8 @@ const Relationship: React.FC<Props> = (props) => {
|
|||||||
}, [relationTo, filterOptionsResult, locale]);
|
}, [relationTo, filterOptionsResult, locale]);
|
||||||
|
|
||||||
const onSave = useCallback<DocumentDrawerProps['onSave']>((args) => {
|
const onSave = useCallback<DocumentDrawerProps['onSave']>((args) => {
|
||||||
dispatchOptions({ type: 'UPDATE', doc: args.doc, collection: args.collectionConfig, i18n });
|
dispatchOptions({ type: 'UPDATE', doc: args.doc, collection: args.collectionConfig, i18n, config });
|
||||||
}, [i18n]);
|
}, [i18n, config]);
|
||||||
|
|
||||||
const classes = [
|
const classes = [
|
||||||
'field-type',
|
'field-type',
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Option, Action, OptionGroup } from './types';
|
import { Option, Action, OptionGroup } from './types';
|
||||||
import { getTranslation } from '../../../../../utilities/getTranslation';
|
import { getTranslation } from '../../../../../utilities/getTranslation';
|
||||||
|
import { formatUseAsTitle } from '../../../../hooks/useTitle';
|
||||||
|
|
||||||
const reduceToIDs = (options) => options.reduce((ids, option) => {
|
const reduceToIDs = (options) => options.reduce((ids, option) => {
|
||||||
if (option.options) {
|
if (option.options) {
|
||||||
@@ -30,15 +31,22 @@ const optionsReducer = (state: OptionGroup[], action: Action): OptionGroup[] =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'UPDATE': {
|
case 'UPDATE': {
|
||||||
const { collection, doc, i18n } = action;
|
const { collection, doc, i18n, config } = action;
|
||||||
const relation = collection.slug;
|
const relation = collection.slug;
|
||||||
const newOptions = [...state];
|
const newOptions = [...state];
|
||||||
const labelKey = collection.admin.useAsTitle || 'id';
|
|
||||||
|
const docTitle = formatUseAsTitle({
|
||||||
|
doc,
|
||||||
|
collection,
|
||||||
|
i18n,
|
||||||
|
config,
|
||||||
|
});
|
||||||
|
|
||||||
const foundOptionGroup = newOptions.find((optionGroup) => optionGroup.label === collection.labels.plural);
|
const foundOptionGroup = newOptions.find((optionGroup) => optionGroup.label === collection.labels.plural);
|
||||||
const foundOption = foundOptionGroup?.options?.find((option) => option.value === doc.id);
|
const foundOption = foundOptionGroup?.options?.find((option) => option.value === doc.id);
|
||||||
|
|
||||||
if (foundOption) {
|
if (foundOption) {
|
||||||
foundOption.label = doc[labelKey] || `${i18n.t('general:untitled')} - ID: ${doc.id}`;
|
foundOption.label = docTitle || `${i18n.t('general:untitled')} - ID: ${doc.id}`;
|
||||||
foundOption.relationTo = relation;
|
foundOption.relationTo = relation;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,9 +54,8 @@ const optionsReducer = (state: OptionGroup[], action: Action): OptionGroup[] =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'ADD': {
|
case 'ADD': {
|
||||||
const { collection, docs, sort, ids = [], i18n } = action;
|
const { collection, docs, sort, ids = [], i18n, config } = action;
|
||||||
const relation = collection.slug;
|
const relation = collection.slug;
|
||||||
const labelKey = collection.admin.useAsTitle || 'id';
|
|
||||||
const loadedIDs = reduceToIDs(state);
|
const loadedIDs = reduceToIDs(state);
|
||||||
const newOptions = [...state];
|
const newOptions = [...state];
|
||||||
const optionsToAddTo = newOptions.find((optionGroup) => optionGroup.label === collection.labels.plural);
|
const optionsToAddTo = newOptions.find((optionGroup) => optionGroup.label === collection.labels.plural);
|
||||||
@@ -57,10 +64,17 @@ const optionsReducer = (state: OptionGroup[], action: Action): OptionGroup[] =>
|
|||||||
if (loadedIDs.indexOf(doc.id) === -1) {
|
if (loadedIDs.indexOf(doc.id) === -1) {
|
||||||
loadedIDs.push(doc.id);
|
loadedIDs.push(doc.id);
|
||||||
|
|
||||||
|
const docTitle = formatUseAsTitle({
|
||||||
|
doc,
|
||||||
|
collection,
|
||||||
|
i18n,
|
||||||
|
config,
|
||||||
|
});
|
||||||
|
|
||||||
return [
|
return [
|
||||||
...docSubOptions,
|
...docSubOptions,
|
||||||
{
|
{
|
||||||
label: doc[labelKey] || `${i18n.t('general:untitled')} - ID: ${doc.id}`,
|
label: docTitle || `${i18n.t('general:untitled')} - ID: ${doc.id}`,
|
||||||
relationTo: relation,
|
relationTo: relation,
|
||||||
value: doc.id,
|
value: doc.id,
|
||||||
},
|
},
|
||||||
@@ -74,7 +88,7 @@ const optionsReducer = (state: OptionGroup[], action: Action): OptionGroup[] =>
|
|||||||
if (!loadedIDs.includes(id)) {
|
if (!loadedIDs.includes(id)) {
|
||||||
newSubOptions.push({
|
newSubOptions.push({
|
||||||
relationTo: relation,
|
relationTo: relation,
|
||||||
label: labelKey === 'id' ? id : `${i18n.t('general:untitled')} - ID: ${id}`,
|
label: `${i18n.t('general:untitled')} - ID: ${id}`,
|
||||||
value: id,
|
value: id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import i18n from 'i18next';
|
|||||||
import { SanitizedCollectionConfig } from '../../../../../collections/config/types';
|
import { SanitizedCollectionConfig } from '../../../../../collections/config/types';
|
||||||
import { RelationshipField } from '../../../../../fields/config/types';
|
import { RelationshipField } from '../../../../../fields/config/types';
|
||||||
import { Where } from '../../../../../types';
|
import { Where } from '../../../../../types';
|
||||||
|
import { SanitizedConfig } from '../../../../../config/types';
|
||||||
|
|
||||||
export type Props = Omit<RelationshipField, 'type'> & {
|
export type Props = Omit<RelationshipField, 'type'> & {
|
||||||
path?: string
|
path?: string
|
||||||
@@ -35,6 +36,7 @@ type UPDATE = {
|
|||||||
doc: any
|
doc: any
|
||||||
collection: SanitizedCollectionConfig
|
collection: SanitizedCollectionConfig
|
||||||
i18n: typeof i18n
|
i18n: typeof i18n
|
||||||
|
config: SanitizedConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
type ADD = {
|
type ADD = {
|
||||||
@@ -42,8 +44,9 @@ type ADD = {
|
|||||||
docs: any[]
|
docs: any[]
|
||||||
collection: SanitizedCollectionConfig
|
collection: SanitizedCollectionConfig
|
||||||
sort?: boolean
|
sort?: boolean
|
||||||
ids?: unknown[]
|
ids?: (string | number)[]
|
||||||
i18n: typeof i18n
|
i18n: typeof i18n
|
||||||
|
config: SanitizedConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Action = CLEAR | ADD | UPDATE
|
export type Action = CLEAR | ADD | UPDATE
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
.field-type.textarea {
|
.field-type.textarea {
|
||||||
position: relative;
|
position: relative;
|
||||||
margin-bottom: $baseline;
|
margin-bottom: $baseline;
|
||||||
|
padding-bottom: base(2.5);
|
||||||
|
|
||||||
.textarea-outer {
|
.textarea-outer {
|
||||||
@include formInput();
|
@include formInput();
|
||||||
@@ -21,8 +22,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&.error {
|
&.error {
|
||||||
textarea {
|
.textarea-outer {
|
||||||
background-color: var(--theme-error-200);
|
background: var(--theme-error-200);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,4 +83,8 @@
|
|||||||
content: attr(data-after);
|
content: attr(data-after);
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@include mid-break {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
11
src/admin/components/icons/Line/index.scss
Normal file
11
src/admin/components/icons/Line/index.scss
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
@import '../../../scss/styles';
|
||||||
|
|
||||||
|
.icon--line {
|
||||||
|
width: $baseline;
|
||||||
|
height: $baseline;
|
||||||
|
|
||||||
|
.stroke {
|
||||||
|
stroke: var(--theme-elevation-800);
|
||||||
|
stroke-width: $style-stroke-width;
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/admin/components/icons/Line/index.tsx
Normal file
21
src/admin/components/icons/Line/index.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import './index.scss';
|
||||||
|
|
||||||
|
const Line: React.FC = () => (
|
||||||
|
<svg
|
||||||
|
className="icon icon--line"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 25 25"
|
||||||
|
>
|
||||||
|
<line
|
||||||
|
x1="8.05164"
|
||||||
|
y1="12.594"
|
||||||
|
x2="16.468"
|
||||||
|
y2="12.594"
|
||||||
|
className="stroke"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default Line;
|
||||||
@@ -5,7 +5,7 @@ import qs from 'qs';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useConfig } from '../Config';
|
import { useConfig } from '../Config';
|
||||||
import { PaginatedDocs } from '../../../../mongoose/types';
|
import { PaginatedDocs } from '../../../../mongoose/types';
|
||||||
import { ContextType, DocumentPermissions, EntityType, Props, Version } from './types';
|
import { ContextType, DocumentPermissions, Props, Version } from './types';
|
||||||
import { TypeWithID } from '../../../../globals/config/types';
|
import { TypeWithID } from '../../../../globals/config/types';
|
||||||
import { TypeWithTimestamps } from '../../../../collections/config/types';
|
import { TypeWithTimestamps } from '../../../../collections/config/types';
|
||||||
import { Where } from '../../../../types';
|
import { Where } from '../../../../types';
|
||||||
@@ -15,6 +15,8 @@ import { useAuth } from '../Auth';
|
|||||||
|
|
||||||
const Context = createContext({} as ContextType);
|
const Context = createContext({} as ContextType);
|
||||||
|
|
||||||
|
export const useDocumentInfo = (): ContextType => useContext(Context);
|
||||||
|
|
||||||
export const DocumentInfoProvider: React.FC<Props> = ({
|
export const DocumentInfoProvider: React.FC<Props> = ({
|
||||||
children,
|
children,
|
||||||
global,
|
global,
|
||||||
@@ -32,7 +34,7 @@ export const DocumentInfoProvider: React.FC<Props> = ({
|
|||||||
|
|
||||||
const baseURL = `${serverURL}${api}`;
|
const baseURL = `${serverURL}${api}`;
|
||||||
let slug: string;
|
let slug: string;
|
||||||
let type: EntityType;
|
let type: 'global' | 'collection';
|
||||||
let pluralType: 'globals' | 'collections';
|
let pluralType: 'globals' | 'collections';
|
||||||
let preferencesKey: string;
|
let preferencesKey: string;
|
||||||
|
|
||||||
@@ -233,5 +235,3 @@ export const DocumentInfoProvider: React.FC<Props> = ({
|
|||||||
</Context.Provider>
|
</Context.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useDocumentInfo = (): ContextType => useContext(Context);
|
|
||||||
|
|||||||
@@ -9,13 +9,9 @@ export type Version = TypeWithVersion<any>
|
|||||||
|
|
||||||
export type DocumentPermissions = null | GlobalPermission | CollectionPermission
|
export type DocumentPermissions = null | GlobalPermission | CollectionPermission
|
||||||
|
|
||||||
export type EntityType = 'global' | 'collection'
|
|
||||||
|
|
||||||
export type ContextType = {
|
export type ContextType = {
|
||||||
collection?: SanitizedCollectionConfig
|
collection?: SanitizedCollectionConfig
|
||||||
global?: SanitizedGlobalConfig
|
global?: SanitizedGlobalConfig
|
||||||
type: EntityType
|
|
||||||
/** Slug of the collection or global */
|
|
||||||
slug?: string
|
slug?: string
|
||||||
id?: string | number
|
id?: string | number
|
||||||
preferencesKey?: string
|
preferencesKey?: string
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ const DefaultAccount: React.FC<Props> = (props) => {
|
|||||||
<h1>
|
<h1>
|
||||||
<RenderTitle
|
<RenderTitle
|
||||||
data={data}
|
data={data}
|
||||||
collection={collection.slug}
|
collection={collection}
|
||||||
useAsTitle={useAsTitle}
|
useAsTitle={useAsTitle}
|
||||||
fallback={`[${t('general:untitled')}]`}
|
fallback={`[${t('general:untitled')}]`}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
line-height: 1.25;
|
||||||
}
|
}
|
||||||
|
|
||||||
margin-bottom: base(1);
|
margin-bottom: base(1);
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ const baseClass = 'select-diff';
|
|||||||
|
|
||||||
const getOptionsToRender = (value: string, options: SelectField['options'], hasMany: boolean): string | OptionObject | (OptionObject | string)[] => {
|
const getOptionsToRender = (value: string, options: SelectField['options'], hasMany: boolean): string | OptionObject | (OptionObject | string)[] => {
|
||||||
if (hasMany && Array.isArray(value)) {
|
if (hasMany && Array.isArray(value)) {
|
||||||
return value.map((val) => options.find((option) => (typeof option === 'string' ? option : option.value) === val) || val);
|
return value.map((val) => options.find((option) => (typeof option === 'string' ? option : option.value) === val) || String(val));
|
||||||
}
|
}
|
||||||
return options.find((option) => (typeof option === 'string' ? option : option.value) === value) || value;
|
return options.find((option) => (typeof option === 'string' ? option : option.value) === value) || String(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getTranslatedOptions = (options: string | OptionObject | (OptionObject | string)[], i18n: Ii18n): string => {
|
const getTranslatedOptions = (options: string | OptionObject | (OptionObject | string)[], i18n: Ii18n): string => {
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ const Tabs: React.FC<Props & { field: TabsField }> = ({
|
|||||||
return (
|
return (
|
||||||
<Nested
|
<Nested
|
||||||
key={i}
|
key={i}
|
||||||
version={version[tab.name]}
|
version={version?.[tab.name]}
|
||||||
comparison={comparison[tab.name]}
|
comparison={comparison?.[tab.name]}
|
||||||
permissions={permissions}
|
permissions={permissions}
|
||||||
field={tab}
|
field={tab}
|
||||||
locales={locales}
|
locales={locales}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useRouteMatch } from 'react-router-dom';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useConfig } from '../../utilities/Config';
|
import { useConfig } from '../../utilities/Config';
|
||||||
import { useAuth } from '../../utilities/Auth';
|
import { useAuth } from '../../utilities/Auth';
|
||||||
|
import { useDocumentInfo } from '../../utilities/DocumentInfo';
|
||||||
import usePayloadAPI from '../../../hooks/usePayloadAPI';
|
import usePayloadAPI from '../../../hooks/usePayloadAPI';
|
||||||
import Eyebrow from '../../elements/Eyebrow';
|
import Eyebrow from '../../elements/Eyebrow';
|
||||||
import { useStepNav } from '../../elements/StepNav';
|
import { useStepNav } from '../../elements/StepNav';
|
||||||
@@ -36,6 +37,7 @@ const VersionView: React.FC<Props> = ({ collection, global }) => {
|
|||||||
const { permissions } = useAuth();
|
const { permissions } = useAuth();
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const { t, i18n } = useTranslation('version');
|
const { t, i18n } = useTranslation('version');
|
||||||
|
const { docPermissions } = useDocumentInfo();
|
||||||
|
|
||||||
let originalDocFetchURL: string;
|
let originalDocFetchURL: string;
|
||||||
let versionFetchURL: string;
|
let versionFetchURL: string;
|
||||||
@@ -163,6 +165,8 @@ const VersionView: React.FC<Props> = ({ collection, global }) => {
|
|||||||
comparison = publishedDoc;
|
comparison = publishedDoc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const canUpdate = docPermissions?.update?.permission;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
<div className={baseClass}>
|
<div className={baseClass}>
|
||||||
@@ -179,6 +183,7 @@ const VersionView: React.FC<Props> = ({ collection, global }) => {
|
|||||||
<h2>
|
<h2>
|
||||||
{formattedCreatedAt}
|
{formattedCreatedAt}
|
||||||
</h2>
|
</h2>
|
||||||
|
{canUpdate && (
|
||||||
<Restore
|
<Restore
|
||||||
className={`${baseClass}__restore`}
|
className={`${baseClass}__restore`}
|
||||||
collection={collection}
|
collection={collection}
|
||||||
@@ -187,6 +192,7 @@ const VersionView: React.FC<Props> = ({ collection, global }) => {
|
|||||||
versionID={versionID}
|
versionID={versionID}
|
||||||
versionDate={formattedCreatedAt}
|
versionDate={formattedCreatedAt}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</header>
|
</header>
|
||||||
<div className={`${baseClass}__controls`}>
|
<div className={`${baseClass}__controls`}>
|
||||||
<CompareVersion
|
<CompareVersion
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ const DefaultEditView: React.FC<Props> = (props) => {
|
|||||||
<h1>
|
<h1>
|
||||||
<RenderTitle
|
<RenderTitle
|
||||||
data={data}
|
data={data}
|
||||||
collection={collection.slug}
|
collection={collection}
|
||||||
useAsTitle={useAsTitle}
|
useAsTitle={useAsTitle}
|
||||||
fallback={`[${t('untitled')}]`}
|
fallback={`[${t('untitled')}]`}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export const SetStepNav: React.FC<{
|
|||||||
const { t, i18n } = useTranslation('general');
|
const { t, i18n } = useTranslation('general');
|
||||||
const { routes: { admin } } = useConfig();
|
const { routes: { admin } } = useConfig();
|
||||||
|
|
||||||
const title = useTitle(useAsTitle, collection.slug);
|
const title = useTitle(collection);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const nav: StepNavItem[] = [{
|
const nav: StepNavItem[] = [{
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, {
|
|||||||
useState, useRef, useEffect, useCallback,
|
useState, useRef, useEffect, useCallback,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useDocumentInfo } from '../../../../utilities/DocumentInfo';
|
||||||
import useField from '../../../../forms/useField';
|
import useField from '../../../../forms/useField';
|
||||||
import Button from '../../../../elements/Button';
|
import Button from '../../../../elements/Button';
|
||||||
import FileDetails from '../../../../elements/FileDetails';
|
import FileDetails from '../../../../elements/FileDetails';
|
||||||
@@ -40,6 +41,7 @@ const Upload: React.FC<Props> = (props) => {
|
|||||||
const [replacingFile, setReplacingFile] = useState(false);
|
const [replacingFile, setReplacingFile] = useState(false);
|
||||||
const { t } = useTranslation('upload');
|
const { t } = useTranslation('upload');
|
||||||
const [doc, setDoc] = useState(reduceFieldsToValues(internalState || {}, true));
|
const [doc, setDoc] = useState(reduceFieldsToValues(internalState || {}, true));
|
||||||
|
const { docPermissions } = useDocumentInfo();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
value,
|
value,
|
||||||
@@ -129,6 +131,8 @@ const Upload: React.FC<Props> = (props) => {
|
|||||||
'field-type',
|
'field-type',
|
||||||
].filter(Boolean).join(' ');
|
].filter(Boolean).join(' ');
|
||||||
|
|
||||||
|
const canRemoveUpload = docPermissions?.update?.permission && 'delete' in docPermissions && docPermissions?.delete?.permission;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classes}>
|
<div className={classes}>
|
||||||
<Error
|
<Error
|
||||||
@@ -139,10 +143,10 @@ const Upload: React.FC<Props> = (props) => {
|
|||||||
<FileDetails
|
<FileDetails
|
||||||
doc={doc}
|
doc={doc}
|
||||||
collection={collection}
|
collection={collection}
|
||||||
handleRemove={() => {
|
handleRemove={canRemoveUpload ? () => {
|
||||||
setReplacingFile(true);
|
setReplacingFile(true);
|
||||||
setValue(null);
|
setValue(null);
|
||||||
}}
|
} : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{(!doc.filename || replacingFile) && (
|
{(!doc.filename || replacingFile) && (
|
||||||
|
|||||||
@@ -212,9 +212,13 @@
|
|||||||
padding-right: var(--gutter-h);
|
padding-right: var(--gutter-h);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__sidebar-wrap {
|
||||||
|
border-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
&__sidebar-fields {
|
&__sidebar-fields {
|
||||||
margin-bottom: base(1);
|
margin-bottom: 0;
|
||||||
padding-top: base(1);
|
padding-top: 0;
|
||||||
padding-right: var(--gutter-h);
|
padding-right: var(--gutter-h);
|
||||||
|
|
||||||
.preview-btn {
|
.preview-btn {
|
||||||
@@ -234,6 +238,7 @@
|
|||||||
|
|
||||||
&__sidebar {
|
&__sidebar {
|
||||||
padding-bottom: base(3.5);
|
padding-bottom: base(3.5);
|
||||||
|
overflow: visible;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
@import '../../../../../../../scss/styles.scss';
|
||||||
|
|
||||||
|
.file {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
margin: base(-.25) 0;
|
||||||
|
|
||||||
|
&__thumbnail {
|
||||||
|
display: inline-block;
|
||||||
|
max-width: base(3);
|
||||||
|
height: base(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__filename {
|
||||||
|
align-self: center;
|
||||||
|
margin-left: base(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Thumbnail from '../../../../../../elements/Thumbnail';
|
||||||
|
|
||||||
|
import './index.scss';
|
||||||
|
|
||||||
|
const baseClass = 'file';
|
||||||
|
|
||||||
|
const File = ({ rowData, data, collection }) => {
|
||||||
|
return (
|
||||||
|
<div className={baseClass}>
|
||||||
|
<Thumbnail
|
||||||
|
size="small"
|
||||||
|
className={`${baseClass}__thumbnail`}
|
||||||
|
doc={{
|
||||||
|
...rowData,
|
||||||
|
filename: data,
|
||||||
|
}}
|
||||||
|
collection={collection}
|
||||||
|
/>
|
||||||
|
<span className={`${baseClass}__filename`}>{ String(data) }</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default File;
|
||||||
@@ -4,6 +4,8 @@ import { useConfig } from '../../../../../../utilities/Config';
|
|||||||
import useIntersect from '../../../../../../../hooks/useIntersect';
|
import useIntersect from '../../../../../../../hooks/useIntersect';
|
||||||
import { useListRelationships } from '../../../RelationshipProvider';
|
import { useListRelationships } from '../../../RelationshipProvider';
|
||||||
import { getTranslation } from '../../../../../../../../utilities/getTranslation';
|
import { getTranslation } from '../../../../../../../../utilities/getTranslation';
|
||||||
|
import { formatUseAsTitle } from '../../../../../../../hooks/useTitle';
|
||||||
|
import { Props as DefaultCellProps } from '../../types';
|
||||||
|
|
||||||
import './index.scss';
|
import './index.scss';
|
||||||
|
|
||||||
@@ -11,9 +13,13 @@ type Value = { relationTo: string, value: number | string };
|
|||||||
const baseClass = 'relationship-cell';
|
const baseClass = 'relationship-cell';
|
||||||
const totalToShow = 3;
|
const totalToShow = 3;
|
||||||
|
|
||||||
const RelationshipCell = (props) => {
|
const RelationshipCell: React.FC<{
|
||||||
|
field: DefaultCellProps['field']
|
||||||
|
data: DefaultCellProps['cellData']
|
||||||
|
}> = (props) => {
|
||||||
const { field, data: cellData } = props;
|
const { field, data: cellData } = props;
|
||||||
const { collections, routes } = useConfig();
|
const config = useConfig();
|
||||||
|
const { collections, routes } = config;
|
||||||
const [intersectionRef, entry] = useIntersect();
|
const [intersectionRef, entry] = useIntersect();
|
||||||
const [values, setValues] = useState<Value[]>([]);
|
const [values, setValues] = useState<Value[]>([]);
|
||||||
const { getRelationships, documents } = useListRelationships();
|
const { getRelationships, documents } = useListRelationships();
|
||||||
@@ -31,7 +37,7 @@ const RelationshipCell = (props) => {
|
|||||||
if (typeof cell === 'object' && 'relationTo' in cell && 'value' in cell) {
|
if (typeof cell === 'object' && 'relationTo' in cell && 'value' in cell) {
|
||||||
formattedValues.push(cell);
|
formattedValues.push(cell);
|
||||||
}
|
}
|
||||||
if ((typeof cell === 'number' || typeof cell === 'string') && typeof field.relationTo === 'string') {
|
if ((typeof cell === 'number' || typeof cell === 'string') && 'relationTo' in field && typeof field.relationTo === 'string') {
|
||||||
formattedValues.push({
|
formattedValues.push({
|
||||||
value: cell,
|
value: cell,
|
||||||
relationTo: field.relationTo,
|
relationTo: field.relationTo,
|
||||||
@@ -52,13 +58,19 @@ const RelationshipCell = (props) => {
|
|||||||
{values.map(({ relationTo, value }, i) => {
|
{values.map(({ relationTo, value }, i) => {
|
||||||
const document = documents[relationTo][value];
|
const document = documents[relationTo][value];
|
||||||
const relatedCollection = collections.find(({ slug }) => slug === relationTo);
|
const relatedCollection = collections.find(({ slug }) => slug === relationTo);
|
||||||
const label = document?.[relatedCollection.admin.useAsTitle] ? document[relatedCollection.admin.useAsTitle] : `${t('untitled')} - ID: ${value}`;
|
|
||||||
|
const label = formatUseAsTitle({
|
||||||
|
doc: document === false ? null : document,
|
||||||
|
collection: relatedCollection,
|
||||||
|
i18n,
|
||||||
|
config,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={i}>
|
<React.Fragment key={i}>
|
||||||
{document === false && `${t('untitled')} - ID: ${value}`}
|
{document === false && `${t('untitled')} - ID: ${value}`}
|
||||||
{document === null && `${t('loading')}...`}
|
{document === null && `${t('loading')}...`}
|
||||||
{document && label}
|
{document && (label || `${t('untitled')} - ID: ${value}`)}
|
||||||
{values.length > i + 1 && ', '}
|
{values.length > i + 1 && ', '}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
@@ -67,7 +79,7 @@ const RelationshipCell = (props) => {
|
|||||||
Array.isArray(cellData) && cellData.length > totalToShow
|
Array.isArray(cellData) && cellData.length > totalToShow
|
||||||
&& t('fields:itemsAndMore', { items: '', count: cellData.length - totalToShow })
|
&& t('fields:itemsAndMore', { items: '', count: cellData.length - totalToShow })
|
||||||
}
|
}
|
||||||
{values.length === 0 && t('noLabel', { label: getTranslation(field.label, i18n) })}
|
{values.length === 0 && t('noLabel', { label: getTranslation(field?.label || '', i18n) })}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import relationship from './Relationship';
|
|||||||
import richText from './Richtext';
|
import richText from './Richtext';
|
||||||
import select from './Select';
|
import select from './Select';
|
||||||
import textarea from './Textarea';
|
import textarea from './Textarea';
|
||||||
|
import File from './File';
|
||||||
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@@ -23,4 +24,5 @@ export default {
|
|||||||
radio: select,
|
radio: select,
|
||||||
textarea,
|
textarea,
|
||||||
upload: relationship,
|
upload: relationship,
|
||||||
|
File,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,14 +6,17 @@ import RenderCustomComponent from '../../../../utilities/RenderCustomComponent';
|
|||||||
import cellComponents from './field-types';
|
import cellComponents from './field-types';
|
||||||
import { Props } from './types';
|
import { Props } from './types';
|
||||||
import { getTranslation } from '../../../../../../utilities/getTranslation';
|
import { getTranslation } from '../../../../../../utilities/getTranslation';
|
||||||
|
import { fieldAffectsData } from '../../../../../../fields/config/types';
|
||||||
|
|
||||||
const DefaultCell: React.FC<Props> = (props) => {
|
const DefaultCell: React.FC<Props> = (props) => {
|
||||||
const {
|
const {
|
||||||
field,
|
field,
|
||||||
|
collection,
|
||||||
collection: {
|
collection: {
|
||||||
slug,
|
slug,
|
||||||
},
|
},
|
||||||
cellData,
|
cellData,
|
||||||
|
rowData,
|
||||||
rowData: {
|
rowData: {
|
||||||
id,
|
id,
|
||||||
} = {},
|
} = {},
|
||||||
@@ -49,9 +52,12 @@ const DefaultCell: React.FC<Props> = (props) => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const CellComponent = cellData && cellComponents[field.type];
|
let CellComponent = cellData && cellComponents[field.type];
|
||||||
|
|
||||||
if (!CellComponent) {
|
if (!CellComponent) {
|
||||||
|
if (collection.upload && fieldAffectsData(field) && field.name === 'filename') {
|
||||||
|
CellComponent = cellComponents.File;
|
||||||
|
} else {
|
||||||
return (
|
return (
|
||||||
<WrapElement {...wrapElementProps}>
|
<WrapElement {...wrapElementProps}>
|
||||||
{((cellData === '' || typeof cellData === 'undefined') && 'label' in field) && t('noLabel', { label: getTranslation(typeof field.label === 'function' ? 'data' : field.label || 'data', i18n) })}
|
{((cellData === '' || typeof cellData === 'undefined') && 'label' in field) && t('noLabel', { label: getTranslation(typeof field.label === 'function' ? 'data' : field.label || 'data', i18n) })}
|
||||||
@@ -61,12 +67,15 @@ const DefaultCell: React.FC<Props> = (props) => {
|
|||||||
</WrapElement>
|
</WrapElement>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WrapElement {...wrapElementProps}>
|
<WrapElement {...wrapElementProps}>
|
||||||
<CellComponent
|
<CellComponent
|
||||||
field={field}
|
field={field}
|
||||||
data={cellData}
|
data={cellData}
|
||||||
|
collection={collection}
|
||||||
|
rowData={rowData}
|
||||||
/>
|
/>
|
||||||
</WrapElement>
|
</WrapElement>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import React, { Fragment } from 'react';
|
import React, { Fragment } from 'react';
|
||||||
import { useHistory } from 'react-router-dom';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useConfig } from '../../../utilities/Config';
|
import { useWindowInfo } from '@faceless-ui/window-info';
|
||||||
import UploadGallery from '../../../elements/UploadGallery';
|
|
||||||
import Eyebrow from '../../../elements/Eyebrow';
|
import Eyebrow from '../../../elements/Eyebrow';
|
||||||
import Paginator from '../../../elements/Paginator';
|
import Paginator from '../../../elements/Paginator';
|
||||||
import ListControls from '../../../elements/ListControls';
|
import ListControls from '../../../elements/ListControls';
|
||||||
|
import ListSelection from '../../../elements/ListSelection';
|
||||||
import Pill from '../../../elements/Pill';
|
import Pill from '../../../elements/Pill';
|
||||||
import Button from '../../../elements/Button';
|
import Button from '../../../elements/Button';
|
||||||
import { Table } from '../../../elements/Table';
|
import { Table } from '../../../elements/Table';
|
||||||
@@ -17,6 +16,11 @@ import { Gutter } from '../../../elements/Gutter';
|
|||||||
import { RelationshipProvider } from './RelationshipProvider';
|
import { RelationshipProvider } from './RelationshipProvider';
|
||||||
import { getTranslation } from '../../../../../utilities/getTranslation';
|
import { getTranslation } from '../../../../../utilities/getTranslation';
|
||||||
import { StaggeredShimmers } from '../../../elements/ShimmerEffect';
|
import { StaggeredShimmers } from '../../../elements/ShimmerEffect';
|
||||||
|
import { SelectionProvider } from './SelectionProvider';
|
||||||
|
import EditMany from '../../../elements/EditMany';
|
||||||
|
import DeleteMany from '../../../elements/DeleteMany';
|
||||||
|
import PublishMany from '../../../elements/PublishMany';
|
||||||
|
import UnpublishMany from '../../../elements/UnpublishMany';
|
||||||
|
|
||||||
import './index.scss';
|
import './index.scss';
|
||||||
|
|
||||||
@@ -26,8 +30,6 @@ const DefaultList: React.FC<Props> = (props) => {
|
|||||||
const {
|
const {
|
||||||
collection,
|
collection,
|
||||||
collection: {
|
collection: {
|
||||||
upload,
|
|
||||||
slug,
|
|
||||||
labels: {
|
labels: {
|
||||||
singular: singularLabel,
|
singular: singularLabel,
|
||||||
plural: pluralLabel,
|
plural: pluralLabel,
|
||||||
@@ -42,17 +44,15 @@ const DefaultList: React.FC<Props> = (props) => {
|
|||||||
hasCreatePermission,
|
hasCreatePermission,
|
||||||
disableEyebrow,
|
disableEyebrow,
|
||||||
modifySearchParams,
|
modifySearchParams,
|
||||||
disableCardLink,
|
|
||||||
onCardClick,
|
|
||||||
handleSortChange,
|
handleSortChange,
|
||||||
handleWhereChange,
|
handleWhereChange,
|
||||||
handlePageChange,
|
handlePageChange,
|
||||||
handlePerPageChange,
|
handlePerPageChange,
|
||||||
customHeader,
|
customHeader,
|
||||||
|
resetParams,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const { routes: { admin } } = useConfig();
|
const { breakpoints: { s: smallBreak } } = useWindowInfo();
|
||||||
const history = useHistory();
|
|
||||||
const { t, i18n } = useTranslation('general');
|
const { t, i18n } = useTranslation('general');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -60,6 +60,10 @@ const DefaultList: React.FC<Props> = (props) => {
|
|||||||
<Meta
|
<Meta
|
||||||
title={getTranslation(collection.labels.plural, i18n)}
|
title={getTranslation(collection.labels.plural, i18n)}
|
||||||
/>
|
/>
|
||||||
|
<SelectionProvider
|
||||||
|
docs={data.docs}
|
||||||
|
totalDocs={data.totalDocs}
|
||||||
|
>
|
||||||
{!disableEyebrow && (
|
{!disableEyebrow && (
|
||||||
<Eyebrow />
|
<Eyebrow />
|
||||||
)}
|
)}
|
||||||
@@ -76,6 +80,11 @@ const DefaultList: React.FC<Props> = (props) => {
|
|||||||
{t('createNew')}
|
{t('createNew')}
|
||||||
</Pill>
|
</Pill>
|
||||||
)}
|
)}
|
||||||
|
{!smallBreak && (
|
||||||
|
<ListSelection
|
||||||
|
label={getTranslation(collection.labels.plural, i18n)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{description && (
|
{description && (
|
||||||
<div className={`${baseClass}__sub-header`}>
|
<div className={`${baseClass}__sub-header`}>
|
||||||
<ViewDescription description={description} />
|
<ViewDescription description={description} />
|
||||||
@@ -86,41 +95,22 @@ const DefaultList: React.FC<Props> = (props) => {
|
|||||||
</header>
|
</header>
|
||||||
<ListControls
|
<ListControls
|
||||||
collection={collection}
|
collection={collection}
|
||||||
enableColumns={Boolean(!upload)}
|
|
||||||
enableSort={Boolean(upload)}
|
|
||||||
modifySearchQuery={modifySearchParams}
|
modifySearchQuery={modifySearchParams}
|
||||||
handleSortChange={handleSortChange}
|
handleSortChange={handleSortChange}
|
||||||
handleWhereChange={handleWhereChange}
|
handleWhereChange={handleWhereChange}
|
||||||
|
resetParams={resetParams}
|
||||||
/>
|
/>
|
||||||
{!data.docs && (
|
{!data.docs && (
|
||||||
<StaggeredShimmers
|
<StaggeredShimmers
|
||||||
className={[
|
className={[`${baseClass}__shimmer`, `${baseClass}__shimmer--rows`].join(' ')}
|
||||||
`${baseClass}__shimmer`,
|
|
||||||
upload ? `${baseClass}__shimmer--uploads` : `${baseClass}__shimmer--rows`,
|
|
||||||
].filter(Boolean).join(' ')}
|
|
||||||
count={6}
|
count={6}
|
||||||
width={upload ? 'unset' : '100%'}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{(data.docs && data.docs.length > 0) && (
|
{(data.docs && data.docs.length > 0) && (
|
||||||
<React.Fragment>
|
|
||||||
{!upload && (
|
|
||||||
<RelationshipProvider>
|
<RelationshipProvider>
|
||||||
<Table data={data.docs} />
|
<Table data={data.docs} />
|
||||||
</RelationshipProvider>
|
</RelationshipProvider>
|
||||||
)}
|
)}
|
||||||
{upload && (
|
|
||||||
<UploadGallery
|
|
||||||
docs={data.docs}
|
|
||||||
collection={collection}
|
|
||||||
onCardClick={(doc) => {
|
|
||||||
if (typeof onCardClick === 'function') onCardClick(doc);
|
|
||||||
if (!disableCardLink) history.push(`${admin}/collections/${slug}/${doc.id}`);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</React.Fragment>
|
|
||||||
)}
|
|
||||||
{data.docs && data.docs.length === 0 && (
|
{data.docs && data.docs.length === 0 && (
|
||||||
<div className={`${baseClass}__no-results`}>
|
<div className={`${baseClass}__no-results`}>
|
||||||
<p>
|
<p>
|
||||||
@@ -167,10 +157,38 @@ const DefaultList: React.FC<Props> = (props) => {
|
|||||||
handleChange={handlePerPageChange}
|
handleChange={handlePerPageChange}
|
||||||
resetPage={data.totalDocs <= data.pagingCounter}
|
resetPage={data.totalDocs <= data.pagingCounter}
|
||||||
/>
|
/>
|
||||||
|
<div className={`${baseClass}__list-selection`}>
|
||||||
|
{smallBreak && (
|
||||||
|
<Fragment>
|
||||||
|
<ListSelection
|
||||||
|
label={getTranslation(collection.labels.plural, i18n)}
|
||||||
|
/>
|
||||||
|
<div className={`${baseClass}__list-selection-actions`}>
|
||||||
|
<EditMany
|
||||||
|
collection={collection}
|
||||||
|
resetParams={resetParams}
|
||||||
|
/>
|
||||||
|
<PublishMany
|
||||||
|
collection={collection}
|
||||||
|
resetParams={resetParams}
|
||||||
|
/>
|
||||||
|
<UnpublishMany
|
||||||
|
collection={collection}
|
||||||
|
resetParams={resetParams}
|
||||||
|
/>
|
||||||
|
<DeleteMany
|
||||||
|
collection={collection}
|
||||||
|
resetParams={resetParams}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Fragment>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</Fragment>
|
</Fragment>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Gutter>
|
</Gutter>
|
||||||
|
</SelectionProvider>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
@import '../../../../../scss/styles.scss';
|
||||||
|
|
||||||
|
.select-all {
|
||||||
|
button {
|
||||||
|
@extend %btn-reset;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:focus,
|
||||||
|
&:active {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
svg {
|
||||||
|
opacity: .2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__input {
|
||||||
|
@include formInput;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 0;
|
||||||
|
position: relative;
|
||||||
|
width: $baseline;
|
||||||
|
height: $baseline;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { SelectAllStatus, useSelection } from '../SelectionProvider';
|
||||||
|
import Check from '../../../../icons/Check';
|
||||||
|
import Line from '../../../../icons/Line';
|
||||||
|
|
||||||
|
import './index.scss';
|
||||||
|
|
||||||
|
const baseClass = 'select-all';
|
||||||
|
|
||||||
|
const SelectAll: React.FC = () => {
|
||||||
|
const { selectAll, toggleAll } = useSelection();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={baseClass}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleAll()}
|
||||||
|
>
|
||||||
|
<span className={`${baseClass}__input`}>
|
||||||
|
{ (selectAll === SelectAllStatus.AllInPage || selectAll === SelectAllStatus.AllAvailable) && (
|
||||||
|
<Check />
|
||||||
|
)}
|
||||||
|
{ selectAll === SelectAllStatus.Some && (
|
||||||
|
<Line />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SelectAll;
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
@import '../../../../../scss/styles.scss';
|
||||||
|
|
||||||
|
.select-row {
|
||||||
|
button {
|
||||||
|
@extend %btn-reset;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:focus,
|
||||||
|
&:active {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
svg {
|
||||||
|
opacity: .2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__input {
|
||||||
|
@include formInput;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 0;
|
||||||
|
position: relative;
|
||||||
|
width: $baseline;
|
||||||
|
height: $baseline;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--checked {
|
||||||
|
button {
|
||||||
|
.select-row__input {
|
||||||
|
svg {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useSelection } from '../SelectionProvider';
|
||||||
|
import Check from '../../../../icons/Check';
|
||||||
|
|
||||||
|
import './index.scss';
|
||||||
|
|
||||||
|
const baseClass = 'select-row';
|
||||||
|
|
||||||
|
const SelectRow: React.FC<{ id: string | number }> = ({ id }) => {
|
||||||
|
const { selected, setSelection } = useSelection();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
baseClass,
|
||||||
|
(selected[id]) && `${baseClass}--checked`,
|
||||||
|
].filter(Boolean).join(' ')}
|
||||||
|
key={id}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelection(id)}
|
||||||
|
>
|
||||||
|
<span className={`${baseClass}__input`}>
|
||||||
|
<Check />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SelectRow;
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import React, {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import { useHistory } from 'react-router-dom';
|
||||||
|
import queryString from 'qs';
|
||||||
|
import { Where } from '../../../../../../types';
|
||||||
|
|
||||||
|
export enum SelectAllStatus {
|
||||||
|
AllAvailable = 'allAvailable',
|
||||||
|
AllInPage = 'allInPage',
|
||||||
|
Some = 'some',
|
||||||
|
None = 'none',
|
||||||
|
}
|
||||||
|
|
||||||
|
type SelectionContext = {
|
||||||
|
selected: Record<string | number, boolean>
|
||||||
|
setSelection: (id: string | number) => void
|
||||||
|
selectAll: SelectAllStatus
|
||||||
|
toggleAll: (allAvailable?: boolean) => void
|
||||||
|
totalDocs: number
|
||||||
|
count: number
|
||||||
|
getQueryParams: (additionalParams?: Where) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
const Context = createContext({} as SelectionContext);
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
children: React.ReactNode
|
||||||
|
docs: any[]
|
||||||
|
totalDocs: number
|
||||||
|
}
|
||||||
|
export const SelectionProvider: React.FC<Props> = ({ children, docs = [], totalDocs }) => {
|
||||||
|
const contextRef = useRef({} as SelectionContext);
|
||||||
|
|
||||||
|
const history = useHistory();
|
||||||
|
const [selected, setSelected] = useState<SelectionContext['selected']>({});
|
||||||
|
const [selectAll, setSelectAll] = useState<SelectAllStatus>(SelectAllStatus.None);
|
||||||
|
const [count, setCount] = useState(0);
|
||||||
|
|
||||||
|
const toggleAll = useCallback((allAvailable = false) => {
|
||||||
|
const rows = {};
|
||||||
|
if (allAvailable) {
|
||||||
|
setSelectAll(SelectAllStatus.AllAvailable);
|
||||||
|
docs.forEach(({ id }) => {
|
||||||
|
rows[id] = true;
|
||||||
|
});
|
||||||
|
} else if (selectAll === SelectAllStatus.AllAvailable || selectAll === SelectAllStatus.AllInPage) {
|
||||||
|
setSelectAll(SelectAllStatus.None);
|
||||||
|
docs.forEach(({ id }) => {
|
||||||
|
rows[id] = false;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
docs.forEach(({ id }) => {
|
||||||
|
rows[id] = selectAll !== SelectAllStatus.Some;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setSelected(rows);
|
||||||
|
}, [docs, selectAll]);
|
||||||
|
|
||||||
|
const setSelection = useCallback((id) => {
|
||||||
|
const isSelected = !selected[id];
|
||||||
|
const newSelected = {
|
||||||
|
...selected,
|
||||||
|
[id]: isSelected,
|
||||||
|
};
|
||||||
|
if (!isSelected) {
|
||||||
|
setSelectAll(SelectAllStatus.Some);
|
||||||
|
}
|
||||||
|
setSelected(newSelected);
|
||||||
|
}, [selected]);
|
||||||
|
|
||||||
|
const getQueryParams = useCallback((additionalParams?: Where): string => {
|
||||||
|
let where: Where;
|
||||||
|
if (selectAll === SelectAllStatus.AllAvailable) {
|
||||||
|
const params = queryString.parse(history.location.search, { ignoreQueryPrefix: true }).where as Where;
|
||||||
|
where = params || {
|
||||||
|
id: { not_equals: '' },
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
where = {
|
||||||
|
id: {
|
||||||
|
in: Object.keys(selected).filter((id) => selected[id]).map((id) => id),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (additionalParams) {
|
||||||
|
where = {
|
||||||
|
and: [
|
||||||
|
{ ...additionalParams },
|
||||||
|
where,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return queryString.stringify({
|
||||||
|
where,
|
||||||
|
}, { addQueryPrefix: true });
|
||||||
|
}, [history.location.search, selectAll, selected]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectAll === SelectAllStatus.AllAvailable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let some = false;
|
||||||
|
let all = true;
|
||||||
|
Object.values(selected).forEach((val) => {
|
||||||
|
all = all && val;
|
||||||
|
some = some || val;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (all) {
|
||||||
|
setSelectAll(SelectAllStatus.AllInPage);
|
||||||
|
} else if (some) {
|
||||||
|
setSelectAll(SelectAllStatus.Some);
|
||||||
|
} else {
|
||||||
|
setSelectAll(SelectAllStatus.None);
|
||||||
|
}
|
||||||
|
}, [docs, selectAll, selected]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const rows = {};
|
||||||
|
if (docs.length) {
|
||||||
|
docs.forEach(({ id }) => {
|
||||||
|
rows[id] = false;
|
||||||
|
});
|
||||||
|
setSelected(rows);
|
||||||
|
}
|
||||||
|
setSelectAll(SelectAllStatus.None);
|
||||||
|
}, [docs, history]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const newCount = selectAll === SelectAllStatus.AllAvailable ? totalDocs : Object.keys(selected).filter((id) => selected[id]).length;
|
||||||
|
setCount(newCount);
|
||||||
|
}, [selectAll, selected, totalDocs]);
|
||||||
|
|
||||||
|
contextRef.current = {
|
||||||
|
selectAll,
|
||||||
|
toggleAll,
|
||||||
|
selected,
|
||||||
|
setSelection,
|
||||||
|
totalDocs,
|
||||||
|
count,
|
||||||
|
getQueryParams,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Context.Provider value={contextRef.current}>
|
||||||
|
{children}
|
||||||
|
</Context.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSelection = (): SelectionContext => useContext(Context);
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { TFunction } from 'react-i18next';
|
import { TFunction } from 'react-i18next';
|
||||||
|
import React from 'react';
|
||||||
import { SanitizedCollectionConfig } from '../../../../../collections/config/types';
|
import { SanitizedCollectionConfig } from '../../../../../collections/config/types';
|
||||||
import { Field, fieldAffectsData, fieldIsPresentationalOnly } from '../../../../../fields/config/types';
|
import { Field, fieldAffectsData, fieldIsPresentationalOnly } from '../../../../../fields/config/types';
|
||||||
|
|
||||||
const formatFields = (config: SanitizedCollectionConfig, t: TFunction): Field[] => {
|
const formatFields = (config: SanitizedCollectionConfig, t: TFunction): Field[] => {
|
||||||
const hasID = config.fields.findIndex((field) => fieldAffectsData(field) && field.name === 'id') > -1;
|
const hasID = config.fields.findIndex((field) => fieldAffectsData(field) && field.name === 'id') > -1;
|
||||||
let fields: Field[] = config.fields.reduce((formatted, field) => {
|
const fields: Field[] = config.fields.reduce((formatted, field) => {
|
||||||
if (!fieldIsPresentationalOnly(field) && (field.hidden === true || field?.admin?.disabled === true)) {
|
if (!fieldIsPresentationalOnly(field) && (field.hidden === true || field?.admin?.disabled === true)) {
|
||||||
return formatted;
|
return formatted;
|
||||||
}
|
}
|
||||||
@@ -13,30 +14,34 @@ const formatFields = (config: SanitizedCollectionConfig, t: TFunction): Field[]
|
|||||||
...formatted,
|
...formatted,
|
||||||
field,
|
field,
|
||||||
];
|
];
|
||||||
}, hasID ? [] : [{ name: 'id', label: 'ID', type: 'text' }]);
|
}, hasID ? [] : [{
|
||||||
|
name: 'id',
|
||||||
|
label: 'ID',
|
||||||
|
type: 'text',
|
||||||
|
admin: {
|
||||||
|
disableBulkEdit: true,
|
||||||
|
},
|
||||||
|
}]);
|
||||||
|
|
||||||
if (config.timestamps) {
|
if (config.timestamps) {
|
||||||
fields = fields.concat([
|
fields.push(
|
||||||
{
|
{
|
||||||
name: 'createdAt',
|
name: 'createdAt',
|
||||||
label: t('general:createdAt'),
|
label: t('general:createdAt'),
|
||||||
type: 'date',
|
type: 'date',
|
||||||
}, {
|
admin: {
|
||||||
|
disableBulkEdit: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
name: 'updatedAt',
|
name: 'updatedAt',
|
||||||
label: t('general:updatedAt'),
|
label: t('general:updatedAt'),
|
||||||
type: 'date',
|
type: 'date',
|
||||||
|
admin: {
|
||||||
|
disableBulkEdit: true,
|
||||||
},
|
},
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.upload) {
|
|
||||||
fields = fields.concat([
|
|
||||||
{
|
|
||||||
name: 'filename',
|
|
||||||
label: t('upload:fileName'),
|
|
||||||
type: 'text',
|
|
||||||
},
|
},
|
||||||
]);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return fields;
|
return fields;
|
||||||
|
|||||||
@@ -37,6 +37,12 @@
|
|||||||
table {
|
table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
|
||||||
|
#heading-_select,
|
||||||
|
.cell-_select {
|
||||||
|
min-width: unset;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,34 +61,43 @@
|
|||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__list-selection {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 10;
|
||||||
|
padding: base(.75) 0;
|
||||||
|
width: 100%;
|
||||||
|
background-color: var(--theme-bg);
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
margin: 0 0 0 base(.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
background-color: var(--theme-elevation-100);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 base(.25);
|
||||||
|
border-radius: $style-radius-s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--theme-elevation-200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__list-selection-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: base(.25);
|
||||||
|
}
|
||||||
|
|
||||||
&__shimmer {
|
&__shimmer {
|
||||||
margin-top: base(1.75);
|
margin-top: base(1.75);
|
||||||
}
|
width: 100%;
|
||||||
|
|
||||||
&__shimmer--rows {
|
|
||||||
>div {
|
>div {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__shimmer--uploads {
|
|
||||||
// match upload cards
|
|
||||||
margin: base(2) -#{base(.5)};
|
|
||||||
width: calc(100% + #{$baseline});
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
|
|
||||||
>div {
|
|
||||||
min-width: 0;
|
|
||||||
width: calc(16.66%);
|
|
||||||
|
|
||||||
>div {
|
|
||||||
margin: base(.5);
|
|
||||||
padding-bottom: 110%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@include mid-break {
|
@include mid-break {
|
||||||
&__wrap {
|
&__wrap {
|
||||||
padding-top: 0;
|
padding-top: 0;
|
||||||
@@ -111,19 +126,9 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
margin-bottom: $baseline;
|
margin-bottom: $baseline;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__shimmer--uploads {
|
|
||||||
>div {
|
|
||||||
width: 33.33%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@include small-break {
|
@include small-break {
|
||||||
&__shimmer--uploads {
|
margin-bottom: base(3);
|
||||||
>div {
|
|
||||||
width: 50%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import { v4 as uuid } from 'uuid';
|
||||||
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
import { useHistory } from 'react-router-dom';
|
import { useHistory } from 'react-router-dom';
|
||||||
import queryString from 'qs';
|
import queryString from 'qs';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -9,10 +10,11 @@ import DefaultList from './Default';
|
|||||||
import RenderCustomComponent from '../../../utilities/RenderCustomComponent';
|
import RenderCustomComponent from '../../../utilities/RenderCustomComponent';
|
||||||
import { useStepNav } from '../../../elements/StepNav';
|
import { useStepNav } from '../../../elements/StepNav';
|
||||||
import formatFields from './formatFields';
|
import formatFields from './formatFields';
|
||||||
import { ListIndexProps, ListPreferences } from './types';
|
import { Props, ListIndexProps, ListPreferences } from './types';
|
||||||
import { usePreferences } from '../../../utilities/Preferences';
|
import { usePreferences } from '../../../utilities/Preferences';
|
||||||
import { useSearchParams } from '../../../utilities/SearchParams';
|
import { useSearchParams } from '../../../utilities/SearchParams';
|
||||||
import { Field } from '../../../../../fields/config/types';
|
import { TableColumnsProvider } from '../../../elements/TableColumns';
|
||||||
|
import type { Field } from '../../../../../fields/config/types';
|
||||||
|
|
||||||
const ListView: React.FC<ListIndexProps> = (props) => {
|
const ListView: React.FC<ListIndexProps> = (props) => {
|
||||||
const {
|
const {
|
||||||
@@ -48,7 +50,7 @@ const ListView: React.FC<ListIndexProps> = (props) => {
|
|||||||
const collectionPermissions = permissions?.collections?.[slug];
|
const collectionPermissions = permissions?.collections?.[slug];
|
||||||
const hasCreatePermission = collectionPermissions?.create?.permission;
|
const hasCreatePermission = collectionPermissions?.create?.permission;
|
||||||
const newDocumentURL = `${admin}/collections/${slug}/create`;
|
const newDocumentURL = `${admin}/collections/${slug}/create`;
|
||||||
const [{ data }, { setParams: setFetchParams }] = usePayloadAPI(fetchURL, { initialParams: { page: 1 } });
|
const [{ data }, { setParams }] = usePayloadAPI(fetchURL, { initialParams: { page: 1 } });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setStepNav([
|
setStepNav([
|
||||||
@@ -62,27 +64,31 @@ const ListView: React.FC<ListIndexProps> = (props) => {
|
|||||||
// Set up Payload REST API query params
|
// Set up Payload REST API query params
|
||||||
// /////////////////////////////////////
|
// /////////////////////////////////////
|
||||||
|
|
||||||
useEffect(() => {
|
const resetParams = useCallback<Props['resetParams']>((overrides = {}) => {
|
||||||
const params = {
|
const params: Record<string, unknown> = {
|
||||||
depth: 0,
|
depth: 0,
|
||||||
draft: 'true',
|
draft: 'true',
|
||||||
page: undefined,
|
page: overrides?.page,
|
||||||
sort: undefined,
|
sort: overrides?.sort,
|
||||||
where: undefined,
|
where: overrides?.where,
|
||||||
limit,
|
limit,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (page) params.page = page;
|
if (page) params.page = page;
|
||||||
if (sort) params.sort = sort;
|
if (sort) params.sort = sort;
|
||||||
if (where) params.where = where;
|
if (where) params.where = where;
|
||||||
|
params.invoke = uuid();
|
||||||
|
|
||||||
|
setParams(params);
|
||||||
|
}, [limit, page, setParams, sort, where]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
// Performance enhancement
|
// Performance enhancement
|
||||||
// Setting the Fetch URL this way
|
// Setting the Fetch URL this way
|
||||||
// prevents a double-fetch
|
// prevents a double-fetch
|
||||||
setFetchURL(`${serverURL}${api}/${slug}`);
|
setFetchURL(`${serverURL}${api}/${slug}`);
|
||||||
|
resetParams();
|
||||||
setFetchParams(params);
|
}, [api, resetParams, serverURL, slug]);
|
||||||
}, [setFetchParams, page, sort, where, collection, limit, serverURL, api, slug]);
|
|
||||||
|
|
||||||
// /////////////////////////////////////
|
// /////////////////////////////////////
|
||||||
// Fetch preferences on first load
|
// Fetch preferences on first load
|
||||||
@@ -128,7 +134,28 @@ const ListView: React.FC<ListIndexProps> = (props) => {
|
|||||||
})();
|
})();
|
||||||
}, [sort, limit, preferenceKey, setPreference, getPreference]);
|
}, [sort, limit, preferenceKey, setPreference, getPreference]);
|
||||||
|
|
||||||
|
// /////////////////////////////////////
|
||||||
|
// Prevent going beyond page limit
|
||||||
|
// /////////////////////////////////////
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data?.totalDocs && data.pagingCounter > data.totalDocs) {
|
||||||
|
const params = queryString.parse(history.location.search, {
|
||||||
|
ignoreQueryPrefix: true,
|
||||||
|
depth: 0,
|
||||||
|
});
|
||||||
|
const newSearchQuery = queryString.stringify({
|
||||||
|
...params,
|
||||||
|
page: data.totalPages,
|
||||||
|
}, { addQueryPrefix: true });
|
||||||
|
history.replace({
|
||||||
|
search: newSearchQuery,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [data, history, resetParams]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<TableColumnsProvider collection={collection}>
|
||||||
<RenderCustomComponent
|
<RenderCustomComponent
|
||||||
DefaultComponent={DefaultList}
|
DefaultComponent={DefaultList}
|
||||||
CustomComponent={CustomList}
|
CustomComponent={CustomList}
|
||||||
@@ -138,8 +165,10 @@ const ListView: React.FC<ListIndexProps> = (props) => {
|
|||||||
hasCreatePermission,
|
hasCreatePermission,
|
||||||
data,
|
data,
|
||||||
limit: limit || defaultLimit,
|
limit: limit || defaultLimit,
|
||||||
|
resetParams,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</TableColumnsProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user