Compare commits
67 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3825041393 | ||
|
|
0fedbabe9e | ||
|
|
c5cb08c5b8 | ||
|
|
833899c893 | ||
|
|
1f480c4cd5 | ||
|
|
b74a59947d | ||
|
|
21b8da7f41 | ||
|
|
fb2fd3e9b7 | ||
|
|
c0ff75c164 | ||
|
|
e1a6e08aa1 | ||
|
|
ac4cc5548a | ||
|
|
e0e1b09b77 | ||
|
|
fe86707c53 | ||
|
|
2ed7e325b8 | ||
|
|
e09ebfffa0 | ||
|
|
a8766d00a8 | ||
|
|
ef9606bf5b | ||
|
|
10dd819863 | ||
|
|
c8594a7e7a | ||
|
|
959567aade | ||
|
|
7a8c7f3429 | ||
|
|
4d578f1bfd | ||
|
|
eabfd91655 | ||
|
|
a4c6c4891e | ||
|
|
11c15720d4 | ||
|
|
24e92cfe69 | ||
|
|
4b243c9007 | ||
|
|
8d65ba1efd | ||
|
|
5f1b0c21eb | ||
|
|
af164159fb | ||
|
|
39e303add6 | ||
|
|
9b5c889187 | ||
|
|
dd9c15c672 | ||
|
|
92e9602329 | ||
|
|
dbf976ee5e | ||
|
|
927b3fb6d3 | ||
|
|
5e84ca3ce7 | ||
|
|
3b2daa1992 | ||
|
|
a19c42f1bd | ||
|
|
fc82661b54 | ||
|
|
4e95a39132 | ||
|
|
5a637a8b09 | ||
|
|
75e776ddb4 | ||
|
|
e1553c2fc8 | ||
|
|
db6d35bc03 | ||
|
|
d5bf957c8e | ||
|
|
566c45b0b4 | ||
|
|
39ee306630 | ||
|
|
748475f785 | ||
|
|
bf9929e9a9 | ||
|
|
9aa1b8ec47 | ||
|
|
ccc92fdb75 | ||
|
|
657aa65e99 | ||
|
|
abebde6b12 | ||
|
|
1df3d149e0 | ||
|
|
8832d08a22 | ||
|
|
51dc66b5d9 | ||
|
|
aae6d716e5 | ||
|
|
32b38439e3 | ||
|
|
fd8ea88488 | ||
|
|
8d1df96637 | ||
|
|
c1f205c2cf | ||
|
|
e9c796e42c | ||
|
|
4e1748fb8a | ||
|
|
959f01739c | ||
|
|
85dee9a7bc | ||
|
|
057522c5bd |
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
|
||||
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
|
||||
url: https://github.com/payloadcms/payload/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.
|
||||
67
CHANGELOG.md
67
CHANGELOG.md
@@ -1,5 +1,72 @@
|
||||
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* hidden fields being mutated on patch ([#2317](https://github.com/payloadcms/payload/issues/2317)) ([8d65ba1](https://github.com/payloadcms/payload/commit/8d65ba1efd8744042bbaf669c10b6837a6b972f8))
|
||||
|
||||
## [1.6.20](https://github.com/payloadcms/payload/compare/v1.6.19...v1.6.20) (2023-03-13)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* allow thumbnails in upload gallery to show useAsTitle value ([aae6d71](https://github.com/payloadcms/payload/commit/aae6d716e5608270ca142f2f4df214f9e271deb4))
|
||||
* allows useListDrawer to work without collectionSlugs defined ([e1553c2](https://github.com/payloadcms/payload/commit/e1553c2fc88ac582744cd72d15c9e9ef3b8ec549))
|
||||
* cancels existing fetches if new fetches are started ([ccc92fd](https://github.com/payloadcms/payload/commit/ccc92fdb7519e14ff1092f19ae4e7060fa413aab))
|
||||
* check relationships indexed access for undefined ([959f017](https://github.com/payloadcms/payload/commit/959f01739c30450f3a6d052dd6083fdacf1527a4))
|
||||
* ensures documentID exists in doc documentDrawers ([#2304](https://github.com/payloadcms/payload/issues/2304)) ([566c45b](https://github.com/payloadcms/payload/commit/566c45b0b436a9a3ea8eff27de2ea829dd6a2f0c))
|
||||
* flattens title fields to allow seaching by title if title inside Row field ([75e776d](https://github.com/payloadcms/payload/commit/75e776ddb43b292eae6c1204589d9dc22deab50c))
|
||||
* keep drop zone active when hovering inner elements ([#2295](https://github.com/payloadcms/payload/issues/2295)) ([39e303a](https://github.com/payloadcms/payload/commit/39e303add62d2dbd3e72d17e64e1ea5d940b0298))
|
||||
* Prevent browser initial favicon request ([fd8ea88](https://github.com/payloadcms/payload/commit/fd8ea88488c80627346733e0595a2ef34c964a87))
|
||||
* removes forced require on array, block, group ts ([657aa65](https://github.com/payloadcms/payload/commit/657aa65e993d13e9a294456b73adcd57f20d7c87))
|
||||
* removes pagination type from top level admin config types ([bf9929e](https://github.com/payloadcms/payload/commit/bf9929e9a9919488f6de0e172909fa27719ecb04))
|
||||
* renders presentational table columns ([4e1748f](https://github.com/payloadcms/payload/commit/4e1748fb8a3554586b377e60738130d03ec12f38))
|
||||
* undefined point fields saving as empty object ([#2313](https://github.com/payloadcms/payload/issues/2313)) ([af16415](https://github.com/payloadcms/payload/commit/af164159fb52f4b0ef97e2fa34b881f97bc07310))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* [#2280](https://github.com/payloadcms/payload/issues/2280) Improve UX of paginator ([#2293](https://github.com/payloadcms/payload/issues/2293)) ([1df3d14](https://github.com/payloadcms/payload/commit/1df3d149e06cc955a61c4371371b601c0d9aad2b))
|
||||
* exposes useTheme hook ([abebde6](https://github.com/payloadcms/payload/commit/abebde6b120a9dddc9971325b616b9cb31bcba90))
|
||||
* provide refresh permissions for auth context ([e9c796e](https://github.com/payloadcms/payload/commit/e9c796e42c1bb1e0ce72d057ee88dee624b94c24))
|
||||
|
||||
## [1.6.19](https://github.com/payloadcms/payload/compare/v1.6.18...v1.6.19) (2023-03-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* ensures nested fields save properly within link, upload rte ([057522c](https://github.com/payloadcms/payload/commit/057522c5bdade430c6e60f589a32f174739d400c))
|
||||
|
||||
## [1.6.18](https://github.com/payloadcms/payload/compare/v1.6.17...v1.6.18) (2023-03-09)
|
||||
|
||||
|
||||
|
||||
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.
|
||||
@@ -4,3 +4,4 @@ exports.useDocumentInfo = require('../dist/admin/components/utilities/DocumentIn
|
||||
exports.useConfig = require('../dist/admin/components/utilities/Config').useConfig;
|
||||
exports.useAuth = require('../dist/admin/components/utilities/Auth').useAuth;
|
||||
exports.useEditDepth = require('../dist/admin/components/utilities/EditDepth').useEditDepth;
|
||||
exports.useTheme = require('../dist/admin/components/utilities/Theme').useTheme;
|
||||
|
||||
@@ -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.
|
||||
|
||||
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.
|
||||
|
||||
## Pull Requests
|
||||
|
||||
@@ -226,14 +226,15 @@ const Greeting: React.FC = () => {
|
||||
|
||||
Useful to retrieve info about the currently logged in user as well as methods for interacting with it. It sends back an object with the following properties:
|
||||
|
||||
| Property | Description |
|
||||
|---------------------|-----------------------------------------------------------------------------------------|
|
||||
| **`user`** | The currently logged in user |
|
||||
| **`logOut`** | A method to log out the currently logged in user |
|
||||
| **`refreshCookie`** | A method to trigger the silent refreshing of a user's auth token |
|
||||
| **`setToken`** | Set the token of the user, to be decoded and used to reset the user and token in memory |
|
||||
| **`token`** | The logged in user's token (useful for creating preview links, etc.) |
|
||||
| **`permissions`** | The permissions of the current user |
|
||||
| Property | Description |
|
||||
|--------------------------|-----------------------------------------------------------------------------------------|
|
||||
| **`user`** | The currently logged in user |
|
||||
| **`logOut`** | A method to log out the currently logged in user |
|
||||
| **`refreshCookie`** | A method to trigger the silent refreshing of a user's auth token |
|
||||
| **`setToken`** | Set the token of the user, to be decoded and used to reset the user and token in memory |
|
||||
| **`token`** | The logged in user's token (useful for creating preview links, etc.) |
|
||||
| **`refreshPermissions`** | Load new permissions (useful when content that effects permissions has been changed) |
|
||||
| **`permissions`** | The permissions of the current user |
|
||||
|
||||
```tsx
|
||||
import { useAuth } from 'payload/components/utilities';
|
||||
|
||||
@@ -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.
|
||||
|
||||
<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>
|
||||
|
||||
Example in `server.js`:
|
||||
|
||||
```ts
|
||||
import express from 'express';
|
||||
import payload from 'payload';
|
||||
import express from "express";
|
||||
import payload from "payload";
|
||||
|
||||
const app = express();
|
||||
|
||||
payload.init({
|
||||
secret: 'PAYLOAD_SECRET_KEY',
|
||||
mongoURL: 'mongodb://localhost/payload',
|
||||
secret: "PAYLOAD_SECRET_KEY",
|
||||
mongoURL: "mongodb://localhost/payload",
|
||||
express: app,
|
||||
});
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Note: Payload must be initialized before the `payload.authenticate` middleware can be used
|
||||
router.use(payload.authenticate); // highlight-line
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
router.get("/", (req, res) => {
|
||||
if (req.user) {
|
||||
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 () => {
|
||||
payload.logger.info(`listening on ${3000}...`);
|
||||
});
|
||||
|
||||
```
|
||||
|
||||
@@ -12,21 +12,22 @@ It's often best practice to write your Collections in separate files and then im
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Description |
|
||||
|------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| **`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. |
|
||||
| **`labels`** | Singular and plural labels for use in identifying this Collection throughout Payload. Auto-generated from slug if not defined. |
|
||||
| **`admin`** | Admin-specific configuration. See below for [more detail](#admin-options). |
|
||||
| **`hooks`** | Entry points to "tie in" to Collection actions at specific points. [More](/docs/hooks/overview#collection-hooks) |
|
||||
| **`access`** | Provide access control functions to define exactly who should be able to do what with Documents in this Collection. [More](/docs/access-control/overview/#collections) |
|
||||
| **`auth`** | Specify options if you would like this Collection to feature authentication. For more, consult the [Authentication](/docs/authentication/config) documentation. |
|
||||
| **`upload`** | Specify options if you would like this Collection to support file uploads. For more, consult the [Uploads](/docs/upload/overview) documentation. |
|
||||
| **`timestamps`** | Set to false to disable documents' automatically generated `createdAt` and `updatedAt` timestamps. |
|
||||
| **`versions`** | Set to true to enable default options, or configure with object properties. [More](/docs/versions/overview#collection-config) |
|
||||
| **`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. |
|
||||
| **`typescript`** | An object with property `interface` as the text used in schema generation. Auto-generated from slug if not defined. |
|
||||
| Option | Description |
|
||||
|--------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| **`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. |
|
||||
| **`labels`** | Singular and plural labels for use in identifying this Collection throughout Payload. Auto-generated from slug if not defined. |
|
||||
| **`admin`** | Admin-specific configuration. See below for [more detail](#admin-options). |
|
||||
| **`hooks`** | Entry points to "tie in" to Collection actions at specific points. [More](/docs/hooks/overview#collection-hooks) |
|
||||
| **`access`** | Provide access control functions to define exactly who should be able to do what with Documents in this Collection. [More](/docs/access-control/overview/#collections) |
|
||||
| **`auth`** | Specify options if you would like this Collection to feature authentication. For more, consult the [Authentication](/docs/authentication/config) documentation. |
|
||||
| **`upload`** | Specify options if you would like this Collection to support file uploads. For more, consult the [Uploads](/docs/upload/overview) documentation. |
|
||||
| **`timestamps`** | Set to false to disable documents' automatically generated `createdAt` and `updatedAt` timestamps. |
|
||||
| **`versions`** | Set to true to enable default options, or configure with object properties. [More](/docs/versions/overview#collection-config) |
|
||||
| **`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. |
|
||||
| **`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. |
|
||||
|
||||
*\* An asterisk denotes that a property is required.*
|
||||
|
||||
|
||||
@@ -155,18 +155,19 @@ 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:
|
||||
|
||||
| Option | Description |
|
||||
| ------------- | -------------|
|
||||
| `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. |
|
||||
| `description` | Helper text to display with the field to provide more information for the editor user. [Click here](#description) for more info. |
|
||||
| `position` | Specify if the field should be rendered in the sidebar by defining `position: 'sidebar'`. |
|
||||
| `width` | Restrict the width of a field. you can pass any string-based value here, be it pixels, percentages, etc. This property is especially useful when fields are nested within a `Row` type where they can be organized horizontally. |
|
||||
| `style` | Attach raw CSS style properties 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. |
|
||||
| `disabled` | If a field is `disabled`, it is completely omitted from the Admin panel. |
|
||||
| `hidden` | Setting a field's `hidden` property on its `admin` config will transform it into a `hidden` input type. Its value will still submit with the Admin panel's requests, but the field itself will not be visible to editors. |
|
||||
| Option | Description |
|
||||
|-------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `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. |
|
||||
| `description` | Helper text to display with the field to provide more information for the editor user. [Click here](#description) for more info. |
|
||||
| `position` | Specify if the field should be rendered in the sidebar by defining `position: 'sidebar'`. |
|
||||
| `width` | Restrict the width of a field. you can pass any string-based value here, be it pixels, percentages, etc. This property is especially useful when fields are nested within a `Row` type where they can be organized horizontally. |
|
||||
| `style` | Attach raw CSS style properties 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. |
|
||||
| `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. |
|
||||
|
||||
### Custom components
|
||||
|
||||
|
||||
@@ -162,7 +162,7 @@ const result = await payload.findByID({
|
||||
});
|
||||
```
|
||||
|
||||
#### Update
|
||||
#### Update by ID
|
||||
|
||||
```js
|
||||
// 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
|
||||
|
||||
```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
|
||||
|
||||
If a collection has [`Authentication`](/docs/authentication/overview) enabled, additional Local API operations will be available:
|
||||
|
||||
@@ -26,13 +26,15 @@ Note: Collection slugs must be formatted in kebab-case
|
||||
|
||||
**All CRUD operations are exposed as follows:**
|
||||
|
||||
| Method | Path | Description |
|
||||
| -------- | --------------------------- | -------------------------------------- |
|
||||
| `GET` | `/api/{collection-slug}` | Find paginated documents |
|
||||
| `GET` | `/api/{collection-slug}/:id` | Find a specific document by ID |
|
||||
| `POST` | `/api/{collection-slug}` | Create a new document |
|
||||
| `PATCH` | `/api/{collection-slug}/:id` | Update a document by ID |
|
||||
| `DELETE` | `/api/{collection-slug}/:id` | Delete an existing document by ID |
|
||||
| Method | Path | Description |
|
||||
|----------|-------------------------------|--------------------------------------------------|
|
||||
| `GET` | `/api/{collection-slug}` | Find paginated documents |
|
||||
| `GET` | `/api/{collection-slug}/:id` | Find a specific document by ID |
|
||||
| `POST` | `/api/{collection-slug}` | Create a new document |
|
||||
| `PATCH` | `/api/{collection-slug}` | Update all documents matching the `where` query |
|
||||
| `PATCH` | `/api/{collection-slug}` | Update a 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
|
||||
|
||||
|
||||
11
package.json
11
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "payload",
|
||||
"version": "1.6.18",
|
||||
"version": "1.6.24",
|
||||
"description": "Node, React and MongoDB Headless CMS and Application Framework",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -186,10 +186,10 @@
|
||||
"url-loader": "^4.1.1",
|
||||
"use-context-selector": "^1.4.1",
|
||||
"uuid": "^8.3.2",
|
||||
"webpack": "^5.75.0",
|
||||
"webpack-bundle-analyzer": "^4.7.0",
|
||||
"webpack": "^5.76.0",
|
||||
"webpack-bundle-analyzer": "^4.8.0",
|
||||
"webpack-cli": "^4.10.0",
|
||||
"webpack-dev-middleware": "^4.3.0",
|
||||
"webpack-dev-middleware": "6.0.1",
|
||||
"webpack-hot-middleware": "^2.25.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -252,9 +252,8 @@
|
||||
"@types/shelljs": "^0.8.11",
|
||||
"@types/testing-library__jest-dom": "^5.14.5",
|
||||
"@types/uuid": "^8.3.4",
|
||||
"@types/webpack": "4.41.33",
|
||||
"@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-hot-middleware": "2.25.6",
|
||||
"@typescript-eslint/eslint-plugin": "^4.33.0",
|
||||
|
||||
@@ -12,7 +12,7 @@ export const requests = {
|
||||
}
|
||||
return fetch(`${url}${query}`, {
|
||||
credentials: 'include',
|
||||
headers: options.headers,
|
||||
...options,
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
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 |
@@ -189,12 +189,10 @@ const Routes = () => {
|
||||
render={(routeProps) => {
|
||||
if (permissions?.collections?.[collection.slug]?.read?.permission) {
|
||||
return (
|
||||
<TableColumnsProvider collection={collection}>
|
||||
<List
|
||||
{...routeProps}
|
||||
collection={collection}
|
||||
/>
|
||||
</TableColumnsProvider>
|
||||
<List
|
||||
{...routeProps}
|
||||
collection={collection}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import { ShimmerEffect } from '../ShimmerEffect';
|
||||
const baseClass = 'code-editor';
|
||||
|
||||
const CodeEditor: React.FC<Props> = (props) => {
|
||||
const { readOnly, className, options, ...rest } = props;
|
||||
const { readOnly, className, options, height, ...rest } = props;
|
||||
|
||||
const { theme } = useTheme();
|
||||
|
||||
@@ -23,7 +23,7 @@ const CodeEditor: React.FC<Props> = (props) => {
|
||||
<Editor
|
||||
className={classes}
|
||||
theme={theme === 'dark' ? 'vs-dark' : 'vs'}
|
||||
loading={<ShimmerEffect height="35vh" />}
|
||||
loading={<ShimmerEffect height={height} />}
|
||||
options={
|
||||
{
|
||||
detectIndentation: true,
|
||||
@@ -37,6 +37,7 @@ const CodeEditor: React.FC<Props> = (props) => {
|
||||
...options,
|
||||
}
|
||||
}
|
||||
height={height}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useId } from 'react';
|
||||
import React, { useId, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Pill from '../Pill';
|
||||
import Plus from '../../icons/Plus';
|
||||
@@ -49,6 +49,8 @@ const ColumnSelector: React.FC<Props> = (props) => {
|
||||
name,
|
||||
} = col;
|
||||
|
||||
if (col.accessor === '_select') return null;
|
||||
|
||||
return (
|
||||
<Pill
|
||||
draggable
|
||||
|
||||
@@ -23,9 +23,6 @@ const DeleteDocument: React.FC<Props> = (props) => {
|
||||
buttonId,
|
||||
collection,
|
||||
collection: {
|
||||
admin: {
|
||||
useAsTitle,
|
||||
},
|
||||
slug,
|
||||
labels: {
|
||||
singular,
|
||||
@@ -39,7 +36,7 @@ const DeleteDocument: React.FC<Props> = (props) => {
|
||||
const { toggleModal } = useModal();
|
||||
const history = useHistory();
|
||||
const { t, i18n } = useTranslation('general');
|
||||
const title = useTitle(useAsTitle, collection.slug) || id;
|
||||
const title = useTitle(collection);
|
||||
const titleToRender = titleFromProps || title;
|
||||
|
||||
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'],
|
||||
}
|
||||
@@ -93,7 +93,10 @@ export const DocumentDrawerContent: React.FC<DocumentDrawerProps> = ({
|
||||
if (isError) return null;
|
||||
|
||||
return (
|
||||
<DocumentInfoProvider collection={collectionConfig}>
|
||||
<DocumentInfoProvider
|
||||
collection={collectionConfig}
|
||||
id={id}
|
||||
>
|
||||
<RenderCustomComponent
|
||||
DefaultComponent={DefaultEdit}
|
||||
CustomComponent={collectionConfig.admin?.components?.views?.Edit}
|
||||
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: var(--theme-elevation-50);
|
||||
}
|
||||
|
||||
.search-filter {
|
||||
@@ -21,24 +23,28 @@
|
||||
|
||||
&__buttons-wrap {
|
||||
display: flex;
|
||||
margin-left: - base(.5);
|
||||
margin-right: - base(.5);
|
||||
width: calc(100% + #{base(1)});
|
||||
align-items: center;
|
||||
margin-right: base(.5);
|
||||
|
||||
.btn, .pill {
|
||||
margin: 0 0 0 base(.5);
|
||||
}
|
||||
|
||||
.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,
|
||||
&__toggle-where,
|
||||
&__toggle-sort {
|
||||
min-width: 140px;
|
||||
|
||||
&.btn--style-primary {
|
||||
svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
&__buttons-active {
|
||||
svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,25 +54,10 @@
|
||||
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 {
|
||||
&__wrap {
|
||||
flex-wrap: wrap;
|
||||
background-color: unset;
|
||||
}
|
||||
|
||||
.search-filter {
|
||||
@@ -74,11 +65,23 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__buttons {
|
||||
margin: 0;
|
||||
&__buttons-wrap {
|
||||
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 {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import AnimateHeight from 'react-animate-height';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useWindowInfo } from '@faceless-ui/window-info';
|
||||
import { fieldAffectsData } from '../../../../fields/config/types';
|
||||
import SearchFilter from '../SearchFilter';
|
||||
import ColumnSelector from '../ColumnSelector';
|
||||
@@ -10,8 +11,15 @@ import Button from '../Button';
|
||||
import { Props } from './types';
|
||||
import { useSearchParams } from '../../utilities/SearchParams';
|
||||
import validateWhereQuery from '../WhereBuilder/validateWhereQuery';
|
||||
import flattenFields from '../../../../utilities/flattenTopLevelFields';
|
||||
import { getTextFieldsToBeSearched } from './getTextFieldsToBeSearched';
|
||||
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';
|
||||
|
||||
@@ -25,6 +33,7 @@ const ListControls: React.FC<Props> = (props) => {
|
||||
handleSortChange,
|
||||
handleWhereChange,
|
||||
modifySearchQuery = true,
|
||||
resetParams,
|
||||
collection: {
|
||||
fields,
|
||||
admin: {
|
||||
@@ -37,10 +46,14 @@ const ListControls: React.FC<Props> = (props) => {
|
||||
const params = useSearchParams();
|
||||
const shouldInitializeWhereOpened = validateWhereQuery(params?.where);
|
||||
|
||||
const [titleField] = useState(() => fields.find((field) => fieldAffectsData(field) && field.name === useAsTitle));
|
||||
const [titleField] = useState(() => {
|
||||
const topLevelFields = flattenFields(fields);
|
||||
return topLevelFields.find((field) => fieldAffectsData(field) && field.name === useAsTitle);
|
||||
});
|
||||
const [textFieldsToBeSearched] = useState(getTextFieldsToBeSearched(listSearchableFields, fields));
|
||||
const [visibleDrawer, setVisibleDrawer] = useState<'where' | 'sort' | 'columns'>(shouldInitializeWhereOpened ? 'where' : undefined);
|
||||
const { t, i18n } = useTranslation('general');
|
||||
const { breakpoints: { s: smallBreak } } = useWindowInfo();
|
||||
|
||||
return (
|
||||
<div className={baseClass}>
|
||||
@@ -54,26 +67,44 @@ const ListControls: React.FC<Props> = (props) => {
|
||||
/>
|
||||
<div className={`${baseClass}__buttons`}>
|
||||
<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 && (
|
||||
<Button
|
||||
className={`${baseClass}__toggle-columns`}
|
||||
buttonStyle={visibleDrawer === 'columns' ? undefined : 'secondary'}
|
||||
<Pill
|
||||
pillStyle="dark"
|
||||
className={`${baseClass}__toggle-columns ${visibleDrawer === 'columns' ? `${baseClass}__buttons-active` : ''}`}
|
||||
onClick={() => setVisibleDrawer(visibleDrawer !== 'columns' ? 'columns' : undefined)}
|
||||
icon="chevron"
|
||||
iconStyle="none"
|
||||
icon={<Chevron />}
|
||||
>
|
||||
{t('columns')}
|
||||
</Button>
|
||||
</Pill>
|
||||
)}
|
||||
<Button
|
||||
className={`${baseClass}__toggle-where`}
|
||||
buttonStyle={visibleDrawer === 'where' ? undefined : 'secondary'}
|
||||
<Pill
|
||||
pillStyle="dark"
|
||||
className={`${baseClass}__toggle-where ${visibleDrawer === 'where' ? `${baseClass}__buttons-active` : ''}`}
|
||||
onClick={() => setVisibleDrawer(visibleDrawer !== 'where' ? 'where' : undefined)}
|
||||
icon="chevron"
|
||||
iconStyle="none"
|
||||
icon={<Chevron />}
|
||||
>
|
||||
{t('filters')}
|
||||
</Button>
|
||||
</Pill>
|
||||
{enableSort && (
|
||||
<Button
|
||||
className={`${baseClass}__toggle-sort`}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Where } from '../../../../types';
|
||||
import { SanitizedCollectionConfig } from '../../../../collections/config/types';
|
||||
import { Column } from '../Table/types';
|
||||
import type { Props as ListProps } from '../../views/collections/List/types';
|
||||
|
||||
export type Props = {
|
||||
enableColumns?: boolean
|
||||
@@ -9,6 +10,7 @@ export type Props = {
|
||||
handleSortChange?: (sort: string) => void
|
||||
handleWhereChange?: (where: Where) => void
|
||||
collection: SanitizedCollectionConfig
|
||||
resetParams?: ListProps['resetParams']
|
||||
}
|
||||
|
||||
export type ListControls = {
|
||||
|
||||
@@ -222,16 +222,6 @@ export const ListDrawerContent: React.FC<ListDrawerProps> = ({
|
||||
hasCreatePermission,
|
||||
disableEyebrow: true,
|
||||
modifySearchParams: false,
|
||||
onCardClick: (doc) => {
|
||||
if (typeof onSelect === 'function') {
|
||||
onSelect({
|
||||
docID: doc.id,
|
||||
collectionConfig: selectedCollectionConfig,
|
||||
});
|
||||
}
|
||||
closeModal(drawerSlug);
|
||||
},
|
||||
disableCardLink: true,
|
||||
handleSortChange: setSort,
|
||||
handleWhereChange: setWhere,
|
||||
handlePageChange: setPage,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ListDrawerProps, ListTogglerProps, UseListDrawer } from './types';
|
||||
import { Drawer, DrawerToggler } from '../Drawer';
|
||||
import { useEditDepth } from '../../utilities/EditDepth';
|
||||
import { ListDrawerContent } from './DrawerContent';
|
||||
import { useConfig } from '../../utilities/Config';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
@@ -49,21 +50,26 @@ export const ListDrawer: React.FC<ListDrawerProps> = (props) => {
|
||||
header={false}
|
||||
gutter={false}
|
||||
>
|
||||
<ListDrawerContent {...props} />
|
||||
<ListDrawerContent
|
||||
{...props}
|
||||
/>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export const useListDrawer: UseListDrawer = ({
|
||||
collectionSlugs,
|
||||
collectionSlugs: collectionSlugsFromProps,
|
||||
uploads,
|
||||
selectedCollection,
|
||||
filterOptions,
|
||||
}) => {
|
||||
const { collections } = useConfig();
|
||||
const drawerDepth = useEditDepth();
|
||||
const uuid = useId();
|
||||
const { modalState, toggleModal, closeModal, openModal } = useModal();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [collectionSlugs, setCollectionSlugs] = useState(collectionSlugsFromProps);
|
||||
|
||||
const drawerSlug = formatListDrawerSlug({
|
||||
depth: drawerDepth,
|
||||
uuid,
|
||||
@@ -73,6 +79,18 @@ export const useListDrawer: UseListDrawer = ({
|
||||
setIsOpen(Boolean(modalState[drawerSlug]?.isOpen));
|
||||
}, [modalState, drawerSlug]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!collectionSlugs || collectionSlugs.length === 0) {
|
||||
const filteredCollectionSlugs = collections.filter(({ upload }) => {
|
||||
if (uploads) {
|
||||
return Boolean(upload) === true;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
setCollectionSlugs(filteredCollectionSlugs.map(({ slug }) => slug));
|
||||
}
|
||||
}, [collectionSlugs, uploads, collections]);
|
||||
const toggleDrawer = useCallback(() => {
|
||||
toggleModal(drawerSlug);
|
||||
}, [toggleModal, drawerSlug]);
|
||||
|
||||
@@ -22,7 +22,7 @@ export type ListTogglerProps = HTMLAttributes<HTMLButtonElement> & {
|
||||
}
|
||||
|
||||
export type UseListDrawer = (args: {
|
||||
collectionSlugs: string[]
|
||||
collectionSlugs?: string[]
|
||||
selectedCollection?: string
|
||||
uploads?: boolean // finds all collections with upload: true
|
||||
filterOptions?: FilterOptionsResult
|
||||
|
||||
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;
|
||||
@@ -2,9 +2,14 @@
|
||||
|
||||
.clickable-arrow {
|
||||
cursor: pointer;
|
||||
transform: rotate(-90deg);
|
||||
|
||||
&--left {
|
||||
&--right {
|
||||
.icon {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
}
|
||||
|
||||
&--left .icon {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
.clickable-arrow--right {
|
||||
margin-right: base(.25);
|
||||
}
|
||||
|
||||
.clickable-arrow,
|
||||
&__page {
|
||||
@extend %btn-reset;
|
||||
|
||||
@@ -93,16 +93,7 @@ const Pagination: React.FC<Props> = (props) => {
|
||||
}
|
||||
|
||||
// Add prev and next arrows based on necessity
|
||||
nodes.push({
|
||||
type: 'ClickableArrow',
|
||||
props: {
|
||||
updatePage: () => updatePage(prevPage),
|
||||
isDisabled: !hasPrevPage,
|
||||
direction: 'left',
|
||||
},
|
||||
});
|
||||
|
||||
nodes.push({
|
||||
nodes.unshift({
|
||||
type: 'ClickableArrow',
|
||||
props: {
|
||||
updatePage: () => updatePage(nextPage),
|
||||
@@ -111,6 +102,15 @@ const Pagination: React.FC<Props> = (props) => {
|
||||
},
|
||||
});
|
||||
|
||||
nodes.unshift({
|
||||
type: 'ClickableArrow',
|
||||
props: {
|
||||
updatePage: () => updatePage(prevPage),
|
||||
isDisabled: !hasPrevPage,
|
||||
direction: 'left',
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={baseClass}>
|
||||
{nodes.map((node, i) => {
|
||||
|
||||
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'],
|
||||
}
|
||||
@@ -7,13 +7,12 @@ const baseClass = 'render-title';
|
||||
|
||||
const RenderTitle: React.FC<Props> = (props) => {
|
||||
const {
|
||||
useAsTitle,
|
||||
collection,
|
||||
title: titleFromProps,
|
||||
data,
|
||||
fallback = '[untitled]',
|
||||
} = props;
|
||||
const titleFromForm = useTitle(useAsTitle, collection);
|
||||
const titleFromForm = useTitle(collection);
|
||||
|
||||
let title = titleFromForm;
|
||||
if (!title) title = data?.id;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { SanitizedCollectionConfig } from '../../../../collections/config/types';
|
||||
|
||||
export type Props = {
|
||||
useAsTitle?: string
|
||||
data?: {
|
||||
@@ -5,5 +7,5 @@ export type Props = {
|
||||
}
|
||||
title?: string
|
||||
fallback?: string
|
||||
collection?: string
|
||||
collection?: SanitizedCollectionConfig
|
||||
}
|
||||
|
||||
@@ -7,10 +7,20 @@
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
right: base(.5);
|
||||
left: base(.5);
|
||||
}
|
||||
|
||||
&__input {
|
||||
@include formInput;
|
||||
box-shadow: none;
|
||||
padding-left: base(2);
|
||||
background-color: var(--theme-elevation-50);
|
||||
border: none;
|
||||
|
||||
&:not(:disabled) {
|
||||
&:hover, &:focus {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,53 +1,25 @@
|
||||
import React from 'react';
|
||||
import type { TFunction } from 'react-i18next';
|
||||
import Cell from '../../views/collections/List/Cell';
|
||||
import SortColumn from '../SortColumn';
|
||||
import { SanitizedCollectionConfig } from '../../../../collections/config/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 { 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 = ({
|
||||
collection,
|
||||
columns,
|
||||
t,
|
||||
cellProps,
|
||||
}: {
|
||||
collection: SanitizedCollectionConfig,
|
||||
columns: Pick<Column, 'accessor' | 'active'>[],
|
||||
t: TFunction,
|
||||
cellProps: Partial<CellProps>[]
|
||||
}): 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);
|
||||
|
||||
// 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 bIndex = columns.findIndex((column) => column.accessor === b.name);
|
||||
if (aIndex === -1 && bIndex === -1) return 0;
|
||||
@@ -97,6 +69,23 @@ const buildColumns = ({
|
||||
};
|
||||
});
|
||||
|
||||
cols.unshift({
|
||||
active: true,
|
||||
label: null,
|
||||
name: '',
|
||||
accessor: '_select',
|
||||
components: {
|
||||
Heading: (
|
||||
<SelectAll />
|
||||
),
|
||||
renderCell: (rowData) => (
|
||||
<SelectRow
|
||||
id={rowData.id}
|
||||
/>
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
return cols;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { TFunction } from 'react-i18next';
|
||||
import { SanitizedCollectionConfig } from '../../../../collections/config/types';
|
||||
import { Column } from '../Table/types';
|
||||
import buildColumns from './buildColumns';
|
||||
@@ -9,7 +8,6 @@ type TOGGLE = {
|
||||
type: 'toggle',
|
||||
payload: {
|
||||
column: string
|
||||
t: TFunction
|
||||
collection: SanitizedCollectionConfig
|
||||
cellProps: Partial<CellProps>[]
|
||||
}
|
||||
@@ -19,7 +17,6 @@ type SET = {
|
||||
type: 'set',
|
||||
payload: {
|
||||
columns: Pick<Column, 'accessor' | 'active'>[]
|
||||
t: TFunction
|
||||
collection: SanitizedCollectionConfig
|
||||
cellProps: Partial<CellProps>[]
|
||||
}
|
||||
@@ -30,7 +27,6 @@ type MOVE = {
|
||||
payload: {
|
||||
fromIndex: number
|
||||
toIndex: number
|
||||
t: TFunction
|
||||
collection: SanitizedCollectionConfig
|
||||
cellProps: Partial<CellProps>[]
|
||||
}
|
||||
@@ -43,7 +39,6 @@ export const columnReducer = (state: Column[], action: Action): Column[] => {
|
||||
case 'toggle': {
|
||||
const {
|
||||
column,
|
||||
t,
|
||||
collection,
|
||||
cellProps,
|
||||
} = action.payload;
|
||||
@@ -62,7 +57,6 @@ export const columnReducer = (state: Column[], action: Action): Column[] => {
|
||||
return buildColumns({
|
||||
columns: withToggledColumn,
|
||||
collection,
|
||||
t,
|
||||
cellProps,
|
||||
});
|
||||
}
|
||||
@@ -70,7 +64,6 @@ export const columnReducer = (state: Column[], action: Action): Column[] => {
|
||||
const {
|
||||
fromIndex,
|
||||
toIndex,
|
||||
t,
|
||||
collection,
|
||||
cellProps,
|
||||
} = action.payload;
|
||||
@@ -82,14 +75,12 @@ export const columnReducer = (state: Column[], action: Action): Column[] => {
|
||||
return buildColumns({
|
||||
columns: withMovedColumn,
|
||||
collection,
|
||||
t,
|
||||
cellProps,
|
||||
});
|
||||
}
|
||||
case 'set': {
|
||||
const {
|
||||
columns,
|
||||
t,
|
||||
collection,
|
||||
cellProps,
|
||||
} = action.payload;
|
||||
@@ -97,7 +88,6 @@ export const columnReducer = (state: Column[], action: Action): Column[] => {
|
||||
return buildColumns({
|
||||
columns,
|
||||
collection,
|
||||
t,
|
||||
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 { SanitizedCollectionConfig } from '../../../../collections/config/types';
|
||||
import { usePreferences } from '../../utilities/Preferences';
|
||||
@@ -8,6 +8,8 @@ import buildColumns from './buildColumns';
|
||||
import { Action, columnReducer } from './columnReducer';
|
||||
import getInitialColumnState from './getInitialColumns';
|
||||
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 {
|
||||
columns: Column[]
|
||||
@@ -33,21 +35,22 @@ export const TableColumnsProvider: React.FC<{
|
||||
cellProps,
|
||||
collection,
|
||||
collection: {
|
||||
fields,
|
||||
admin: {
|
||||
useAsTitle,
|
||||
defaultColumns,
|
||||
},
|
||||
},
|
||||
}) => {
|
||||
const { t } = useTranslation('general');
|
||||
const preferenceKey = `${collection.slug}-list`;
|
||||
const prevCollection = useRef<SanitizedCollectionConfig['slug']>();
|
||||
const hasInitialized = useRef(false);
|
||||
const { getPreference, setPreference } = usePreferences();
|
||||
const { t } = useTranslation();
|
||||
const [formattedFields] = useState<Field[]>(() => formatFields(collection, t));
|
||||
|
||||
const [tableColumns, dispatchTableColumns] = useReducer(columnReducer, {}, () => {
|
||||
const initialColumns = getInitialColumnState(fields, useAsTitle, defaultColumns);
|
||||
const initialColumns = getInitialColumnState(formattedFields, useAsTitle, defaultColumns);
|
||||
|
||||
return buildColumns({
|
||||
collection,
|
||||
columns: initialColumns.map((column) => ({
|
||||
@@ -55,7 +58,6 @@ export const TableColumnsProvider: React.FC<{
|
||||
active: true,
|
||||
})),
|
||||
cellProps,
|
||||
t,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -72,7 +74,7 @@ export const TableColumnsProvider: React.FC<{
|
||||
|
||||
const currentPreferences = await getPreference<ListPreferences>(preferenceKey);
|
||||
prevCollection.current = collection.slug;
|
||||
const initialColumns = getInitialColumnState(fields, useAsTitle, defaultColumns);
|
||||
const initialColumns = getInitialColumnState(formattedFields, useAsTitle, defaultColumns);
|
||||
const newCols = currentPreferences?.columns || initialColumns;
|
||||
|
||||
dispatchTableColumns({
|
||||
@@ -89,8 +91,7 @@ export const TableColumnsProvider: React.FC<{
|
||||
}
|
||||
return column;
|
||||
}),
|
||||
t,
|
||||
collection,
|
||||
collection: { ...collection, fields: formatFields(collection, t) },
|
||||
cellProps,
|
||||
},
|
||||
});
|
||||
@@ -100,7 +101,7 @@ export const TableColumnsProvider: React.FC<{
|
||||
};
|
||||
|
||||
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
|
||||
@@ -130,12 +131,11 @@ export const TableColumnsProvider: React.FC<{
|
||||
dispatchTableColumns({
|
||||
type: 'set',
|
||||
payload: {
|
||||
collection,
|
||||
collection: { ...collection, fields: formatFields(collection, t) },
|
||||
columns: columns.map((column) => ({
|
||||
accessor: column,
|
||||
active: true,
|
||||
})),
|
||||
t,
|
||||
// onSelect,
|
||||
cellProps,
|
||||
},
|
||||
@@ -153,8 +153,7 @@ export const TableColumnsProvider: React.FC<{
|
||||
payload: {
|
||||
fromIndex,
|
||||
toIndex,
|
||||
collection,
|
||||
t,
|
||||
collection: { ...collection, fields: formatFields(collection, t) },
|
||||
cellProps,
|
||||
},
|
||||
});
|
||||
@@ -165,8 +164,7 @@ export const TableColumnsProvider: React.FC<{
|
||||
type: 'toggle',
|
||||
payload: {
|
||||
column,
|
||||
collection,
|
||||
t,
|
||||
collection: { ...collection, fields: formatFields(collection, t) },
|
||||
cellProps,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import React, { Fragment } from 'react';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Props } from './types';
|
||||
import Thumbnail from '../Thumbnail';
|
||||
import { useConfig } from '../../utilities/Config';
|
||||
import { formatUseAsTitle } from '../../../hooks/useTitle';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
@@ -14,12 +16,13 @@ export const ThumbnailCard: React.FC<Props> = (props) => {
|
||||
doc,
|
||||
collection,
|
||||
thumbnail,
|
||||
label,
|
||||
label: labelFromProps,
|
||||
alignLabel,
|
||||
onKeyDown,
|
||||
} = props;
|
||||
|
||||
const { t } = useTranslation('general');
|
||||
const { t, i18n } = useTranslation('general');
|
||||
const config = useConfig();
|
||||
|
||||
const classes = [
|
||||
baseClass,
|
||||
@@ -28,8 +31,20 @@ export const ThumbnailCard: React.FC<Props> = (props) => {
|
||||
alignLabel && `${baseClass}--align-label-${alignLabel}`,
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
let title = labelFromProps;
|
||||
|
||||
if (!title) {
|
||||
title = formatUseAsTitle({
|
||||
doc,
|
||||
collection,
|
||||
i18n,
|
||||
config,
|
||||
}) || doc?.filename as string || `[${t('untitled')}]`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
title={title}
|
||||
className={classes}
|
||||
onClick={typeof onClick === 'function' ? onClick : undefined}
|
||||
onKeyDown={typeof onKeyDown === 'function' ? onKeyDown : undefined}
|
||||
@@ -45,12 +60,7 @@ export const ThumbnailCard: React.FC<Props> = (props) => {
|
||||
)}
|
||||
</div>
|
||||
<div className={`${baseClass}__label`}>
|
||||
{label && label}
|
||||
{!label && doc && (
|
||||
<Fragment>
|
||||
{typeof doc?.filename === 'string' ? doc?.filename : `[${t('untitled')}]`}
|
||||
</Fragment>
|
||||
)}
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
@import '../../../scss/styles.scss';
|
||||
|
||||
$caretSize: 6;
|
||||
|
||||
.tooltip {
|
||||
--caret-size: 6px;
|
||||
|
||||
opacity: 0;
|
||||
background-color: var(--theme-elevation-800);
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translate3d(-50%, calc(#{$caretSize}px * -1), 0);
|
||||
padding: base(.2) base(.4);
|
||||
color: var(--theme-elevation-0);
|
||||
line-height: base(.75);
|
||||
@@ -22,14 +20,12 @@ $caretSize: 6;
|
||||
content: ' ';
|
||||
display: block;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translate3d(-50%, 100%, 0);
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: #{$caretSize}px solid transparent;
|
||||
border-right: #{$caretSize}px solid transparent;
|
||||
border-top: #{$caretSize}px solid var(--theme-elevation-800);
|
||||
border-left: var(--caret-size) solid transparent;
|
||||
border-right: var(--caret-size) solid transparent;
|
||||
}
|
||||
|
||||
&--show {
|
||||
@@ -39,6 +35,26 @@ $caretSize: 6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
&--position-top {
|
||||
bottom: 100%;
|
||||
transform: translate3d(-50%, calc(var(--caret-size) * -1), 0);
|
||||
|
||||
&::after {
|
||||
bottom: 1px;
|
||||
border-top: var(--caret-size) solid var(--theme-elevation-800);
|
||||
}
|
||||
}
|
||||
|
||||
&--position-bottom {
|
||||
top: 100%;
|
||||
transform: translate3d(-50%, var(--caret-size), 0);
|
||||
|
||||
&::after {
|
||||
bottom: calc(100% + var(--caret-size) - 1px);
|
||||
border-bottom: var(--caret-size) solid var(--theme-elevation-800);
|
||||
}
|
||||
}
|
||||
|
||||
@include mid-break {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { Props } from './types';
|
||||
import useIntersect from '../../../hooks/useIntersect';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
@@ -9,9 +10,18 @@ const Tooltip: React.FC<Props> = (props) => {
|
||||
children,
|
||||
show: showFromProps = true,
|
||||
delay = 350,
|
||||
boundingRef,
|
||||
} = props;
|
||||
|
||||
const [show, setShow] = React.useState(showFromProps);
|
||||
const [position, setPosition] = React.useState<'top' | 'bottom'>('top');
|
||||
|
||||
const [ref, intersectionEntry] = useIntersect({
|
||||
threshold: 0,
|
||||
rootMargin: '-145px 0px 0px 100px',
|
||||
root: boundingRef?.current || null,
|
||||
});
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
let timerId: NodeJS.Timeout;
|
||||
@@ -30,16 +40,35 @@ const Tooltip: React.FC<Props> = (props) => {
|
||||
};
|
||||
}, [showFromProps, delay]);
|
||||
|
||||
useEffect(() => {
|
||||
setPosition(intersectionEntry?.isIntersecting ? 'top' : 'bottom');
|
||||
}, [intersectionEntry]);
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={[
|
||||
'tooltip',
|
||||
className,
|
||||
show && 'tooltip--show',
|
||||
].filter(Boolean).join(' ')}
|
||||
>
|
||||
{children}
|
||||
</aside>
|
||||
<React.Fragment>
|
||||
<aside
|
||||
ref={ref}
|
||||
className={[
|
||||
'tooltip',
|
||||
className,
|
||||
'tooltip--position-top',
|
||||
].filter(Boolean).join(' ')}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{children}
|
||||
</aside>
|
||||
|
||||
<aside
|
||||
className={[
|
||||
'tooltip',
|
||||
className,
|
||||
show && 'tooltip--show',
|
||||
`tooltip--position-${position}`,
|
||||
].filter(Boolean).join(' ')}
|
||||
>
|
||||
{children}
|
||||
</aside>
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -3,4 +3,5 @@ export type Props = {
|
||||
children: React.ReactNode
|
||||
show?: boolean
|
||||
delay?: number
|
||||
boundingRef?: React.RefObject<HTMLElement>
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
@@ -154,7 +154,7 @@ const Form: React.FC<Props> = (props) => {
|
||||
// If submit handler comes through via props, run that
|
||||
if (onSubmit) {
|
||||
const data = {
|
||||
...reduceFieldsToValues(fields),
|
||||
...reduceFieldsToValues(fields, true),
|
||||
...overrides,
|
||||
};
|
||||
|
||||
|
||||
@@ -7,10 +7,11 @@ import { useConfig } from '../../utilities/Config';
|
||||
import { useForm } from '../Form/context';
|
||||
|
||||
type NullifyLocaleFieldProps = {
|
||||
localized: boolean
|
||||
path: string
|
||||
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 currentLocale = useLocale();
|
||||
const { localization } = useConfig();
|
||||
@@ -30,8 +31,8 @@ export const NullifyLocaleField: React.FC<NullifyLocaleFieldProps> = ({ path, fi
|
||||
setChecked(useFallback);
|
||||
};
|
||||
|
||||
if (currentLocale === defaultLocale || (localization && !localization.fallback)) {
|
||||
// hide when editing default locale or when fallback is disabled
|
||||
if (!localized || currentLocale === defaultLocale || (localization && !localization.fallback)) {
|
||||
// hide when field is not localized or editing default locale or when fallback is disabled
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ const ArrayFieldType: React.FC<Props> = (props) => {
|
||||
minRows,
|
||||
permissions,
|
||||
indexPath,
|
||||
localized,
|
||||
admin: {
|
||||
readOnly,
|
||||
description,
|
||||
@@ -260,6 +261,7 @@ const ArrayFieldType: React.FC<Props> = (props) => {
|
||||
</header>
|
||||
|
||||
<NullifyLocaleField
|
||||
localized={localized}
|
||||
path={path}
|
||||
fieldValue={value}
|
||||
/>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
&__block {
|
||||
margin: base(0.5);
|
||||
width: calc(12.5% - #{base(1)});
|
||||
width: calc((100% / 6) - #{base(1)});
|
||||
}
|
||||
|
||||
&__default-image {
|
||||
|
||||
@@ -53,6 +53,7 @@ const BlocksField: React.FC<Props> = (props) => {
|
||||
validate = blocksValidator,
|
||||
permissions,
|
||||
indexPath,
|
||||
localized,
|
||||
admin: {
|
||||
readOnly,
|
||||
description,
|
||||
@@ -257,6 +258,7 @@ const BlocksField: React.FC<Props> = (props) => {
|
||||
</header>
|
||||
|
||||
<NullifyLocaleField
|
||||
localized={localized}
|
||||
path={path}
|
||||
fieldValue={value}
|
||||
/>
|
||||
|
||||
@@ -10,6 +10,7 @@ import Plus from '../../../../icons/Plus';
|
||||
import { getTranslation } from '../../../../../../utilities/getTranslation';
|
||||
import Tooltip from '../../../../elements/Tooltip';
|
||||
import { useDocumentDrawer } from '../../../../elements/DocumentDrawer';
|
||||
import { useConfig } from '../../../../utilities/Config';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
@@ -25,6 +26,8 @@ export const AddNewRelation: React.FC<Props> = ({ path, hasMany, relationTo, val
|
||||
const [popupOpen, setPopupOpen] = useState(false);
|
||||
const { t, i18n } = useTranslation('fields');
|
||||
const [showTooltip, setShowTooltip] = useState(false);
|
||||
const config = useConfig();
|
||||
|
||||
const [
|
||||
DocumentDrawer,
|
||||
DocumentDrawerToggler,
|
||||
@@ -47,6 +50,7 @@ export const AddNewRelation: React.FC<Props> = ({ path, hasMany, relationTo, val
|
||||
],
|
||||
sort: true,
|
||||
i18n,
|
||||
config,
|
||||
});
|
||||
|
||||
if (hasMany) {
|
||||
@@ -56,7 +60,7 @@ export const AddNewRelation: React.FC<Props> = ({ path, hasMany, relationTo, val
|
||||
}
|
||||
|
||||
setSelectedCollection(undefined);
|
||||
}, [relationTo, collectionConfig, dispatchOptions, i18n, hasMany, setValue, value]);
|
||||
}, [relationTo, collectionConfig, dispatchOptions, i18n, hasMany, setValue, value, config]);
|
||||
|
||||
const onPopopToggle = useCallback((state) => {
|
||||
setPopupOpen(state);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Value } from './types';
|
||||
|
||||
type RelationMap = {
|
||||
[relation: string]: unknown[]
|
||||
[relation: string]: (string | number)[]
|
||||
}
|
||||
|
||||
type CreateRelationMap = (args: {
|
||||
@@ -31,7 +31,11 @@ export const createRelationMap: CreateRelationMap = ({
|
||||
|
||||
const add = (relation: string, id: unknown) => {
|
||||
if (((typeof id === 'string') || typeof id === 'number') && typeof relation === 'string') {
|
||||
relationMap[relation].push(id);
|
||||
if (relationMap[relation]) {
|
||||
relationMap[relation].push(id);
|
||||
} else {
|
||||
relationMap[relation] = [id];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -56,13 +56,15 @@ const Relationship: React.FC<Props> = (props) => {
|
||||
} = {},
|
||||
} = props;
|
||||
|
||||
const config = useConfig();
|
||||
|
||||
const {
|
||||
serverURL,
|
||||
routes: {
|
||||
api,
|
||||
},
|
||||
collections,
|
||||
} = useConfig();
|
||||
} = config;
|
||||
|
||||
const { t, i18n } = useTranslation('fields');
|
||||
const { permissions } = useAuth();
|
||||
@@ -172,9 +174,19 @@ const Relationship: React.FC<Props> = (props) => {
|
||||
|
||||
if (response.ok) {
|
||||
const data: PaginatedDocs<unknown> = await response.json();
|
||||
|
||||
if (data.docs.length > 0) {
|
||||
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);
|
||||
|
||||
if (!data.nextPage) {
|
||||
@@ -190,7 +202,15 @@ const Relationship: React.FC<Props> = (props) => {
|
||||
} else if (response.status === 403) {
|
||||
setLastFullyLoadedRelation(relations.indexOf(relation));
|
||||
lastLoadedPageToUse = 1;
|
||||
dispatchOptions({ type: 'ADD', docs: [], collection, sort, ids: relationMap[relation], i18n });
|
||||
dispatchOptions({
|
||||
type: 'ADD',
|
||||
docs: [],
|
||||
collection,
|
||||
sort,
|
||||
ids: relationMap[relation],
|
||||
i18n,
|
||||
config,
|
||||
});
|
||||
} else {
|
||||
setErrorLoading(t('error:unspecific'));
|
||||
}
|
||||
@@ -211,6 +231,7 @@ const Relationship: React.FC<Props> = (props) => {
|
||||
t,
|
||||
i18n,
|
||||
locale,
|
||||
config,
|
||||
]);
|
||||
|
||||
const updateSearch = useDebouncedCallback((searchArg: string, valueArg: Value | Value[]) => {
|
||||
@@ -261,13 +282,24 @@ const Relationship: React.FC<Props> = (props) => {
|
||||
'Accept-Language': i18n.language,
|
||||
},
|
||||
});
|
||||
|
||||
const collection = collections.find((coll) => coll.slug === relation);
|
||||
let docs = [];
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
dispatchOptions({ type: 'ADD', docs: data.docs, collection, sort: true, ids: idsToLoad, i18n });
|
||||
} else if (response.status === 403) {
|
||||
dispatchOptions({ type: 'ADD', docs: [], collection, sort: true, ids: idsToLoad, i18n });
|
||||
docs = data.docs;
|
||||
}
|
||||
|
||||
dispatchOptions({
|
||||
type: 'ADD',
|
||||
docs,
|
||||
collection,
|
||||
sort: true,
|
||||
ids: idsToLoad,
|
||||
i18n,
|
||||
config,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, Promise.resolve());
|
||||
@@ -283,6 +315,7 @@ const Relationship: React.FC<Props> = (props) => {
|
||||
i18n,
|
||||
relationTo,
|
||||
locale,
|
||||
config,
|
||||
]);
|
||||
|
||||
// Determine if we should switch to word boundary search
|
||||
@@ -311,8 +344,8 @@ const Relationship: React.FC<Props> = (props) => {
|
||||
}, [relationTo, filterOptionsResult, locale]);
|
||||
|
||||
const onSave = useCallback<DocumentDrawerProps['onSave']>((args) => {
|
||||
dispatchOptions({ type: 'UPDATE', doc: args.doc, collection: args.collectionConfig, i18n });
|
||||
}, [i18n]);
|
||||
dispatchOptions({ type: 'UPDATE', doc: args.doc, collection: args.collectionConfig, i18n, config });
|
||||
}, [i18n, config]);
|
||||
|
||||
const classes = [
|
||||
'field-type',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Option, Action, OptionGroup } from './types';
|
||||
import { getTranslation } from '../../../../../utilities/getTranslation';
|
||||
import { formatUseAsTitle } from '../../../../hooks/useTitle';
|
||||
|
||||
const reduceToIDs = (options) => options.reduce((ids, option) => {
|
||||
if (option.options) {
|
||||
@@ -30,15 +31,22 @@ const optionsReducer = (state: OptionGroup[], action: Action): OptionGroup[] =>
|
||||
}
|
||||
|
||||
case 'UPDATE': {
|
||||
const { collection, doc, i18n } = action;
|
||||
const { collection, doc, i18n, config } = action;
|
||||
const relation = collection.slug;
|
||||
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 foundOption = foundOptionGroup?.options?.find((option) => option.value === doc.id);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -46,9 +54,8 @@ const optionsReducer = (state: OptionGroup[], action: Action): OptionGroup[] =>
|
||||
}
|
||||
|
||||
case 'ADD': {
|
||||
const { collection, docs, sort, ids = [], i18n } = action;
|
||||
const { collection, docs, sort, ids = [], i18n, config } = action;
|
||||
const relation = collection.slug;
|
||||
const labelKey = collection.admin.useAsTitle || 'id';
|
||||
const loadedIDs = reduceToIDs(state);
|
||||
const newOptions = [...state];
|
||||
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) {
|
||||
loadedIDs.push(doc.id);
|
||||
|
||||
const docTitle = formatUseAsTitle({
|
||||
doc,
|
||||
collection,
|
||||
i18n,
|
||||
config,
|
||||
});
|
||||
|
||||
return [
|
||||
...docSubOptions,
|
||||
{
|
||||
label: doc[labelKey] || `${i18n.t('general:untitled')} - ID: ${doc.id}`,
|
||||
label: docTitle || `${i18n.t('general:untitled')} - ID: ${doc.id}`,
|
||||
relationTo: relation,
|
||||
value: doc.id,
|
||||
},
|
||||
@@ -74,7 +88,7 @@ const optionsReducer = (state: OptionGroup[], action: Action): OptionGroup[] =>
|
||||
if (!loadedIDs.includes(id)) {
|
||||
newSubOptions.push({
|
||||
relationTo: relation,
|
||||
label: labelKey === 'id' ? id : `${i18n.t('general:untitled')} - ID: ${id}`,
|
||||
label: `${i18n.t('general:untitled')} - ID: ${id}`,
|
||||
value: id,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import i18n from 'i18next';
|
||||
import { SanitizedCollectionConfig } from '../../../../../collections/config/types';
|
||||
import { RelationshipField } from '../../../../../fields/config/types';
|
||||
import { Where } from '../../../../../types';
|
||||
import { SanitizedConfig } from '../../../../../config/types';
|
||||
|
||||
export type Props = Omit<RelationshipField, 'type'> & {
|
||||
path?: string
|
||||
@@ -35,6 +36,7 @@ type UPDATE = {
|
||||
doc: any
|
||||
collection: SanitizedCollectionConfig
|
||||
i18n: typeof i18n
|
||||
config: SanitizedConfig
|
||||
}
|
||||
|
||||
type ADD = {
|
||||
@@ -42,8 +44,9 @@ type ADD = {
|
||||
docs: any[]
|
||||
collection: SanitizedCollectionConfig
|
||||
sort?: boolean
|
||||
ids?: unknown[]
|
||||
ids?: (string | number)[]
|
||||
i18n: typeof i18n
|
||||
config: SanitizedConfig
|
||||
}
|
||||
|
||||
export type Action = CLEAR | ADD | UPDATE
|
||||
|
||||
@@ -14,6 +14,7 @@ import RenderFields from '../../../../../../RenderFields';
|
||||
import FormSubmit from '../../../../../../Submit';
|
||||
import buildStateFromSchema from '../../../../../../Form/buildStateFromSchema';
|
||||
import { getTranslation } from '../../../../../../../../../utilities/getTranslation';
|
||||
import deepCopyObject from '../../../../../../../../../utilities/deepCopyObject';
|
||||
|
||||
export const UploadDrawer: React.FC<ElementProps & {
|
||||
drawerSlug: string
|
||||
@@ -52,7 +53,7 @@ export const UploadDrawer: React.FC<ElementProps & {
|
||||
|
||||
useEffect(() => {
|
||||
const awaitInitialState = async () => {
|
||||
const state = await buildStateFromSchema({ fieldSchema, data: { ...element?.fields || {} }, user, operation: 'update', locale, t });
|
||||
const state = await buildStateFromSchema({ fieldSchema, data: deepCopyObject(element?.fields || {}), user, operation: 'update', locale, t });
|
||||
setInitialState(state);
|
||||
};
|
||||
|
||||
|
||||
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;
|
||||
@@ -81,6 +81,21 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
requests.post(`${serverURL}${api}/${userSlug}/logout`);
|
||||
}, [serverURL, api, userSlug]);
|
||||
|
||||
const refreshPermissions = useCallback(async () => {
|
||||
const request = await requests.get(`${serverURL}${api}/access`, {
|
||||
headers: {
|
||||
'Accept-Language': i18n.language,
|
||||
},
|
||||
});
|
||||
|
||||
if (request.status === 200) {
|
||||
const json: Permissions = await request.json();
|
||||
setPermissions(json);
|
||||
} else {
|
||||
throw new Error("Fetching permissions failed with status code " + request.status);
|
||||
}
|
||||
}, [serverURL, api, i18n]);
|
||||
|
||||
// On mount, get user and set
|
||||
useEffect(() => {
|
||||
const fetchMe = async () => {
|
||||
@@ -117,21 +132,8 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
|
||||
// When user changes, get new access
|
||||
useEffect(() => {
|
||||
async function getPermissions() {
|
||||
const request = await requests.get(`${serverURL}${api}/access`, {
|
||||
headers: {
|
||||
'Accept-Language': i18n.language,
|
||||
},
|
||||
});
|
||||
|
||||
if (request.status === 200) {
|
||||
const json: Permissions = await request.json();
|
||||
setPermissions(json);
|
||||
}
|
||||
}
|
||||
|
||||
if (id) {
|
||||
getPermissions();
|
||||
refreshPermissions();
|
||||
}
|
||||
}, [i18n, id, api, serverURL]);
|
||||
|
||||
@@ -174,6 +176,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
user,
|
||||
logOut,
|
||||
refreshCookie,
|
||||
refreshPermissions,
|
||||
permissions,
|
||||
setToken,
|
||||
token: tokenInMemory,
|
||||
|
||||
@@ -6,5 +6,6 @@ export type AuthContext<T = User> = {
|
||||
refreshCookie: () => void
|
||||
setToken: (token: string) => void
|
||||
token?: string
|
||||
refreshPermissions: () => Promise<void>
|
||||
permissions?: Permissions
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useConfig } from '../Config';
|
||||
import { Props } from './types';
|
||||
import payloadFavicon from '../../../assets/images/favicon.svg';
|
||||
import payloadOgImage from '../../../assets/images/og-image.png';
|
||||
import useMountEffect from '../../../hooks/useMountEffect';
|
||||
|
||||
const Meta: React.FC<Props> = ({
|
||||
description,
|
||||
@@ -17,6 +18,13 @@ const Meta: React.FC<Props> = ({
|
||||
const favicon = config.admin.meta.favicon ?? payloadFavicon;
|
||||
const ogImage = config.admin.meta.ogImage ?? payloadOgImage;
|
||||
|
||||
useMountEffect(() => {
|
||||
const faviconElement = document.querySelector<HTMLLinkElement>('link[data-placeholder-favicon]');
|
||||
if (faviconElement) {
|
||||
faviconElement.remove();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Helmet
|
||||
htmlAttributes={{
|
||||
|
||||
@@ -96,7 +96,7 @@ const DefaultAccount: React.FC<Props> = (props) => {
|
||||
<h1>
|
||||
<RenderTitle
|
||||
data={data}
|
||||
collection={collection.slug}
|
||||
collection={collection}
|
||||
useAsTitle={useAsTitle}
|
||||
fallback={`[${t('general:untitled')}]`}
|
||||
/>
|
||||
|
||||
@@ -14,9 +14,9 @@ const baseClass = 'select-diff';
|
||||
|
||||
const getOptionsToRender = (value: string, options: SelectField['options'], hasMany: boolean): string | OptionObject | (OptionObject | string)[] => {
|
||||
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 => {
|
||||
|
||||
@@ -21,8 +21,8 @@ const Tabs: React.FC<Props & { field: TabsField }> = ({
|
||||
return (
|
||||
<Nested
|
||||
key={i}
|
||||
version={version[tab.name]}
|
||||
comparison={comparison[tab.name]}
|
||||
version={version?.[tab.name]}
|
||||
comparison={comparison?.[tab.name]}
|
||||
permissions={permissions}
|
||||
field={tab}
|
||||
locales={locales}
|
||||
|
||||
@@ -128,7 +128,7 @@ const DefaultEditView: React.FC<Props> = (props) => {
|
||||
<h1>
|
||||
<RenderTitle
|
||||
data={data}
|
||||
collection={collection.slug}
|
||||
collection={collection}
|
||||
useAsTitle={useAsTitle}
|
||||
fallback={`[${t('untitled')}]`}
|
||||
/>
|
||||
|
||||
@@ -26,7 +26,7 @@ export const SetStepNav: React.FC<{
|
||||
const { t, i18n } = useTranslation('general');
|
||||
const { routes: { admin } } = useConfig();
|
||||
|
||||
const title = useTitle(useAsTitle, collection.slug);
|
||||
const title = useTitle(collection);
|
||||
|
||||
useEffect(() => {
|
||||
const nav: StepNavItem[] = [{
|
||||
|
||||
@@ -59,6 +59,10 @@
|
||||
.file-field__drop-zone {
|
||||
border-color: var(--theme-success-500);
|
||||
background: var(--theme-success-150);
|
||||
|
||||
* {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useConfig } from '../../../../../../utilities/Config';
|
||||
import useIntersect from '../../../../../../../hooks/useIntersect';
|
||||
import { useListRelationships } from '../../../RelationshipProvider';
|
||||
import { getTranslation } from '../../../../../../../../utilities/getTranslation';
|
||||
import { formatUseAsTitle } from '../../../../../../../hooks/useTitle';
|
||||
import { Props as DefaultCellProps } from '../../types';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
@@ -11,9 +13,13 @@ type Value = { relationTo: string, value: number | string };
|
||||
const baseClass = 'relationship-cell';
|
||||
const totalToShow = 3;
|
||||
|
||||
const RelationshipCell = (props) => {
|
||||
const RelationshipCell: React.FC<{
|
||||
field: DefaultCellProps['field']
|
||||
data: DefaultCellProps['cellData']
|
||||
}> = (props) => {
|
||||
const { field, data: cellData } = props;
|
||||
const { collections, routes } = useConfig();
|
||||
const config = useConfig();
|
||||
const { collections, routes } = config;
|
||||
const [intersectionRef, entry] = useIntersect();
|
||||
const [values, setValues] = useState<Value[]>([]);
|
||||
const { getRelationships, documents } = useListRelationships();
|
||||
@@ -31,7 +37,7 @@ const RelationshipCell = (props) => {
|
||||
if (typeof cell === 'object' && 'relationTo' in cell && 'value' in 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({
|
||||
value: cell,
|
||||
relationTo: field.relationTo,
|
||||
@@ -52,13 +58,19 @@ const RelationshipCell = (props) => {
|
||||
{values.map(({ relationTo, value }, i) => {
|
||||
const document = documents[relationTo][value];
|
||||
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 (
|
||||
<React.Fragment key={i}>
|
||||
{document === false && `${t('untitled')} - ID: ${value}`}
|
||||
{document === null && `${t('loading')}...`}
|
||||
{document && label}
|
||||
{document && (label || `${t('untitled')} - ID: ${value}`)}
|
||||
{values.length > i + 1 && ', '}
|
||||
</React.Fragment>
|
||||
);
|
||||
@@ -67,7 +79,7 @@ const RelationshipCell = (props) => {
|
||||
Array.isArray(cellData) && 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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
@import '../../../../../../../scss/styles.scss';
|
||||
|
||||
.upload-cell {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
margin: base(-.25) 0;
|
||||
|
||||
.thumbnail {
|
||||
max-width: base(3);
|
||||
height: base(3);
|
||||
}
|
||||
|
||||
&__filename {
|
||||
align-self: center;
|
||||
margin-left: base(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import Thumbnail from '../../../../../../elements/Thumbnail';
|
||||
import type { Props } from './types';
|
||||
import { useConfig } from '../../../../../../utilities/Config';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
const baseClass = 'upload-cell';
|
||||
|
||||
const UploadCell:React.FC<Props> = ({ rowData, cellData, collection }: Props) => {
|
||||
const { routes: { admin } } = useConfig();
|
||||
|
||||
return (
|
||||
<Link
|
||||
className={baseClass}
|
||||
to={`${admin}/collections/${collection.slug}/${rowData.id}`}
|
||||
>
|
||||
<Thumbnail
|
||||
size="small"
|
||||
doc={{
|
||||
...rowData,
|
||||
filename: cellData,
|
||||
}}
|
||||
collection={collection}
|
||||
/>
|
||||
<span className={`${baseClass}__filename`}>{ String(cellData) }</span>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
export default UploadCell;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { SanitizedCollectionConfig } from '../../../../../../../../collections/config/types';
|
||||
import { Props as CellProps } from '../../types';
|
||||
|
||||
export type Props = CellProps & {
|
||||
collection: SanitizedCollectionConfig
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
import React, { Fragment } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useConfig } from '../../../utilities/Config';
|
||||
import UploadGallery from '../../../elements/UploadGallery';
|
||||
import { useWindowInfo } from '@faceless-ui/window-info';
|
||||
import Eyebrow from '../../../elements/Eyebrow';
|
||||
import Paginator from '../../../elements/Paginator';
|
||||
import ListControls from '../../../elements/ListControls';
|
||||
import ListSelection from '../../../elements/ListSelection';
|
||||
import Pill from '../../../elements/Pill';
|
||||
import Button from '../../../elements/Button';
|
||||
import { Table } from '../../../elements/Table';
|
||||
@@ -17,6 +16,11 @@ import { Gutter } from '../../../elements/Gutter';
|
||||
import { RelationshipProvider } from './RelationshipProvider';
|
||||
import { getTranslation } from '../../../../../utilities/getTranslation';
|
||||
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';
|
||||
|
||||
@@ -26,8 +30,6 @@ const DefaultList: React.FC<Props> = (props) => {
|
||||
const {
|
||||
collection,
|
||||
collection: {
|
||||
upload,
|
||||
slug,
|
||||
labels: {
|
||||
singular: singularLabel,
|
||||
plural: pluralLabel,
|
||||
@@ -42,17 +44,15 @@ const DefaultList: React.FC<Props> = (props) => {
|
||||
hasCreatePermission,
|
||||
disableEyebrow,
|
||||
modifySearchParams,
|
||||
disableCardLink,
|
||||
onCardClick,
|
||||
handleSortChange,
|
||||
handleWhereChange,
|
||||
handlePageChange,
|
||||
handlePerPageChange,
|
||||
customHeader,
|
||||
resetParams,
|
||||
} = props;
|
||||
|
||||
const { routes: { admin } } = useConfig();
|
||||
const history = useHistory();
|
||||
const { breakpoints: { s: smallBreak } } = useWindowInfo();
|
||||
const { t, i18n } = useTranslation('general');
|
||||
|
||||
return (
|
||||
@@ -60,117 +60,135 @@ const DefaultList: React.FC<Props> = (props) => {
|
||||
<Meta
|
||||
title={getTranslation(collection.labels.plural, i18n)}
|
||||
/>
|
||||
{!disableEyebrow && (
|
||||
<Eyebrow />
|
||||
)}
|
||||
<Gutter className={`${baseClass}__wrap`}>
|
||||
<header className={`${baseClass}__header`}>
|
||||
{customHeader && customHeader}
|
||||
{!customHeader && (
|
||||
<Fragment>
|
||||
<h1>
|
||||
{getTranslation(pluralLabel, i18n)}
|
||||
</h1>
|
||||
{hasCreatePermission && (
|
||||
<Pill to={newDocumentURL}>
|
||||
{t('createNew')}
|
||||
</Pill>
|
||||
)}
|
||||
{description && (
|
||||
<div className={`${baseClass}__sub-header`}>
|
||||
<ViewDescription description={description} />
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
)}
|
||||
</header>
|
||||
<ListControls
|
||||
collection={collection}
|
||||
enableColumns={Boolean(!upload)}
|
||||
enableSort={Boolean(upload)}
|
||||
modifySearchQuery={modifySearchParams}
|
||||
handleSortChange={handleSortChange}
|
||||
handleWhereChange={handleWhereChange}
|
||||
/>
|
||||
{!data.docs && (
|
||||
<StaggeredShimmers
|
||||
className={[
|
||||
`${baseClass}__shimmer`,
|
||||
upload ? `${baseClass}__shimmer--uploads` : `${baseClass}__shimmer--rows`,
|
||||
].filter(Boolean).join(' ')}
|
||||
count={6}
|
||||
width={upload ? 'unset' : '100%'}
|
||||
<SelectionProvider
|
||||
docs={data.docs}
|
||||
totalDocs={data.totalDocs}
|
||||
>
|
||||
{!disableEyebrow && (
|
||||
<Eyebrow />
|
||||
)}
|
||||
<Gutter className={`${baseClass}__wrap`}>
|
||||
<header className={`${baseClass}__header`}>
|
||||
{customHeader && customHeader}
|
||||
{!customHeader && (
|
||||
<Fragment>
|
||||
<h1>
|
||||
{getTranslation(pluralLabel, i18n)}
|
||||
</h1>
|
||||
{hasCreatePermission && (
|
||||
<Pill to={newDocumentURL}>
|
||||
{t('createNew')}
|
||||
</Pill>
|
||||
)}
|
||||
{!smallBreak && (
|
||||
<ListSelection
|
||||
label={getTranslation(collection.labels.plural, i18n)}
|
||||
/>
|
||||
)}
|
||||
{description && (
|
||||
<div className={`${baseClass}__sub-header`}>
|
||||
<ViewDescription description={description} />
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
)}
|
||||
</header>
|
||||
<ListControls
|
||||
collection={collection}
|
||||
modifySearchQuery={modifySearchParams}
|
||||
handleSortChange={handleSortChange}
|
||||
handleWhereChange={handleWhereChange}
|
||||
resetParams={resetParams}
|
||||
/>
|
||||
)}
|
||||
{(data.docs && data.docs.length > 0) && (
|
||||
<React.Fragment>
|
||||
{!upload && (
|
||||
<RelationshipProvider>
|
||||
<Table data={data.docs} />
|
||||
</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 && (
|
||||
<div className={`${baseClass}__no-results`}>
|
||||
<p>
|
||||
{t('noResults', { label: getTranslation(pluralLabel, i18n) })}
|
||||
</p>
|
||||
{hasCreatePermission && newDocumentURL && (
|
||||
<Button
|
||||
el="link"
|
||||
to={newDocumentURL}
|
||||
>
|
||||
{t('createNewLabel', { label: getTranslation(singularLabel, i18n) })}
|
||||
</Button>
|
||||
{!data.docs && (
|
||||
<StaggeredShimmers
|
||||
className={[`${baseClass}__shimmer`, `${baseClass}__shimmer--rows`].join(' ')}
|
||||
count={6}
|
||||
/>
|
||||
)}
|
||||
{(data.docs && data.docs.length > 0) && (
|
||||
<RelationshipProvider>
|
||||
<Table data={data.docs} />
|
||||
</RelationshipProvider>
|
||||
)}
|
||||
{data.docs && data.docs.length === 0 && (
|
||||
<div className={`${baseClass}__no-results`}>
|
||||
<p>
|
||||
{t('noResults', { label: getTranslation(pluralLabel, i18n) })}
|
||||
</p>
|
||||
{hasCreatePermission && newDocumentURL && (
|
||||
<Button
|
||||
el="link"
|
||||
to={newDocumentURL}
|
||||
>
|
||||
{t('createNewLabel', { label: getTranslation(singularLabel, i18n) })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className={`${baseClass}__page-controls`}>
|
||||
<Paginator
|
||||
limit={data.limit}
|
||||
totalPages={data.totalPages}
|
||||
page={data.page}
|
||||
hasPrevPage={data.hasPrevPage}
|
||||
hasNextPage={data.hasNextPage}
|
||||
prevPage={data.prevPage}
|
||||
nextPage={data.nextPage}
|
||||
numberOfNeighbors={1}
|
||||
disableHistoryChange={modifySearchParams === false}
|
||||
onChange={handlePageChange}
|
||||
/>
|
||||
{data?.totalDocs > 0 && (
|
||||
<Fragment>
|
||||
<div className={`${baseClass}__page-info`}>
|
||||
{(data.page * data.limit) - (data.limit - 1)}
|
||||
-
|
||||
{data.totalPages > 1 && data.totalPages !== data.page ? (data.limit * data.page) : data.totalDocs}
|
||||
{' '}
|
||||
{t('of')}
|
||||
{' '}
|
||||
{data.totalDocs}
|
||||
</div>
|
||||
<PerPage
|
||||
limits={collection?.admin?.pagination?.limits}
|
||||
limit={limit}
|
||||
modifySearchParams={modifySearchParams}
|
||||
handleChange={handlePerPageChange}
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className={`${baseClass}__page-controls`}>
|
||||
<Paginator
|
||||
limit={data.limit}
|
||||
totalPages={data.totalPages}
|
||||
page={data.page}
|
||||
hasPrevPage={data.hasPrevPage}
|
||||
hasNextPage={data.hasNextPage}
|
||||
prevPage={data.prevPage}
|
||||
nextPage={data.nextPage}
|
||||
numberOfNeighbors={1}
|
||||
disableHistoryChange={modifySearchParams === false}
|
||||
onChange={handlePageChange}
|
||||
/>
|
||||
{data?.totalDocs > 0 && (
|
||||
<Fragment>
|
||||
<div className={`${baseClass}__page-info`}>
|
||||
{(data.page * data.limit) - (data.limit - 1)}
|
||||
-
|
||||
{data.totalPages > 1 && data.totalPages !== data.page ? (data.limit * data.page) : data.totalDocs}
|
||||
{' '}
|
||||
{t('of')}
|
||||
{' '}
|
||||
{data.totalDocs}
|
||||
</div>
|
||||
<PerPage
|
||||
limits={collection?.admin?.pagination?.limits}
|
||||
limit={limit}
|
||||
modifySearchParams={modifySearchParams}
|
||||
handleChange={handlePerPageChange}
|
||||
resetPage={data.totalDocs <= data.pagingCounter}
|
||||
/>
|
||||
</Fragment>
|
||||
)}
|
||||
</div>
|
||||
</Gutter>
|
||||
</Gutter>
|
||||
</SelectionProvider>
|
||||
</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,42 +1,70 @@
|
||||
import { TFunction } from 'react-i18next';
|
||||
import React from 'react';
|
||||
import { SanitizedCollectionConfig } from '../../../../../collections/config/types';
|
||||
import { Field, fieldAffectsData, fieldIsPresentationalOnly } from '../../../../../fields/config/types';
|
||||
import UploadCell from './Cell/field-types/Upload';
|
||||
import { Props } from './Cell/types';
|
||||
|
||||
const formatFields = (config: SanitizedCollectionConfig, t: TFunction): Field[] => {
|
||||
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)) {
|
||||
return formatted;
|
||||
}
|
||||
|
||||
if (config.upload && fieldAffectsData(field) && field.name === 'filename') {
|
||||
const Cell: React.FC<Props> = (props) => (
|
||||
<UploadCell
|
||||
collection={config}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return [
|
||||
...formatted,
|
||||
{
|
||||
...field,
|
||||
admin: {
|
||||
...field.admin,
|
||||
components: {
|
||||
...field.admin?.components || {},
|
||||
Cell: field.admin?.components?.Cell || Cell,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
...formatted,
|
||||
field,
|
||||
];
|
||||
}, hasID ? [] : [{ name: 'id', label: 'ID', type: 'text' }]);
|
||||
}, hasID ? [] : [{
|
||||
name: 'id',
|
||||
label: 'ID',
|
||||
type: 'text',
|
||||
admin: {
|
||||
disableBulkEdit: true,
|
||||
},
|
||||
}]);
|
||||
|
||||
if (config.timestamps) {
|
||||
fields = fields.concat([
|
||||
fields.push(
|
||||
{
|
||||
name: 'createdAt',
|
||||
label: t('general:createdAt'),
|
||||
type: 'date',
|
||||
admin: {
|
||||
disableBulkEdit: true,
|
||||
},
|
||||
}, {
|
||||
name: 'updatedAt',
|
||||
label: t('general:updatedAt'),
|
||||
type: 'date',
|
||||
admin: {
|
||||
disableBulkEdit: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
if (config.upload) {
|
||||
fields = fields.concat([
|
||||
{
|
||||
name: 'filename',
|
||||
label: t('upload:fileName'),
|
||||
type: 'text',
|
||||
},
|
||||
]);
|
||||
);
|
||||
}
|
||||
|
||||
return fields;
|
||||
|
||||
@@ -37,6 +37,12 @@
|
||||
table {
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
|
||||
#heading-_select,
|
||||
.cell-_select {
|
||||
min-width: unset;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,31 +61,40 @@
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
&__shimmer {
|
||||
margin-top: base(1.75);
|
||||
}
|
||||
&__list-selection {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
z-index: 10;
|
||||
padding: base(.75) 0;
|
||||
width: 100%;
|
||||
background-color: var(--theme-bg);
|
||||
|
||||
&__shimmer--rows {
|
||||
>div {
|
||||
margin-top: 8px;
|
||||
.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__shimmer--uploads {
|
||||
// match upload cards
|
||||
margin: base(2) -#{base(.5)};
|
||||
width: calc(100% + #{$baseline});
|
||||
&__list-selection-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: base(.25);
|
||||
}
|
||||
|
||||
&__shimmer {
|
||||
margin-top: base(1.75);
|
||||
width: 100%;
|
||||
>div {
|
||||
min-width: 0;
|
||||
width: calc(16.66%);
|
||||
|
||||
>div {
|
||||
margin: base(.5);
|
||||
padding-bottom: 110%;
|
||||
}
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,19 +126,9 @@
|
||||
width: 100%;
|
||||
margin-bottom: $baseline;
|
||||
}
|
||||
|
||||
&__shimmer--uploads {
|
||||
>div {
|
||||
width: 33.33%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@include small-break {
|
||||
&__shimmer--uploads {
|
||||
>div {
|
||||
width: 50%;
|
||||
}
|
||||
}
|
||||
margin-bottom: base(3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 queryString from 'qs';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -9,10 +10,11 @@ import DefaultList from './Default';
|
||||
import RenderCustomComponent from '../../../utilities/RenderCustomComponent';
|
||||
import { useStepNav } from '../../../elements/StepNav';
|
||||
import formatFields from './formatFields';
|
||||
import { ListIndexProps, ListPreferences } from './types';
|
||||
import { Props, ListIndexProps, ListPreferences } from './types';
|
||||
import { usePreferences } from '../../../utilities/Preferences';
|
||||
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 {
|
||||
@@ -48,7 +50,7 @@ const ListView: React.FC<ListIndexProps> = (props) => {
|
||||
const collectionPermissions = permissions?.collections?.[slug];
|
||||
const hasCreatePermission = collectionPermissions?.create?.permission;
|
||||
const newDocumentURL = `${admin}/collections/${slug}/create`;
|
||||
const [{ data }, { setParams: setFetchParams }] = usePayloadAPI(fetchURL, { initialParams: { page: 1 } });
|
||||
const [{ data }, { setParams }] = usePayloadAPI(fetchURL, { initialParams: { page: 1 } });
|
||||
|
||||
useEffect(() => {
|
||||
setStepNav([
|
||||
@@ -62,27 +64,31 @@ const ListView: React.FC<ListIndexProps> = (props) => {
|
||||
// Set up Payload REST API query params
|
||||
// /////////////////////////////////////
|
||||
|
||||
useEffect(() => {
|
||||
const params = {
|
||||
const resetParams = useCallback<Props['resetParams']>((overrides = {}) => {
|
||||
const params: Record<string, unknown> = {
|
||||
depth: 0,
|
||||
draft: 'true',
|
||||
page: undefined,
|
||||
sort: undefined,
|
||||
where: undefined,
|
||||
page: overrides?.page,
|
||||
sort: overrides?.sort,
|
||||
where: overrides?.where,
|
||||
limit,
|
||||
};
|
||||
|
||||
if (page) params.page = page;
|
||||
if (sort) params.sort = sort;
|
||||
if (where) params.where = where;
|
||||
params.invoke = uuid();
|
||||
|
||||
setParams(params);
|
||||
}, [limit, page, setParams, sort, where]);
|
||||
|
||||
useEffect(() => {
|
||||
// Performance enhancement
|
||||
// Setting the Fetch URL this way
|
||||
// prevents a double-fetch
|
||||
setFetchURL(`${serverURL}${api}/${slug}`);
|
||||
|
||||
setFetchParams(params);
|
||||
}, [setFetchParams, page, sort, where, collection, limit, serverURL, api, slug]);
|
||||
resetParams();
|
||||
}, [api, resetParams, serverURL, slug]);
|
||||
|
||||
// /////////////////////////////////////
|
||||
// Fetch preferences on first load
|
||||
@@ -128,18 +134,41 @@ const ListView: React.FC<ListIndexProps> = (props) => {
|
||||
})();
|
||||
}, [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 (
|
||||
<RenderCustomComponent
|
||||
DefaultComponent={DefaultList}
|
||||
CustomComponent={CustomList}
|
||||
componentProps={{
|
||||
collection: { ...collection, fields },
|
||||
newDocumentURL,
|
||||
hasCreatePermission,
|
||||
data,
|
||||
limit: limit || defaultLimit,
|
||||
}}
|
||||
/>
|
||||
<TableColumnsProvider collection={collection}>
|
||||
<RenderCustomComponent
|
||||
DefaultComponent={DefaultList}
|
||||
CustomComponent={CustomList}
|
||||
componentProps={{
|
||||
collection: { ...collection, fields },
|
||||
newDocumentURL,
|
||||
hasCreatePermission,
|
||||
data,
|
||||
limit: limit || defaultLimit,
|
||||
resetParams,
|
||||
}}
|
||||
/>
|
||||
</TableColumnsProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Where } from '../../../../../types';
|
||||
import { SanitizedCollectionConfig } from '../../../../../collections/config/types';
|
||||
import { PaginatedDocs } from '../../../../../mongoose/types';
|
||||
import { Props as ListControlsProps } from '../../../elements/ListControls/types';
|
||||
@@ -11,15 +12,16 @@ export type Props = {
|
||||
setListControls: (controls: unknown) => void
|
||||
setSort: (sort: string) => void
|
||||
toggleColumn: (column: string) => void
|
||||
resetParams: (overrides?: { page?: number, sort?: string, where?: Where }) => void
|
||||
hasCreatePermission: boolean
|
||||
setLimit: (limit: number) => void
|
||||
limit: number
|
||||
disableEyebrow?: boolean
|
||||
modifySearchParams?: boolean
|
||||
onCardClick?: (doc: any) => void
|
||||
disableCardLink?: boolean
|
||||
handleSortChange?: ListControlsProps['handleSortChange']
|
||||
handleWhereChange?: ListControlsProps['handleWhereChange']
|
||||
handleDelete?: () => void
|
||||
handlePageChange?: PaginatorProps['onChange']
|
||||
handlePerPageChange?: PerPageProps['handleChange']
|
||||
onCreateNewClick?: () => void
|
||||
|
||||
@@ -43,12 +43,15 @@ const usePayloadAPI: UsePayloadAPI = (url, options = {}) => {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const abortController = new AbortController();
|
||||
|
||||
const fetchData = async () => {
|
||||
setIsError(false);
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await requests.get(`${url}${search}`, {
|
||||
signal: abortController.signal,
|
||||
headers: {
|
||||
'Accept-Language': i18n.language,
|
||||
},
|
||||
@@ -62,8 +65,10 @@ const usePayloadAPI: UsePayloadAPI = (url, options = {}) => {
|
||||
setData(json);
|
||||
setIsLoading(false);
|
||||
} catch (error) {
|
||||
setIsError(true);
|
||||
setIsLoading(false);
|
||||
if (!abortController.signal.aborted) {
|
||||
setIsError(true);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -73,6 +78,10 @@ const usePayloadAPI: UsePayloadAPI = (url, options = {}) => {
|
||||
setIsError(false);
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
return () => {
|
||||
abortController.abort();
|
||||
};
|
||||
}, [url, locale, search, i18n.language]);
|
||||
|
||||
return [{ data, isLoading, isError }, { setParams }];
|
||||
|
||||
@@ -3,6 +3,7 @@ import { SanitizedCollectionConfig } from '../../collections/config/types';
|
||||
import isImage from '../../uploads/isImage';
|
||||
|
||||
const absoluteURLPattern = new RegExp('^(?:[a-z]+:)?//', 'i');
|
||||
const base64Pattern = new RegExp(/^data:image\/[a-z]+;base64,/);
|
||||
|
||||
const useThumbnail = (collection: SanitizedCollectionConfig, doc: Record<string, unknown>): string | false => {
|
||||
const {
|
||||
@@ -29,7 +30,7 @@ const useThumbnail = (collection: SanitizedCollectionConfig, doc: Record<string,
|
||||
if (typeof adminThumbnail === 'function') {
|
||||
const thumbnailURL = adminThumbnail({ doc });
|
||||
|
||||
if (absoluteURLPattern.test(thumbnailURL)) {
|
||||
if (absoluteURLPattern.test(thumbnailURL) || base64Pattern.test(thumbnailURL)) {
|
||||
return thumbnailURL;
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user