Merge branch 'master' of https://github.com/payloadcms/payload into feat/form-builder-example

This commit is contained in:
PatrikKozak
2023-04-11 10:49:31 -04:00
201 changed files with 5894 additions and 1692 deletions

42
.github/ISSUE_TEMPLATE/1.bug_report.yml vendored Normal file
View 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!

View File

@@ -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 -->

View File

@@ -1,8 +1,5 @@
blank_issues_enabled: false blank_issues_enabled: false
contact_links: contact_links:
- name: Security Vulnerability
url: https://github.com/payloadcms/payload/blob/master/SECURITY.md
about: See instructions to privately disclose any security concerns
- name: Feature Request - name: Feature Request
url: https://github.com/payloadcms/payload/discussions url: https://github.com/payloadcms/payload/discussions
about: Suggest an idea to improve Payload in our GitHub Discussions about: Suggest an idea to improve Payload in our GitHub Discussions

58
.github/reproduction-guide.md vendored Normal file
View 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.

View File

@@ -1,5 +1,93 @@
## [1.6.32](https://github.com/payloadcms/payload/compare/v1.6.31...v1.6.32) (2023-04-05)
### Features
* only uses sharp if required ([f9f6ec4](https://github.com/payloadcms/payload/commit/f9f6ec47d9a4f9ed94b7f7a4d50f13a8ee881ad0))
## [1.6.31](https://github.com/payloadcms/payload/compare/v1.6.30...v1.6.31) (2023-04-04)
### Bug Fixes
* ensures select hasMany does not get mutated on patch operations ([3a6acf3](https://github.com/payloadcms/payload/commit/3a6acf322b5546ca3cd1d4dcb093af6e3b6ed086))
### Features
* improves required type accuracy ([a9cd23a](https://github.com/payloadcms/payload/commit/a9cd23a883d89c8deb3c1b5386decd50516d69fd))
## [1.6.30](https://github.com/payloadcms/payload/compare/v1.6.29...v1.6.30) (2023-04-03)
### Bug Fixes
* incorrect type local api using delete with where ([de5ceb2](https://github.com/payloadcms/payload/commit/de5ceb2aca624f702ea39556ffe2f689701615c1))
* originalDoc being mutated in beforeChange field hooks ([888bbf2](https://github.com/payloadcms/payload/commit/888bbf28e0b793a2298e27a7e1df235d78b0a718))
## [1.6.29](https://github.com/payloadcms/payload/compare/v1.6.28...v1.6.29) (2023-03-31)
### Bug Fixes
* update and delete local API return types ([#2434](https://github.com/payloadcms/payload/issues/2434)) ([02410a0](https://github.com/payloadcms/payload/commit/02410a0be38004b90d19207071569294fd104a66))
## [1.6.28](https://github.com/payloadcms/payload/compare/v1.6.27...v1.6.28) (2023-03-28)
### Bug Fixes
* potential memory leak with `probe-image-size` ([8eea0d6](https://github.com/payloadcms/payload/commit/8eea0d6cf41dd6360d713f463ad1b48ba253a9e7))
## [1.6.27](https://github.com/payloadcms/payload/compare/v1.6.26...v1.6.27) (2023-03-27)
### Bug Fixes
* [#2355](https://github.com/payloadcms/payload/issues/2355), select field not fully visible on small screens in certain scenarios ([07eb8dd](https://github.com/payloadcms/payload/commit/07eb8dd7d252043c00b79d532736896134204c4c))
* [#2384](https://github.com/payloadcms/payload/issues/2384), preserves manually set verified from admin UI ([72a8b1e](https://github.com/payloadcms/payload/commit/72a8b1eebe6c3b45663a14fa7488772ad13f975d))
* hide fields with admin.hidden attribute ([ad25b09](https://github.com/payloadcms/payload/commit/ad25b096b6efa7e0cba647e82e29e36f7a95934a))
* make update typing a deep partial ([#2407](https://github.com/payloadcms/payload/issues/2407)) ([e8dc7d4](https://github.com/payloadcms/payload/commit/e8dc7d462e21d1021275a95fbf62094f290e37ce))
* restoring version did not correctly create new version from result ([6ca12b1](https://github.com/payloadcms/payload/commit/6ca12b1cc06554b04f3055df8f01d7eee1c09169))
* textarea field overlap in UI ([1c8cf24](https://github.com/payloadcms/payload/commit/1c8cf24ba623746c160007d7c09b3160f2aae8d3))
## [1.6.25](https://github.com/payloadcms/payload/compare/v1.6.24...v1.6.25) (2023-03-24)
### Bug Fixes
* upload field select existing file ([#2392](https://github.com/payloadcms/payload/issues/2392)) ([38e917a](https://github.com/payloadcms/payload/commit/38e917a3dfa70ac3234915a6c8f7424eb22cb000))
## [1.6.24](https://github.com/payloadcms/payload/compare/v1.6.23...v1.6.24) (2023-03-23)
### Features
* bulk-operations ([#2346](https://github.com/payloadcms/payload/issues/2346)) ([0fedbab](https://github.com/payloadcms/payload/commit/0fedbabe9e975f375dc12447fcdab4119bc6a4c4))
## [1.6.23](https://github.com/payloadcms/payload/compare/v1.6.22...v1.6.23) (2023-03-22)
### Bug Fixes
* [#2315](https://github.com/payloadcms/payload/issues/2315) - deleting files if overwriteExistingFiles is true ([4d578f1](https://github.com/payloadcms/payload/commit/4d578f1bfd05efab5cc8db95895eabb776b2d9d1))
* [#2363](https://github.com/payloadcms/payload/issues/2363) version tabs and select field comparisons ([#2364](https://github.com/payloadcms/payload/issues/2364)) ([21b8da7](https://github.com/payloadcms/payload/commit/21b8da7f415cdace9f7d5898c98f9c7a6bb39107))
* allows base64 thumbnails ([#2361](https://github.com/payloadcms/payload/issues/2361)) ([e09ebff](https://github.com/payloadcms/payload/commit/e09ebfffa0a7a7fdb3469f272de0e6930d97a336))
* DateField admin type ([#2256](https://github.com/payloadcms/payload/issues/2256)) ([fb2fd3e](https://github.com/payloadcms/payload/commit/fb2fd3e9b7e302d8069bfcb6f3cb698ac7abf0ca))
* fallback to default locale showing on non-localized fields ([#2316](https://github.com/payloadcms/payload/issues/2316)) ([e1a6e08](https://github.com/payloadcms/payload/commit/e1a6e08aa140cf21597d6009b811f7fdd2106f4f))
* Fix missing Spanish translations ([#2372](https://github.com/payloadcms/payload/issues/2372)) ([c0ff75c](https://github.com/payloadcms/payload/commit/c0ff75c1647a36219549e20fc081883f8cf1d7e4))
* relationship field useAsTitle [#2333](https://github.com/payloadcms/payload/issues/2333) ([#2350](https://github.com/payloadcms/payload/issues/2350)) ([10dd819](https://github.com/payloadcms/payload/commit/10dd819863ecac4a5cea2e13f820df2224ac57f4))
### Features
* adds title attribute to ThumbnailCard ([#2368](https://github.com/payloadcms/payload/issues/2368)) ([a8766d0](https://github.com/payloadcms/payload/commit/a8766d00a8365c8e6ffe507944fbe49aaa39d4bd))
* exposes defaultSort property for collection list view ([#2382](https://github.com/payloadcms/payload/issues/2382)) ([1f480c4](https://github.com/payloadcms/payload/commit/1f480c4cd5673a6fe08360183fe1c7c1d4e05de0))
## [1.6.22](https://github.com/payloadcms/payload/compare/v1.6.21...v1.6.22) (2023-03-15)
## [1.6.21](https://github.com/payloadcms/payload/compare/v1.6.20...v1.6.21) (2023-03-15) ## [1.6.21](https://github.com/payloadcms/payload/compare/v1.6.20...v1.6.21) (2023-03-15)

64
ISSUE_GUIDE.md Normal file
View 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.

View File

@@ -49,8 +49,6 @@ The directory split up in this way specifically to reduce friction when creating
The following command will start Payload with your config: `yarn dev my-test-dir`. This command will start up Payload using your config and refresh a test database on every restart. The following command will start Payload with your config: `yarn dev my-test-dir`. This command will start up Payload using your config and refresh a test database on every restart.
When switching between test directories, you will want to remove your `node_modules/.cache ` manually or by running `yarn clean:cache`.
NOTE: It is recommended to add the test credentials (located in `test/credentials.ts`) to your autofill for `localhost:3000/admin` as this will be required on every nodemon restart. The default credentials are `dev@payloadcms.com` as E-Mail and `test` as password. NOTE: It is recommended to add the test credentials (located in `test/credentials.ts`) to your autofill for `localhost:3000/admin` as this will be required on every nodemon restart. The default credentials are `dev@payloadcms.com` as E-Mail and `test` as password.
## Pull Requests ## Pull Requests

View File

@@ -166,7 +166,6 @@ The `useDocumentInfo` hook provides lots of information about the document curre
|---------------------------|--------------------------------------------------------------------------------------------------------------------| | |---------------------------|--------------------------------------------------------------------------------------------------------------------| |
| **`collection`** | If the doc is a collection, its collection config will be returned | | **`collection`** | If the doc is a collection, its collection config will be returned |
| **`global`** | If the doc is a global, its global config will be returned | | **`global`** | If the doc is a global, its global config will be returned |
| **`type`** | The type of document being edited (collection or global) |
| **`id`** | If the doc is a collection, its ID will be returned | | **`id`** | If the doc is a collection, its ID will be returned |
| **`preferencesKey`** | The `preferences` key to use when interacting with document-level user preferences | | **`preferencesKey`** | The `preferences` key to use when interacting with document-level user preferences |
| **`versions`** | Versions of the current doc | | **`versions`** | Versions of the current doc |

View File

@@ -11,38 +11,46 @@ Because Payload uses your existing Express server, you are free to add whatever
This approach has a ton of benefits - it's great for isolation of concerns and limiting scope, but it also means that your additional routes won't have access to Payload's user authentication. This approach has a ton of benefits - it's great for isolation of concerns and limiting scope, but it also means that your additional routes won't have access to Payload's user authentication.
<Banner type="success"> <Banner type="success">
You can make full use of Payload's built-in authentication within your own custom Express endpoints by adding Payload's authentication middleware. You can make full use of Payload's built-in authentication within your own
custom Express endpoints by adding Payload's authentication middleware.
</Banner>
<Banner type="warning">
Payload must be initialized before the `payload.authenticate` middleware can
be used. This is done by calling `payload.init()` prior to adding the
middleware.
</Banner> </Banner>
Example in `server.js`: Example in `server.js`:
```ts ```ts
import express from 'express'; import express from "express";
import payload from 'payload'; import payload from "payload";
const app = express(); const app = express();
payload.init({ payload.init({
secret: 'PAYLOAD_SECRET_KEY', secret: "PAYLOAD_SECRET_KEY",
mongoURL: 'mongodb://localhost/payload', mongoURL: "mongodb://localhost/payload",
express: app, express: app,
}); });
const router = express.Router(); const router = express.Router();
// Note: Payload must be initialized before the `payload.authenticate` middleware can be used
router.use(payload.authenticate); // highlight-line router.use(payload.authenticate); // highlight-line
router.get('/', (req, res) => { router.get("/", (req, res) => {
if (req.user) { if (req.user) {
return res.send(`Authenticated successfully as ${req.user.email}.`); return res.send(`Authenticated successfully as ${req.user.email}.`);
} }
return res.send('Not authenticated'); return res.send("Not authenticated");
}); });
app.use('/some-route-here', router); app.use("/some-route-here", router);
app.listen(3000, async () => { app.listen(3000, async () => {
payload.logger.info(`listening on ${3000}...`); payload.logger.info(`listening on ${3000}...`);
}); });
``` ```

View File

@@ -13,7 +13,7 @@ It's often best practice to write your Collections in separate files and then im
## Options ## Options
| Option | Description | | Option | Description |
|------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| |--------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **`slug`** * | Unique, URL-friendly string that will act as an identifier for this Collection. | | **`slug`** * | Unique, URL-friendly string that will act as an identifier for this Collection. |
| **`fields`** * | Array of field types that will determine the structure and functionality of the data stored within this Collection. [Click here](/docs/fields/overview) for a full list of field types as well as how to configure them. | | **`fields`** * | Array of field types that will determine the structure and functionality of the data stored within this Collection. [Click here](/docs/fields/overview) for a full list of field types as well as how to configure them. |
| **`labels`** | Singular and plural labels for use in identifying this Collection throughout Payload. Auto-generated from slug if not defined. | | **`labels`** | Singular and plural labels for use in identifying this Collection throughout Payload. Auto-generated from slug if not defined. |
@@ -27,6 +27,8 @@ It's often best practice to write your Collections in separate files and then im
| **`endpoints`** | Add custom routes to the REST API. [More](/docs/rest-api/overview#custom-endpoints) | | **`endpoints`** | Add custom routes to the REST API. [More](/docs/rest-api/overview#custom-endpoints) |
| **`graphQL`** | An object with `singularName` and `pluralName` strings used in schema generation. Auto-generated from slug if not defined. | | **`graphQL`** | An object with `singularName` and `pluralName` strings used in schema generation. Auto-generated from slug if not defined. |
| **`typescript`** | An object with property `interface` as the text used in schema generation. Auto-generated from slug if not defined. | | **`typescript`** | An object with property `interface` as the text used in schema generation. Auto-generated from slug if not defined. |
| **`defaultSort`** | Pass a top-level field to sort by default in the collection List view. Prefix the name of the field with a minus symbol ("-") to sort in descending order. |
| **`pagination`** | Set pagination-specific options for this collection. [More](#pagination) |
*\* An asterisk denotes that a property is required.* *\* An asterisk denotes that a property is required.*
@@ -111,6 +113,15 @@ const Posts: CollectionConfig = {
}; };
``` ```
### Pagination
Here are a few options that you can specify options for pagination on a collection-by-collection basis:
| Option | Description |
| --------------------------- | -------------|
| `defaultLimit` | Integer that specifies the default per-page limit that should be used. Defaults to 10. |
| `limits` | Provide an array of integers to use as per-page options for admins to choose from in the List view. |
### Access control ### Access control
You can specify extremely granular access control (what users can do with documents in a collection) on a collection by collection basis. To learn more, go to the [Access Control](/docs/access-control/overview) docs. You can specify extremely granular access control (what users can do with documents in a collection) on a collection by collection basis. To learn more, go to the [Access Control](/docs/access-control/overview) docs.

View File

@@ -72,6 +72,15 @@ All field types with a `name` property support the `localized` property—even t
Enabling localization for field types that support nested fields will automatically create localized "sets" of all fields contained within the field. For example, if you have a page layout using a blocks field type, you have the choice of either localizing the full layout, by enabling localization on the top-level blocks field, or only certain fields within the layout. Enabling localization for field types that support nested fields will automatically create localized "sets" of all fields contained within the field. For example, if you have a page layout using a blocks field type, you have the choice of either localizing the full layout, by enabling localization on the top-level blocks field, or only certain fields within the layout.
</Banner> </Banner>
<Banner type="warning">
<strong>Important:</strong>
<br />
When converting an existing field to or from `localized: true` the data
structure in the document will change for this field and so existing data for
this field will be lost. Before changing the localization setting on fields
with existing data, you may need to consider a field migration strategy.
</Banner>
### Retrieving localized docs ### Retrieving localized docs
When retrieving documents, you can specify which locale you'd like to receive as well as which fallback locale should be used. When retrieving documents, you can specify which locale you'd like to receive as well as which fallback locale should be used.

View File

@@ -104,6 +104,8 @@ require('dotenv').config()
// the rest of your `server.js` file goes here // the rest of your `server.js` file goes here
``` ```
Note that if you rely on any environment variables in your config itself, you should also call `dotenv()` at the top of your config itself as well. There's no harm in calling it in both your server and your config itself!
**Here is an example project structure w/ `dotenv` and an `.env` file:** **Here is an example project structure w/ `dotenv` and an `.env` file:**
``` ```

View File

@@ -36,45 +36,72 @@ _\* An asterisk denotes that a property is required._
In addition to the default [field admin config](/docs/fields/overview#admin-config), you can customize the following fields that will adjust how the component displays in the admin panel via the `date` property. In addition to the default [field admin config](/docs/fields/overview#admin-config), you can customize the following fields that will adjust how the component displays in the admin panel via the `date` property.
| Option | Description | | Property | Description |
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------------------------ | ------------------------------------------------------------------------------------------- |
| **`pickerAppearance`** | Determines the appearance of the datepicker: `dayAndTime` `timeOnly` `dayOnly` `monthOnly`. Defaults to a calendar day picker - see `displayFormat`. |
| **`displayFormat`** | Determines how the date is presented. dayAndTime default to `MMM d, yyy h:mm a` timeOnly defaults to `h:mm a` dayOnly defaults to `dd` and monthOnly defaults to `MMMM`. Defaults to `MMM d, yyy` when `pickerAppearance` is not set. |
| **`placeholder`** | Placeholder text for the field. | | **`placeholder`** | Placeholder text for the field. |
| **`monthsToShow`** | Number of months to display max is 2. Defaults to 1. | | **`date`** | Pass options to customize date field appearance. |
| **`minDate`** | Passed directly to [react-datepicker](https://github.com/Hacker0x01/react-datepicker/blob/master/docs/datepicker.md). | | **`date.displayFormat`** | Format date to be shown in field **cell**. |
| **`maxDate`** | Passed directly to [react-datepicker](https://github.com/Hacker0x01/react-datepicker/blob/master/docs/datepicker.md). | | **`date.pickerAppearance`** \* | Determines the appearance of the datepicker: `dayAndTime` `timeOnly` `dayOnly` `monthOnly`. |
| **`minTime`** | Passed directly to [react-datepicker](https://github.com/Hacker0x01/react-datepicker/blob/master/docs/datepicker.md). | | **`date.monthsToShow`** \* | Number of months to display max is 2. Defaults to 1. |
| **`maxTime`** | Passed directly to [react-datepicker](https://github.com/Hacker0x01/react-datepicker/blob/master/docs/datepicker.md). | | **`date.minDate`** \* | Min date value to allow. |
| **`timeIntervals`** | Passed directly to [react-datepicker](https://github.com/Hacker0x01/react-datepicker/blob/master/docs/datepicker.md). Defaults to 30 minutes. | | **`date.maxDate`** \* | Max date value to allow. |
| **`timeFormat`** | Passed directly to [react-datepicker](https://github.com/Hacker0x01/react-datepicker/blob/master/docs/datepicker.md). Defaults to `'h:mm aa'`. | | **`date.minTime`** \* | Min time value to allow. |
| **`date.maxTime`** \* | Max date value to allow. |
| **`date.timeIntervals`** \* | Time intervals to display. Defaults to 30 minutes. |
| **`date.timeFormat`** \* | Determines time format. Defaults to `'h:mm aa'`. |
_\* An asterisk denotes that a property is required._ _\* This property is passed directly to [react-datepicker](https://github.com/Hacker0x01/react-datepicker/blob/master/docs/datepicker.md). ._
Common use cases for customizing the `date` property are to restrict your field to only show time or day input—but lots more can be done. #### Display Format and Picker Appearance
These properties only affect how the date is displayed in the UI. The full date is always stored in the format `YYYY-MM-DDTHH:mm:ss.SSSZ` (e.g. `1999-01-01T8:00:00.000+05:00`).
`displayFormat` determines how the date is presented in the field **cell**, you can pass any valid (unicode date format)[https://date-fns.org/v2.29.3/docs/format].
`pickerAppearance` sets the appearance of the **react datepicker**, the options available are `dayAndTime`, `dayOnly`, `timeOnly`, and `monthOnly`. By default, the datepicker will display `dayOnly`.
When only `pickerAppearance` is set, an equivalent format will be rendered in the date field cell. To overwrite this format, set `displayFormat`.
### Example ### Example
`collections/ExampleCollection.ts` `collections/ExampleCollection.ts`
```ts ```ts
import { CollectionConfig } from 'payload/types'; import { CollectionConfig } from "payload/types";
const ExampleCollection: CollectionConfig = { const ExampleCollection: CollectionConfig = {
slug: 'example-collection', slug: "example-collection",
fields: [ fields: [
{ {
name: 'time', // required name: "dateOnly",
type: 'date', // required type: "date",
label: 'Event Start Time',
defaultValue: '1988-11-05T8:00:00.000+05:00',
admin: { admin: {
date: { date: {
// All config options above should be placed here pickerAppearance: "dayOnly",
pickerAppearance: 'timeOnly', displayFormat: "d MMM yyy",
} },
} },
} },
] {
name: "timeOnly",
type: "date",
admin: {
date: {
pickerAppearance: "timeOnly",
displayFormat: "h:mm:ss a",
},
},
},
{
name: "monthOnly",
type: "date",
admin: {
date: {
pickerAppearance: "monthOnly",
displayFormat: "MMMM yyyy",
},
},
},
],
}; };
``` ```

View File

@@ -156,7 +156,7 @@ Example:
In addition to each field's base configuration, you can define specific traits and properties for fields that only have effect on how they are rendered in the Admin panel. The following properties are available for all fields within the `admin` property: In addition to each field's base configuration, you can define specific traits and properties for fields that only have effect on how they are rendered in the Admin panel. The following properties are available for all fields within the `admin` property:
| Option | Description | | Option | Description |
| ------------- | -------------| |-------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `condition` | You can programmatically show / hide fields based on what other fields are doing. [Click here](#conditional-logic) for more info. | | `condition` | You can programmatically show / hide fields based on what other fields are doing. [Click here](#conditional-logic) for more info. |
| `components` | All field components can be completely and easily swapped out for custom components that you define. [Click here](#custom-components) for more info. | | `components` | All field components can be completely and easily swapped out for custom components that you define. [Click here](#custom-components) for more info. |
| `description` | Helper text to display with the field to provide more information for the editor user. [Click here](#description) for more info. | | `description` | Helper text to display with the field to provide more information for the editor user. [Click here](#description) for more info. |
@@ -166,6 +166,7 @@ In addition to each field's base configuration, you can define specific traits a
| `className` | Attach a CSS class name to the root DOM element of a field. | | `className` | Attach a CSS class name to the root DOM element of a field. |
| `readOnly` | Setting a field to `readOnly` has no effect on the API whatsoever but disables the admin component's editability to prevent editors from modifying the field's value. | | `readOnly` | Setting a field to `readOnly` has no effect on the API whatsoever but disables the admin component's editability to prevent editors from modifying the field's value. |
| `disabled` | If a field is `disabled`, it is completely omitted from the Admin panel. | | `disabled` | If a field is `disabled`, it is completely omitted from the Admin panel. |
| `disableBulkEdit` | Set `disableBulkEdit` to `true` to prevent fields from appearing in the select options when making edits for multiple documents. |
| `hidden` | Setting a field's `hidden` property on its `admin` config will transform it into a `hidden` input type. Its value will still submit with the Admin panel's requests, but the field itself will not be visible to editors. | | `hidden` | Setting a field's `hidden` property on its `admin` config will transform it into a `hidden` input type. Its value will still submit with the Admin panel's requests, but the field itself will not be visible to editors. |
### Custom components ### Custom components

View File

@@ -25,7 +25,7 @@ keywords: textarea, fields, config, configuration, documentation, Content Manage
| **`hooks`** | Provide field-based hooks to control logic for this field. [More](/docs/fields/overview#field-level-hooks) | | **`hooks`** | Provide field-based hooks to control logic for this field. [More](/docs/fields/overview#field-level-hooks) |
| **`access`** | Provide field-based access control to denote what users can see and do with this field's data. [More](/docs/fields/overview#field-level-access-control) | | **`access`** | Provide field-based access control to denote what users can see and do with this field's data. [More](/docs/fields/overview#field-level-access-control) |
| **`hidden`** | Restrict this field's visibility from all APIs entirely. Will still be saved to the database, but will not appear in any API or the Admin panel. | | **`hidden`** | Restrict this field's visibility from all APIs entirely. Will still be saved to the database, but will not appear in any API or the Admin panel. |
| **`defaultValue`** | Provide data to be used for this field's default value. [More](/docs/fields/overview#default-values) [More](/docs/fields/overview#default-values) | | **`defaultValue`** | Provide data to be used for this field's default value. [More](/docs/fields/overview#default-values)|
| **`localized`** | Enable localization for this field. Requires [localization to be enabled](/docs/configuration/localization) in the Base config. | | **`localized`** | Enable localization for this field. Requires [localization to be enabled](/docs/configuration/localization) in the Base config. |
| **`required`** | Require this field to have a value. | | **`required`** | Require this field to have a value. |
| **`admin`** | Admin-specific configuration. See below for [more detail](#admin-config). | | **`admin`** | Admin-specific configuration. See below for [more detail](#admin-config). |

View File

@@ -79,7 +79,13 @@ const beforeOperationHook: CollectionBeforeOperationHook = async ({
### beforeValidate ### beforeValidate
Runs before the `create` and `update` operations. This hook allows you to add or format data before the incoming data is validated. Runs before the `create` and `update` operations. This hook allows you to add or format data before the incoming data is validated server-side.
Please do note that this does not run before the client-side validation. If you added a `validate` function, this would be the lifecycle:
1. `validate` runs on the client
2. if successful, `beforeValidate` runs on the server
3. `validate` runs on the server
```ts ```ts
import { CollectionBeforeOperationHook } from 'payload/types'; import { CollectionBeforeOperationHook } from 'payload/types';

View File

@@ -67,6 +67,7 @@ Field Hooks receive one `args` argument that contains the following properties:
| **`previousSiblingDoc`** | The sibling data from the previous document in `afterChange` hook. | | **`previousSiblingDoc`** | The sibling data from the previous document in `afterChange` hook. |
| **`req`** | The Express `request` object. It is mocked for Local API operations. | | **`req`** | The Express `request` object. It is mocked for Local API operations. |
| **`value`** | The value of the field. | | **`value`** | The value of the field. |
| **`previousValue`** | The previous value of the field, before changes were applied, only in `afterChange` hooks. |
#### Return value #### Return value

View File

@@ -162,7 +162,7 @@ const result = await payload.findByID({
}); });
``` ```
#### Update #### Update by ID
```js ```js
// Result will be the updated Post document. // Result will be the updated Post document.
@@ -193,6 +193,44 @@ const result = await payload.update({
}); });
``` ```
#### Update Many
```js
// Result will be an object with:
// {
// docs: [], // each document that was updated
// errors: [], // each error also includes the id of the document
// }
const result = await payload.update({
collection: "posts", // required
where: {
// required
fieldName: { equals: 'value' },
},
data: {
// required
title: "sure",
description: "maybe",
},
depth: 0,
locale: "en",
fallbackLocale: false,
user: dummyUser,
overrideAccess: false,
showHiddenFields: true,
// If your collection supports uploads, you can upload
// a file directly through the Local API by providing
// its full, absolute file path.
filePath: path.resolve(__dirname, "./path-to-image.jpg"),
// If you are uploading a file and would like to replace
// the existing file instead of generating a new filename,
// you can set the following property to `true`
overwriteExistingFiles: true,
});
```
#### Delete #### Delete
```js ```js
@@ -209,6 +247,29 @@ const result = await payload.delete({
}); });
``` ```
#### Delete Many
```js
// Result will be an object with:
// {
// docs: [], // each document that is now deleted
// errors: [], // any errors that occurred, including the id of the errored on document
// }
const result = await payload.delete({
collection: "posts", // required
where: {
// required
fieldName: { equals: 'value' },
},
depth: 0,
locale: "en",
fallbackLocale: false,
user: dummyUser,
overrideAccess: false,
showHiddenFields: true,
});
```
## Auth Operations ## Auth Operations
If a collection has [`Authentication`](/docs/authentication/overview) enabled, additional Local API operations will be available: If a collection has [`Authentication`](/docs/authentication/overview) enabled, additional Local API operations will be available:

View File

@@ -27,12 +27,14 @@ Note: Collection slugs must be formatted in kebab-case
**All CRUD operations are exposed as follows:** **All CRUD operations are exposed as follows:**
| Method | Path | Description | | Method | Path | Description |
| -------- | --------------------------- | -------------------------------------- | |----------|-------------------------------|--------------------------------------------------|
| `GET` | `/api/{collection-slug}` | Find paginated documents | | `GET` | `/api/{collection-slug}` | Find paginated documents |
| `GET` | `/api/{collection-slug}/:id` | Find a specific document by ID | | `GET` | `/api/{collection-slug}/:id` | Find a specific document by ID |
| `POST` | `/api/{collection-slug}` | Create a new document | | `POST` | `/api/{collection-slug}` | Create a new document |
| `PATCH` | `/api/{collection-slug}` | Update all documents matching the `where` query |
| `PATCH` | `/api/{collection-slug}/:id` | Update a document by ID | | `PATCH` | `/api/{collection-slug}/:id` | Update a document by ID |
| `DELETE` | `/api/{collection-slug}/:id` | Delete an existing document by ID | | `DELETE` | `/api/{collection-slug}` | Delete all documents matching the `where` query |
| `DELETE` | `/api/{collection-sldug}/:id` | Delete an existing document by ID |
##### Additional `find` query parameters ##### Additional `find` query parameters

View File

@@ -1,6 +1,6 @@
{ {
"name": "payload", "name": "payload",
"version": "1.6.21", "version": "1.6.32",
"description": "Node, React and MongoDB Headless CMS and Application Framework", "description": "Node, React and MongoDB Headless CMS and Application Framework",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -186,10 +186,10 @@
"url-loader": "^4.1.1", "url-loader": "^4.1.1",
"use-context-selector": "^1.4.1", "use-context-selector": "^1.4.1",
"uuid": "^8.3.2", "uuid": "^8.3.2",
"webpack": "^5.75.0", "webpack": "^5.76.0",
"webpack-bundle-analyzer": "^4.7.0", "webpack-bundle-analyzer": "^4.8.0",
"webpack-cli": "^4.10.0", "webpack-cli": "^4.10.0",
"webpack-dev-middleware": "^4.3.0", "webpack-dev-middleware": "6.0.1",
"webpack-hot-middleware": "^2.25.3" "webpack-hot-middleware": "^2.25.3"
}, },
"devDependencies": { "devDependencies": {
@@ -252,9 +252,8 @@
"@types/shelljs": "^0.8.11", "@types/shelljs": "^0.8.11",
"@types/testing-library__jest-dom": "^5.14.5", "@types/testing-library__jest-dom": "^5.14.5",
"@types/uuid": "^8.3.4", "@types/uuid": "^8.3.4",
"@types/webpack": "4.41.33",
"@types/webpack-bundle-analyzer": "^4.6.0", "@types/webpack-bundle-analyzer": "^4.6.0",
"@types/webpack-dev-middleware": "4.3.0", "@types/webpack-dev-middleware": "^5.3.0",
"@types/webpack-env": "^1.18.0", "@types/webpack-env": "^1.18.0",
"@types/webpack-hot-middleware": "2.25.6", "@types/webpack-hot-middleware": "2.25.6",
"@typescript-eslint/eslint-plugin": "^4.33.0", "@typescript-eslint/eslint-plugin": "^4.33.0",

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -14,7 +14,6 @@ import Version from './views/Version';
import { DocumentInfoProvider } from './utilities/DocumentInfo'; import { DocumentInfoProvider } from './utilities/DocumentInfo';
import { useLocale } from './utilities/Locale'; import { useLocale } from './utilities/Locale';
import { LoadingOverlayToggle } from './elements/Loading'; import { LoadingOverlayToggle } from './elements/Loading';
import { TableColumnsProvider } from './elements/TableColumns';
const Dashboard = lazy(() => import('./views/Dashboard')); const Dashboard = lazy(() => import('./views/Dashboard'));
const ForgotPassword = lazy(() => import('./views/ForgotPassword')); const ForgotPassword = lazy(() => import('./views/ForgotPassword'));
@@ -189,12 +188,10 @@ const Routes = () => {
render={(routeProps) => { render={(routeProps) => {
if (permissions?.collections?.[collection.slug]?.read?.permission) { if (permissions?.collections?.[collection.slug]?.read?.permission) {
return ( return (
<TableColumnsProvider collection={collection}>
<List <List
{...routeProps} {...routeProps}
collection={collection} collection={collection}
/> />
</TableColumnsProvider>
); );
} }
@@ -276,10 +273,15 @@ const Routes = () => {
render={(routeProps) => { render={(routeProps) => {
if (permissions?.collections?.[collection.slug]?.readVersions?.permission) { if (permissions?.collections?.[collection.slug]?.readVersions?.permission) {
return ( return (
<DocumentInfoProvider
collection={collection}
id={routeProps.match.params.id}
>
<Version <Version
{...routeProps} {...routeProps}
collection={collection} collection={collection}
/> />
</DocumentInfoProvider>
); );
} }

View File

@@ -9,7 +9,7 @@ import { ShimmerEffect } from '../ShimmerEffect';
const baseClass = 'code-editor'; const baseClass = 'code-editor';
const CodeEditor: React.FC<Props> = (props) => { const CodeEditor: React.FC<Props> = (props) => {
const { readOnly, className, options, ...rest } = props; const { readOnly, className, options, height, ...rest } = props;
const { theme } = useTheme(); const { theme } = useTheme();
@@ -23,7 +23,7 @@ const CodeEditor: React.FC<Props> = (props) => {
<Editor <Editor
className={classes} className={classes}
theme={theme === 'dark' ? 'vs-dark' : 'vs'} theme={theme === 'dark' ? 'vs-dark' : 'vs'}
loading={<ShimmerEffect height="35vh" />} loading={<ShimmerEffect height={height} />}
options={ options={
{ {
detectIndentation: true, detectIndentation: true,
@@ -37,6 +37,7 @@ const CodeEditor: React.FC<Props> = (props) => {
...options, ...options,
} }
} }
height={height}
{...rest} {...rest}
/> />
); );

View File

@@ -1,4 +1,4 @@
import React, { useId } from 'react'; import React, { useId, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import Pill from '../Pill'; import Pill from '../Pill';
import Plus from '../../icons/Plus'; import Plus from '../../icons/Plus';
@@ -49,6 +49,8 @@ const ColumnSelector: React.FC<Props> = (props) => {
name, name,
} = col; } = col;
if (col.accessor === '_select') return null;
return ( return (
<Pill <Pill
draggable draggable

View File

@@ -23,9 +23,6 @@ const DeleteDocument: React.FC<Props> = (props) => {
buttonId, buttonId,
collection, collection,
collection: { collection: {
admin: {
useAsTitle,
},
slug, slug,
labels: { labels: {
singular, singular,
@@ -39,7 +36,7 @@ const DeleteDocument: React.FC<Props> = (props) => {
const { toggleModal } = useModal(); const { toggleModal } = useModal();
const history = useHistory(); const history = useHistory();
const { t, i18n } = useTranslation('general'); const { t, i18n } = useTranslation('general');
const title = useTitle(useAsTitle, collection.slug) || id; const title = useTitle(collection);
const titleToRender = titleFromProps || title; const titleToRender = titleFromProps || title;
const modalSlug = `delete-${id}`; const modalSlug = `delete-${id}`;

View 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;
}
}

View 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;

View 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'],
}

View 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);
}
}
}

View 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;

View 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'],
}

View File

@@ -0,0 +1,5 @@
@import '../../../scss/styles.scss';
.field-select {
margin-bottom: base(1);
}

View 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>
);
};

View File

@@ -5,6 +5,8 @@
&__wrap { &__wrap {
display: flex; display: flex;
align-items: center;
background-color: var(--theme-elevation-50);
} }
.search-filter { .search-filter {
@@ -21,26 +23,30 @@
&__buttons-wrap { &__buttons-wrap {
display: flex; display: flex;
margin-left: - base(.5); align-items: center;
margin-right: - base(.5); margin-right: base(.5);
width: calc(100% + #{base(1)});
.btn, .pill {
margin: 0 0 0 base(.5);
}
.btn { .btn {
margin: 0 base(.5); background-color: var(--theme-elevation-100);
cursor: pointer;
padding: 0 base(.25);
border-radius: $style-radius-s;
&:hover {
background-color: var(--theme-elevation-200);
}
} }
} }
&__toggle-columns, &__buttons-active {
&__toggle-where,
&__toggle-sort {
min-width: 140px;
&.btn--style-primary {
svg { svg {
transform: rotate(180deg); transform: rotate(180deg);
} }
} }
}
.column-selector, .column-selector,
.where-builder, .where-builder,
@@ -48,25 +54,10 @@
margin-top: base(1); margin-top: base(1);
} }
@include mid-break {
&__buttons {
margin-left: base(.5);
}
&__buttons-wrap {
margin-left: - base(.25);
margin-right: - base(.25);
width: calc(100% + #{base(0.5)});
.btn {
margin: 0 base(.25);
}
}
}
@include small-break { @include small-break {
&__wrap { &__wrap {
flex-wrap: wrap; flex-wrap: wrap;
background-color: unset;
} }
.search-filter { .search-filter {
@@ -74,11 +65,23 @@
width: 100%; width: 100%;
} }
&__buttons { &__buttons-wrap {
margin: 0; margin-left: - base(.25);
margin-right: - base(.25);
width: calc(100% + #{base(0.5)});
.pill {
margin: 0 base(.25);
padding: base(.5) base(1);
svg {
margin-left: auto;
}
}
} }
&__buttons { &__buttons {
margin: 0;
width: 100%; width: 100%;
} }

View File

@@ -1,6 +1,7 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import AnimateHeight from 'react-animate-height'; import AnimateHeight from 'react-animate-height';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useWindowInfo } from '@faceless-ui/window-info';
import { fieldAffectsData } from '../../../../fields/config/types'; import { fieldAffectsData } from '../../../../fields/config/types';
import SearchFilter from '../SearchFilter'; import SearchFilter from '../SearchFilter';
import ColumnSelector from '../ColumnSelector'; import ColumnSelector from '../ColumnSelector';
@@ -13,6 +14,12 @@ import validateWhereQuery from '../WhereBuilder/validateWhereQuery';
import flattenFields from '../../../../utilities/flattenTopLevelFields'; import flattenFields from '../../../../utilities/flattenTopLevelFields';
import { getTextFieldsToBeSearched } from './getTextFieldsToBeSearched'; import { getTextFieldsToBeSearched } from './getTextFieldsToBeSearched';
import { getTranslation } from '../../../../utilities/getTranslation'; import { getTranslation } from '../../../../utilities/getTranslation';
import Pill from '../Pill';
import Chevron from '../../icons/Chevron';
import EditMany from '../EditMany';
import DeleteMany from '../DeleteMany';
import PublishMany from '../PublishMany';
import UnpublishMany from '../UnpublishMany';
import './index.scss'; import './index.scss';
@@ -26,6 +33,7 @@ const ListControls: React.FC<Props> = (props) => {
handleSortChange, handleSortChange,
handleWhereChange, handleWhereChange,
modifySearchQuery = true, modifySearchQuery = true,
resetParams,
collection: { collection: {
fields, fields,
admin: { admin: {
@@ -45,6 +53,7 @@ const ListControls: React.FC<Props> = (props) => {
const [textFieldsToBeSearched] = useState(getTextFieldsToBeSearched(listSearchableFields, fields)); const [textFieldsToBeSearched] = useState(getTextFieldsToBeSearched(listSearchableFields, fields));
const [visibleDrawer, setVisibleDrawer] = useState<'where' | 'sort' | 'columns'>(shouldInitializeWhereOpened ? 'where' : undefined); const [visibleDrawer, setVisibleDrawer] = useState<'where' | 'sort' | 'columns'>(shouldInitializeWhereOpened ? 'where' : undefined);
const { t, i18n } = useTranslation('general'); const { t, i18n } = useTranslation('general');
const { breakpoints: { s: smallBreak } } = useWindowInfo();
return ( return (
<div className={baseClass}> <div className={baseClass}>
@@ -58,26 +67,44 @@ const ListControls: React.FC<Props> = (props) => {
/> />
<div className={`${baseClass}__buttons`}> <div className={`${baseClass}__buttons`}>
<div className={`${baseClass}__buttons-wrap`}> <div className={`${baseClass}__buttons-wrap`}>
{ !smallBreak && (
<React.Fragment>
<EditMany
collection={collection}
resetParams={resetParams}
/>
<PublishMany
collection={collection}
resetParams={resetParams}
/>
<UnpublishMany
collection={collection}
resetParams={resetParams}
/>
<DeleteMany
collection={collection}
resetParams={resetParams}
/>
</React.Fragment>
)}
{enableColumns && ( {enableColumns && (
<Button <Pill
className={`${baseClass}__toggle-columns`} pillStyle="dark"
buttonStyle={visibleDrawer === 'columns' ? undefined : 'secondary'} className={`${baseClass}__toggle-columns ${visibleDrawer === 'columns' ? `${baseClass}__buttons-active` : ''}`}
onClick={() => setVisibleDrawer(visibleDrawer !== 'columns' ? 'columns' : undefined)} onClick={() => setVisibleDrawer(visibleDrawer !== 'columns' ? 'columns' : undefined)}
icon="chevron" icon={<Chevron />}
iconStyle="none"
> >
{t('columns')} {t('columns')}
</Button> </Pill>
)} )}
<Button <Pill
className={`${baseClass}__toggle-where`} pillStyle="dark"
buttonStyle={visibleDrawer === 'where' ? undefined : 'secondary'} className={`${baseClass}__toggle-where ${visibleDrawer === 'where' ? `${baseClass}__buttons-active` : ''}`}
onClick={() => setVisibleDrawer(visibleDrawer !== 'where' ? 'where' : undefined)} onClick={() => setVisibleDrawer(visibleDrawer !== 'where' ? 'where' : undefined)}
icon="chevron" icon={<Chevron />}
iconStyle="none"
> >
{t('filters')} {t('filters')}
</Button> </Pill>
{enableSort && ( {enableSort && (
<Button <Button
className={`${baseClass}__toggle-sort`} className={`${baseClass}__toggle-sort`}

View File

@@ -1,6 +1,7 @@
import { Where } from '../../../../types'; import { Where } from '../../../../types';
import { SanitizedCollectionConfig } from '../../../../collections/config/types'; import { SanitizedCollectionConfig } from '../../../../collections/config/types';
import { Column } from '../Table/types'; import { Column } from '../Table/types';
import type { Props as ListProps } from '../../views/collections/List/types';
export type Props = { export type Props = {
enableColumns?: boolean enableColumns?: boolean
@@ -9,6 +10,7 @@ export type Props = {
handleSortChange?: (sort: string) => void handleSortChange?: (sort: string) => void
handleWhereChange?: (where: Where) => void handleWhereChange?: (where: Where) => void
collection: SanitizedCollectionConfig collection: SanitizedCollectionConfig
resetParams?: ListProps['resetParams']
} }
export type ListControls = { export type ListControls = {

View File

@@ -222,16 +222,6 @@ export const ListDrawerContent: React.FC<ListDrawerProps> = ({
hasCreatePermission, hasCreatePermission,
disableEyebrow: true, disableEyebrow: true,
modifySearchParams: false, modifySearchParams: false,
onCardClick: (doc) => {
if (typeof onSelect === 'function') {
onSelect({
docID: doc.id,
collectionConfig: selectedCollectionConfig,
});
}
closeModal(drawerSlug);
},
disableCardLink: true,
handleSortChange: setSort, handleSortChange: setSort,
handleWhereChange: setWhere, handleWhereChange: setWhere,
handlePageChange: setPage, handlePageChange: setPage,

View File

@@ -20,15 +20,11 @@
flex-grow: 1; flex-grow: 1;
align-items: flex-start; align-items: flex-start;
.pill { button .pill {
pointer-events: none; pointer-events: none;
margin: 0; margin: 0;
margin-top: base(0.25); margin-top: base(0.25);
margin-left: base(0.5); margin-left: base(0.5);
@include mid-break {
margin-top: 0;
}
} }
} }

View File

@@ -10,7 +10,7 @@ import './index.scss';
export const baseClass = 'list-drawer'; export const baseClass = 'list-drawer';
const formatListDrawerSlug = ({ export const formatListDrawerSlug = ({
depth, depth,
uuid, uuid,
}: { }: {

View 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);
}
}

View 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>
{' '}
&mdash;
<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;

View 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;
}
}

View 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;

View 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'],
}

View File

@@ -58,6 +58,7 @@ const SelectAdapter: React.FC<Props> = (props) => {
isClearable={isClearable} isClearable={isClearable}
filterOption={filterOption} filterOption={filterOption}
onMenuOpen={onMenuOpen} onMenuOpen={onMenuOpen}
menuPlacement="auto"
selectProps={{ selectProps={{
...selectProps, ...selectProps,
}} }}

View File

@@ -7,13 +7,12 @@ const baseClass = 'render-title';
const RenderTitle: React.FC<Props> = (props) => { const RenderTitle: React.FC<Props> = (props) => {
const { const {
useAsTitle,
collection, collection,
title: titleFromProps, title: titleFromProps,
data, data,
fallback = '[untitled]', fallback = '[untitled]',
} = props; } = props;
const titleFromForm = useTitle(useAsTitle, collection); const titleFromForm = useTitle(collection);
let title = titleFromForm; let title = titleFromForm;
if (!title) title = data?.id; if (!title) title = data?.id;

View File

@@ -1,3 +1,5 @@
import { SanitizedCollectionConfig } from '../../../../collections/config/types';
export type Props = { export type Props = {
useAsTitle?: string useAsTitle?: string
data?: { data?: {
@@ -5,5 +7,5 @@ export type Props = {
} }
title?: string title?: string
fallback?: string fallback?: string
collection?: string collection?: SanitizedCollectionConfig
} }

View File

@@ -7,10 +7,20 @@
position: absolute; position: absolute;
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
right: base(.5); left: base(.5);
} }
&__input { &__input {
@include formInput; @include formInput;
box-shadow: none;
padding-left: base(2);
background-color: var(--theme-elevation-50);
border: none;
&:not(:disabled) {
&:hover, &:focus {
box-shadow: none;
}
}
} }
} }

View File

@@ -24,6 +24,7 @@ const Status: React.FC<Props> = () => {
global, global,
id, id,
getVersions, getVersions,
docPermissions,
} = useDocumentInfo(); } = useDocumentInfo();
const { toggleModal } = useModal(); const { toggleModal } = useModal();
const { const {
@@ -114,12 +115,14 @@ const Status: React.FC<Props> = () => {
} }
}, [collection, global, publishedDoc, serverURL, api, id, i18n, locale, resetForm, getVersions, t, toggleModal, revertModalSlug, unPublishModalSlug]); }, [collection, global, publishedDoc, serverURL, api, id, i18n, locale, resetForm, getVersions, t, toggleModal, revertModalSlug, unPublishModalSlug]);
const canUpdate = docPermissions?.update?.permission;
if (statusToRender) { if (statusToRender) {
return ( return (
<div className={baseClass}> <div className={baseClass}>
<div className={`${baseClass}__value-wrap`}> <div className={`${baseClass}__value-wrap`}>
<span className={`${baseClass}__value`}>{t(statusToRender)}</span> <span className={`${baseClass}__value`}>{t(statusToRender)}</span>
{statusToRender === 'published' && ( {canUpdate && statusToRender === 'published' && (
<React.Fragment> <React.Fragment>
&nbsp;&mdash;&nbsp; &nbsp;&mdash;&nbsp;
<Button <Button
@@ -152,7 +155,7 @@ const Status: React.FC<Props> = () => {
</Modal> </Modal>
</React.Fragment> </React.Fragment>
)} )}
{statusToRender === 'changed' && ( {canUpdate && statusToRender === 'changed' && (
<React.Fragment> <React.Fragment>
&nbsp;&mdash;&nbsp; &nbsp;&mdash;&nbsp;
<Button <Button

View File

@@ -1,53 +1,25 @@
import React from 'react'; import React from 'react';
import type { TFunction } from 'react-i18next';
import Cell from '../../views/collections/List/Cell'; import Cell from '../../views/collections/List/Cell';
import SortColumn from '../SortColumn'; import SortColumn from '../SortColumn';
import { SanitizedCollectionConfig } from '../../../../collections/config/types'; import { SanitizedCollectionConfig } from '../../../../collections/config/types';
import { Column } from '../Table/types'; import { Column } from '../Table/types';
import { Field, fieldIsPresentationalOnly } from '../../../../fields/config/types'; import { fieldIsPresentationalOnly } from '../../../../fields/config/types';
import flattenFields from '../../../../utilities/flattenTopLevelFields'; import flattenFields from '../../../../utilities/flattenTopLevelFields';
import { Props as CellProps } from '../../views/collections/List/Cell/types'; import { Props as CellProps } from '../../views/collections/List/Cell/types';
import SelectAll from '../../views/collections/List/SelectAll';
import SelectRow from '../../views/collections/List/SelectRow';
const buildColumns = ({ const buildColumns = ({
collection, collection,
columns, columns,
t,
cellProps, cellProps,
}: { }: {
collection: SanitizedCollectionConfig, collection: SanitizedCollectionConfig,
columns: Pick<Column, 'accessor' | 'active'>[], columns: Pick<Column, 'accessor' | 'active'>[],
t: TFunction,
cellProps: Partial<CellProps>[] cellProps: Partial<CellProps>[]
}): Column[] => { }): Column[] => {
// only insert each base field if it doesn't already exist in the collection
const baseFields: Field[] = [
{
name: 'id',
type: 'text',
label: 'ID',
},
{
name: 'updatedAt',
type: 'date',
label: t('updatedAt'),
},
{
name: 'createdAt',
type: 'date',
label: t('createdAt'),
},
];
const combinedFields = baseFields.reduce((acc, field) => {
// if the field already exists in the collection, don't add it
if (acc.find((f) => 'name' in f && 'name' in field && f.name === field.name)) return acc;
return [...acc, field];
}, collection.fields);
const flattenedFields = flattenFields(combinedFields, true);
// sort the fields to the order of activeColumns // sort the fields to the order of activeColumns
const sortedFields = flattenedFields.sort((a, b) => { const sortedFields = flattenFields(collection.fields, true).sort((a, b) => {
const aIndex = columns.findIndex((column) => column.accessor === a.name); const aIndex = columns.findIndex((column) => column.accessor === a.name);
const bIndex = columns.findIndex((column) => column.accessor === b.name); const bIndex = columns.findIndex((column) => column.accessor === b.name);
if (aIndex === -1 && bIndex === -1) return 0; if (aIndex === -1 && bIndex === -1) return 0;
@@ -58,10 +30,14 @@ const buildColumns = ({
const firstActiveColumn = sortedFields.find((field) => columns.find((column) => column.accessor === field.name)?.active); const firstActiveColumn = sortedFields.find((field) => columns.find((column) => column.accessor === field.name)?.active);
const cols: Column[] = sortedFields.map((field, colIndex) => { let colIndex = -1;
const cols: Column[] = sortedFields.map((field) => {
const isActive = columns.find((column) => column.accessor === field.name)?.active || false; const isActive = columns.find((column) => column.accessor === field.name)?.active || false;
const isFirstActive = firstActiveColumn?.name === field.name; const isFirstActive = firstActiveColumn?.name === field.name;
if (isActive) {
colIndex += 1;
}
const props = cellProps?.[colIndex] || {};
return { return {
active: isActive, active: isActive,
accessor: field.name, accessor: field.name,
@@ -89,7 +65,7 @@ const buildColumns = ({
rowData={rowData} rowData={rowData}
cellData={cellData} cellData={cellData}
link={isFirstActive} link={isFirstActive}
{...(cellProps?.[colIndex] || {})} {...(props)}
/> />
); );
}, },
@@ -97,6 +73,25 @@ const buildColumns = ({
}; };
}); });
if (cellProps?.[0]?.link !== false) {
cols.unshift({
active: true,
label: null,
name: '',
accessor: '_select',
components: {
Heading: (
<SelectAll />
),
renderCell: (rowData) => (
<SelectRow
id={rowData.id}
/>
),
},
});
}
return cols; return cols;
}; };

View File

@@ -1,4 +1,3 @@
import { TFunction } from 'react-i18next';
import { SanitizedCollectionConfig } from '../../../../collections/config/types'; import { SanitizedCollectionConfig } from '../../../../collections/config/types';
import { Column } from '../Table/types'; import { Column } from '../Table/types';
import buildColumns from './buildColumns'; import buildColumns from './buildColumns';
@@ -9,7 +8,6 @@ type TOGGLE = {
type: 'toggle', type: 'toggle',
payload: { payload: {
column: string column: string
t: TFunction
collection: SanitizedCollectionConfig collection: SanitizedCollectionConfig
cellProps: Partial<CellProps>[] cellProps: Partial<CellProps>[]
} }
@@ -19,7 +17,6 @@ type SET = {
type: 'set', type: 'set',
payload: { payload: {
columns: Pick<Column, 'accessor' | 'active'>[] columns: Pick<Column, 'accessor' | 'active'>[]
t: TFunction
collection: SanitizedCollectionConfig collection: SanitizedCollectionConfig
cellProps: Partial<CellProps>[] cellProps: Partial<CellProps>[]
} }
@@ -30,7 +27,6 @@ type MOVE = {
payload: { payload: {
fromIndex: number fromIndex: number
toIndex: number toIndex: number
t: TFunction
collection: SanitizedCollectionConfig collection: SanitizedCollectionConfig
cellProps: Partial<CellProps>[] cellProps: Partial<CellProps>[]
} }
@@ -43,7 +39,6 @@ export const columnReducer = (state: Column[], action: Action): Column[] => {
case 'toggle': { case 'toggle': {
const { const {
column, column,
t,
collection, collection,
cellProps, cellProps,
} = action.payload; } = action.payload;
@@ -62,7 +57,6 @@ export const columnReducer = (state: Column[], action: Action): Column[] => {
return buildColumns({ return buildColumns({
columns: withToggledColumn, columns: withToggledColumn,
collection, collection,
t,
cellProps, cellProps,
}); });
} }
@@ -70,7 +64,6 @@ export const columnReducer = (state: Column[], action: Action): Column[] => {
const { const {
fromIndex, fromIndex,
toIndex, toIndex,
t,
collection, collection,
cellProps, cellProps,
} = action.payload; } = action.payload;
@@ -82,14 +75,12 @@ export const columnReducer = (state: Column[], action: Action): Column[] => {
return buildColumns({ return buildColumns({
columns: withMovedColumn, columns: withMovedColumn,
collection, collection,
t,
cellProps, cellProps,
}); });
} }
case 'set': { case 'set': {
const { const {
columns, columns,
t,
collection, collection,
cellProps, cellProps,
} = action.payload; } = action.payload;
@@ -97,7 +88,6 @@ export const columnReducer = (state: Column[], action: Action): Column[] => {
return buildColumns({ return buildColumns({
columns, columns,
collection, collection,
t,
cellProps, cellProps,
}); });
} }

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useReducer, createContext, useContext, useRef } from 'react'; import React, { useCallback, useEffect, useReducer, createContext, useContext, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { SanitizedCollectionConfig } from '../../../../collections/config/types'; import { SanitizedCollectionConfig } from '../../../../collections/config/types';
import { usePreferences } from '../../utilities/Preferences'; import { usePreferences } from '../../utilities/Preferences';
@@ -8,6 +8,8 @@ import buildColumns from './buildColumns';
import { Action, columnReducer } from './columnReducer'; import { Action, columnReducer } from './columnReducer';
import getInitialColumnState from './getInitialColumns'; import getInitialColumnState from './getInitialColumns';
import { Props as CellProps } from '../../views/collections/List/Cell/types'; import { Props as CellProps } from '../../views/collections/List/Cell/types';
import formatFields from '../../views/collections/List/formatFields';
import { Field } from '../../../../fields/config/types';
export interface ITableColumns { export interface ITableColumns {
columns: Column[] columns: Column[]
@@ -33,21 +35,22 @@ export const TableColumnsProvider: React.FC<{
cellProps, cellProps,
collection, collection,
collection: { collection: {
fields,
admin: { admin: {
useAsTitle, useAsTitle,
defaultColumns, defaultColumns,
}, },
}, },
}) => { }) => {
const { t } = useTranslation('general');
const preferenceKey = `${collection.slug}-list`; const preferenceKey = `${collection.slug}-list`;
const prevCollection = useRef<SanitizedCollectionConfig['slug']>(); const prevCollection = useRef<SanitizedCollectionConfig['slug']>();
const hasInitialized = useRef(false); const hasInitialized = useRef(false);
const { getPreference, setPreference } = usePreferences(); const { getPreference, setPreference } = usePreferences();
const { t } = useTranslation();
const [formattedFields] = useState<Field[]>(() => formatFields(collection, t));
const [tableColumns, dispatchTableColumns] = useReducer(columnReducer, {}, () => { const [tableColumns, dispatchTableColumns] = useReducer(columnReducer, {}, () => {
const initialColumns = getInitialColumnState(fields, useAsTitle, defaultColumns); const initialColumns = getInitialColumnState(formattedFields, useAsTitle, defaultColumns);
return buildColumns({ return buildColumns({
collection, collection,
columns: initialColumns.map((column) => ({ columns: initialColumns.map((column) => ({
@@ -55,7 +58,6 @@ export const TableColumnsProvider: React.FC<{
active: true, active: true,
})), })),
cellProps, cellProps,
t,
}); });
}); });
@@ -72,7 +74,7 @@ export const TableColumnsProvider: React.FC<{
const currentPreferences = await getPreference<ListPreferences>(preferenceKey); const currentPreferences = await getPreference<ListPreferences>(preferenceKey);
prevCollection.current = collection.slug; prevCollection.current = collection.slug;
const initialColumns = getInitialColumnState(fields, useAsTitle, defaultColumns); const initialColumns = getInitialColumnState(formattedFields, useAsTitle, defaultColumns);
const newCols = currentPreferences?.columns || initialColumns; const newCols = currentPreferences?.columns || initialColumns;
dispatchTableColumns({ dispatchTableColumns({
@@ -89,8 +91,7 @@ export const TableColumnsProvider: React.FC<{
} }
return column; return column;
}), }),
t, collection: { ...collection, fields: formatFields(collection, t) },
collection,
cellProps, cellProps,
}, },
}); });
@@ -100,7 +101,7 @@ export const TableColumnsProvider: React.FC<{
}; };
sync(); sync();
}, [preferenceKey, setPreference, fields, tableColumns, getPreference, useAsTitle, defaultColumns, t, collection, cellProps]); }, [preferenceKey, setPreference, tableColumns, getPreference, useAsTitle, defaultColumns, collection, cellProps, formattedFields, t]);
// ///////////////////////////////////// // /////////////////////////////////////
// Set preferences on column change // Set preferences on column change
@@ -130,12 +131,11 @@ export const TableColumnsProvider: React.FC<{
dispatchTableColumns({ dispatchTableColumns({
type: 'set', type: 'set',
payload: { payload: {
collection, collection: { ...collection, fields: formatFields(collection, t) },
columns: columns.map((column) => ({ columns: columns.map((column) => ({
accessor: column, accessor: column,
active: true, active: true,
})), })),
t,
// onSelect, // onSelect,
cellProps, cellProps,
}, },
@@ -153,8 +153,7 @@ export const TableColumnsProvider: React.FC<{
payload: { payload: {
fromIndex, fromIndex,
toIndex, toIndex,
collection, collection: { ...collection, fields: formatFields(collection, t) },
t,
cellProps, cellProps,
}, },
}); });
@@ -165,8 +164,7 @@ export const TableColumnsProvider: React.FC<{
type: 'toggle', type: 'toggle',
payload: { payload: {
column, column,
collection, collection: { ...collection, fields: formatFields(collection, t) },
t,
cellProps, cellProps,
}, },
}); });

View File

@@ -15,6 +15,7 @@ const Thumbnail: React.FC<Props> = (props) => {
}, },
collection, collection,
size, size,
className = '',
} = props; } = props;
const thumbnailSRC = useThumbnail(collection, doc); const thumbnailSRC = useThumbnail(collection, doc);
@@ -22,6 +23,7 @@ const Thumbnail: React.FC<Props> = (props) => {
const classes = [ const classes = [
baseClass, baseClass,
`${baseClass}--size-${size || 'medium'}`, `${baseClass}--size-${size || 'medium'}`,
className,
].join(' '); ].join(' ');
return ( return (

View File

@@ -4,4 +4,5 @@ export type Props = {
doc: Record<string, unknown> doc: Record<string, unknown>
collection: SanitizedCollectionConfig collection: SanitizedCollectionConfig
size?: 'small' | 'medium' | 'large' | 'expand', size?: 'small' | 'medium' | 'large' | 'expand',
className?: string
} }

View File

@@ -2,6 +2,8 @@ import React from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Props } from './types'; import { Props } from './types';
import Thumbnail from '../Thumbnail'; import Thumbnail from '../Thumbnail';
import { useConfig } from '../../utilities/Config';
import { formatUseAsTitle } from '../../../hooks/useTitle';
import './index.scss'; import './index.scss';
@@ -14,12 +16,13 @@ export const ThumbnailCard: React.FC<Props> = (props) => {
doc, doc,
collection, collection,
thumbnail, thumbnail,
label, label: labelFromProps,
alignLabel, alignLabel,
onKeyDown, onKeyDown,
} = props; } = props;
const { t } = useTranslation('general'); const { t, i18n } = useTranslation('general');
const config = useConfig();
const classes = [ const classes = [
baseClass, baseClass,
@@ -28,10 +31,20 @@ export const ThumbnailCard: React.FC<Props> = (props) => {
alignLabel && `${baseClass}--align-label-${alignLabel}`, alignLabel && `${baseClass}--align-label-${alignLabel}`,
].filter(Boolean).join(' '); ].filter(Boolean).join(' ');
const title: any = doc?.[collection?.admin?.useAsTitle] || doc?.filename || `[${t('untitled')}]`; let title = labelFromProps;
if (!title) {
title = formatUseAsTitle({
doc,
collection,
i18n,
config,
}) || doc?.filename as string || `[${t('untitled')}]`;
}
return ( return (
<div <div
title={title}
className={classes} className={classes}
onClick={typeof onClick === 'function' ? onClick : undefined} onClick={typeof onClick === 'function' ? onClick : undefined}
onKeyDown={typeof onKeyDown === 'function' ? onKeyDown : undefined} onKeyDown={typeof onKeyDown === 'function' ? onKeyDown : undefined}
@@ -47,7 +60,7 @@ export const ThumbnailCard: React.FC<Props> = (props) => {
)} )}
</div> </div>
<div className={`${baseClass}__label`}> <div className={`${baseClass}__label`}>
{label || title} {title}
</div> </div>
</div> </div>
); );

View 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;
}
}

View 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;

View 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'],
}

View File

@@ -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%;
}
}
}

View File

@@ -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;

View File

@@ -1,7 +0,0 @@
import { SanitizedCollectionConfig } from '../../../../collections/config/types';
export type Props = {
docs?: Record<string, unknown>[],
collection: SanitizedCollectionConfig,
onCardClick: (doc) => void,
}

View File

@@ -7,10 +7,11 @@ import { useConfig } from '../../utilities/Config';
import { useForm } from '../Form/context'; import { useForm } from '../Form/context';
type NullifyLocaleFieldProps = { type NullifyLocaleFieldProps = {
localized: boolean
path: string path: string
fieldValue?: null | [] | number fieldValue?: null | [] | number
} }
export const NullifyLocaleField: React.FC<NullifyLocaleFieldProps> = ({ path, fieldValue }) => { export const NullifyLocaleField: React.FC<NullifyLocaleFieldProps> = ({ localized, path, fieldValue }) => {
const { dispatchFields, setModified } = useForm(); const { dispatchFields, setModified } = useForm();
const currentLocale = useLocale(); const currentLocale = useLocale();
const { localization } = useConfig(); const { localization } = useConfig();
@@ -30,8 +31,8 @@ export const NullifyLocaleField: React.FC<NullifyLocaleFieldProps> = ({ path, fi
setChecked(useFallback); setChecked(useFallback);
}; };
if (currentLocale === defaultLocale || (localization && !localization.fallback)) { if (!localized || currentLocale === defaultLocale || (localization && !localization.fallback)) {
// hide when editing default locale or when fallback is disabled // hide when field is not localized or editing default locale or when fallback is disabled
return null; return null;
} }

View File

@@ -45,6 +45,7 @@ const ArrayFieldType: React.FC<Props> = (props) => {
minRows, minRows,
permissions, permissions,
indexPath, indexPath,
localized,
admin: { admin: {
readOnly, readOnly,
description, description,
@@ -62,13 +63,12 @@ const ArrayFieldType: React.FC<Props> = (props) => {
const CustomRowLabel = components?.RowLabel || undefined; const CustomRowLabel = components?.RowLabel || undefined;
const { preferencesKey } = useDocumentInfo(); const { preferencesKey, id } = useDocumentInfo();
const { getPreference } = usePreferences(); const { getPreference } = usePreferences();
const { setPreference } = usePreferences(); const { setPreference } = usePreferences();
const [rows, dispatchRows] = useReducer(reducer, undefined); const [rows, dispatchRows] = useReducer(reducer, undefined);
const formContext = useForm(); const formContext = useForm();
const { user } = useAuth(); const { user } = useAuth();
const { id } = useDocumentInfo();
const locale = useLocale(); const locale = useLocale();
const operation = useOperation(); const operation = useOperation();
const { t, i18n } = useTranslation('fields'); const { t, i18n } = useTranslation('fields');
@@ -260,6 +260,7 @@ const ArrayFieldType: React.FC<Props> = (props) => {
</header> </header>
<NullifyLocaleField <NullifyLocaleField
localized={localized}
path={path} path={path}
fieldValue={value} fieldValue={value}
/> />

View File

@@ -17,7 +17,7 @@
&__block { &__block {
margin: base(0.5); margin: base(0.5);
width: calc(12.5% - #{base(1)}); width: calc((100% / 6) - #{base(1)});
} }
&__default-image { &__default-image {

View File

@@ -53,6 +53,7 @@ const BlocksField: React.FC<Props> = (props) => {
validate = blocksValidator, validate = blocksValidator,
permissions, permissions,
indexPath, indexPath,
localized,
admin: { admin: {
readOnly, readOnly,
description, description,
@@ -64,13 +65,12 @@ const BlocksField: React.FC<Props> = (props) => {
const path = pathFromProps || name; const path = pathFromProps || name;
const { preferencesKey } = useDocumentInfo(); const { preferencesKey, id } = useDocumentInfo();
const { getPreference } = usePreferences(); const { getPreference } = usePreferences();
const { setPreference } = usePreferences(); const { setPreference } = usePreferences();
const [rows, dispatchRows] = useReducer(reducer, undefined); const [rows, dispatchRows] = useReducer(reducer, undefined);
const formContext = useForm(); const formContext = useForm();
const { user } = useAuth(); const { user } = useAuth();
const { id } = useDocumentInfo();
const locale = useLocale(); const locale = useLocale();
const operation = useOperation(); const operation = useOperation();
const { dispatchFields, setModified } = formContext; const { dispatchFields, setModified } = formContext;
@@ -257,6 +257,7 @@ const BlocksField: React.FC<Props> = (props) => {
</header> </header>
<NullifyLocaleField <NullifyLocaleField
localized={localized}
path={path} path={path}
fieldValue={value} fieldValue={value}
/> />

View File

@@ -10,6 +10,7 @@ import Plus from '../../../../icons/Plus';
import { getTranslation } from '../../../../../../utilities/getTranslation'; import { getTranslation } from '../../../../../../utilities/getTranslation';
import Tooltip from '../../../../elements/Tooltip'; import Tooltip from '../../../../elements/Tooltip';
import { useDocumentDrawer } from '../../../../elements/DocumentDrawer'; import { useDocumentDrawer } from '../../../../elements/DocumentDrawer';
import { useConfig } from '../../../../utilities/Config';
import './index.scss'; import './index.scss';
@@ -25,6 +26,8 @@ export const AddNewRelation: React.FC<Props> = ({ path, hasMany, relationTo, val
const [popupOpen, setPopupOpen] = useState(false); const [popupOpen, setPopupOpen] = useState(false);
const { t, i18n } = useTranslation('fields'); const { t, i18n } = useTranslation('fields');
const [showTooltip, setShowTooltip] = useState(false); const [showTooltip, setShowTooltip] = useState(false);
const config = useConfig();
const [ const [
DocumentDrawer, DocumentDrawer,
DocumentDrawerToggler, DocumentDrawerToggler,
@@ -47,6 +50,7 @@ export const AddNewRelation: React.FC<Props> = ({ path, hasMany, relationTo, val
], ],
sort: true, sort: true,
i18n, i18n,
config,
}); });
if (hasMany) { if (hasMany) {
@@ -56,7 +60,7 @@ export const AddNewRelation: React.FC<Props> = ({ path, hasMany, relationTo, val
} }
setSelectedCollection(undefined); setSelectedCollection(undefined);
}, [relationTo, collectionConfig, dispatchOptions, i18n, hasMany, setValue, value]); }, [relationTo, collectionConfig, dispatchOptions, i18n, hasMany, setValue, value, config]);
const onPopopToggle = useCallback((state) => { const onPopopToggle = useCallback((state) => {
setPopupOpen(state); setPopupOpen(state);

View File

@@ -1,7 +1,7 @@
import { Value } from './types'; import { Value } from './types';
type RelationMap = { type RelationMap = {
[relation: string]: unknown[] [relation: string]: (string | number)[]
} }
type CreateRelationMap = (args: { type CreateRelationMap = (args: {

View File

@@ -56,13 +56,15 @@ const Relationship: React.FC<Props> = (props) => {
} = {}, } = {},
} = props; } = props;
const config = useConfig();
const { const {
serverURL, serverURL,
routes: { routes: {
api, api,
}, },
collections, collections,
} = useConfig(); } = config;
const { t, i18n } = useTranslation('fields'); const { t, i18n } = useTranslation('fields');
const { permissions } = useAuth(); const { permissions } = useAuth();
@@ -172,9 +174,19 @@ const Relationship: React.FC<Props> = (props) => {
if (response.ok) { if (response.ok) {
const data: PaginatedDocs<unknown> = await response.json(); const data: PaginatedDocs<unknown> = await response.json();
if (data.docs.length > 0) { if (data.docs.length > 0) {
resultsFetched += data.docs.length; resultsFetched += data.docs.length;
dispatchOptions({ type: 'ADD', docs: data.docs, collection, sort, i18n });
dispatchOptions({
type: 'ADD',
docs: data.docs,
collection,
sort,
i18n,
config,
});
setLastLoadedPage(data.page); setLastLoadedPage(data.page);
if (!data.nextPage) { if (!data.nextPage) {
@@ -190,7 +202,15 @@ const Relationship: React.FC<Props> = (props) => {
} else if (response.status === 403) { } else if (response.status === 403) {
setLastFullyLoadedRelation(relations.indexOf(relation)); setLastFullyLoadedRelation(relations.indexOf(relation));
lastLoadedPageToUse = 1; lastLoadedPageToUse = 1;
dispatchOptions({ type: 'ADD', docs: [], collection, sort, ids: relationMap[relation], i18n }); dispatchOptions({
type: 'ADD',
docs: [],
collection,
sort,
ids: relationMap[relation],
i18n,
config,
});
} else { } else {
setErrorLoading(t('error:unspecific')); setErrorLoading(t('error:unspecific'));
} }
@@ -211,6 +231,7 @@ const Relationship: React.FC<Props> = (props) => {
t, t,
i18n, i18n,
locale, locale,
config,
]); ]);
const updateSearch = useDebouncedCallback((searchArg: string, valueArg: Value | Value[]) => { const updateSearch = useDebouncedCallback((searchArg: string, valueArg: Value | Value[]) => {
@@ -261,13 +282,24 @@ const Relationship: React.FC<Props> = (props) => {
'Accept-Language': i18n.language, 'Accept-Language': i18n.language,
}, },
}); });
const collection = collections.find((coll) => coll.slug === relation); const collection = collections.find((coll) => coll.slug === relation);
let docs = [];
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
dispatchOptions({ type: 'ADD', docs: data.docs, collection, sort: true, ids: idsToLoad, i18n }); docs = data.docs;
} else if (response.status === 403) {
dispatchOptions({ type: 'ADD', docs: [], collection, sort: true, ids: idsToLoad, i18n });
} }
dispatchOptions({
type: 'ADD',
docs,
collection,
sort: true,
ids: idsToLoad,
i18n,
config,
});
} }
} }
}, Promise.resolve()); }, Promise.resolve());
@@ -283,6 +315,7 @@ const Relationship: React.FC<Props> = (props) => {
i18n, i18n,
relationTo, relationTo,
locale, locale,
config,
]); ]);
// Determine if we should switch to word boundary search // Determine if we should switch to word boundary search
@@ -311,8 +344,8 @@ const Relationship: React.FC<Props> = (props) => {
}, [relationTo, filterOptionsResult, locale]); }, [relationTo, filterOptionsResult, locale]);
const onSave = useCallback<DocumentDrawerProps['onSave']>((args) => { const onSave = useCallback<DocumentDrawerProps['onSave']>((args) => {
dispatchOptions({ type: 'UPDATE', doc: args.doc, collection: args.collectionConfig, i18n }); dispatchOptions({ type: 'UPDATE', doc: args.doc, collection: args.collectionConfig, i18n, config });
}, [i18n]); }, [i18n, config]);
const classes = [ const classes = [
'field-type', 'field-type',

View File

@@ -1,5 +1,6 @@
import { Option, Action, OptionGroup } from './types'; import { Option, Action, OptionGroup } from './types';
import { getTranslation } from '../../../../../utilities/getTranslation'; import { getTranslation } from '../../../../../utilities/getTranslation';
import { formatUseAsTitle } from '../../../../hooks/useTitle';
const reduceToIDs = (options) => options.reduce((ids, option) => { const reduceToIDs = (options) => options.reduce((ids, option) => {
if (option.options) { if (option.options) {
@@ -30,15 +31,22 @@ const optionsReducer = (state: OptionGroup[], action: Action): OptionGroup[] =>
} }
case 'UPDATE': { case 'UPDATE': {
const { collection, doc, i18n } = action; const { collection, doc, i18n, config } = action;
const relation = collection.slug; const relation = collection.slug;
const newOptions = [...state]; const newOptions = [...state];
const labelKey = collection.admin.useAsTitle || 'id';
const docTitle = formatUseAsTitle({
doc,
collection,
i18n,
config,
});
const foundOptionGroup = newOptions.find((optionGroup) => optionGroup.label === collection.labels.plural); const foundOptionGroup = newOptions.find((optionGroup) => optionGroup.label === collection.labels.plural);
const foundOption = foundOptionGroup?.options?.find((option) => option.value === doc.id); const foundOption = foundOptionGroup?.options?.find((option) => option.value === doc.id);
if (foundOption) { if (foundOption) {
foundOption.label = doc[labelKey] || `${i18n.t('general:untitled')} - ID: ${doc.id}`; foundOption.label = docTitle || `${i18n.t('general:untitled')} - ID: ${doc.id}`;
foundOption.relationTo = relation; foundOption.relationTo = relation;
} }
@@ -46,9 +54,8 @@ const optionsReducer = (state: OptionGroup[], action: Action): OptionGroup[] =>
} }
case 'ADD': { case 'ADD': {
const { collection, docs, sort, ids = [], i18n } = action; const { collection, docs, sort, ids = [], i18n, config } = action;
const relation = collection.slug; const relation = collection.slug;
const labelKey = collection.admin.useAsTitle || 'id';
const loadedIDs = reduceToIDs(state); const loadedIDs = reduceToIDs(state);
const newOptions = [...state]; const newOptions = [...state];
const optionsToAddTo = newOptions.find((optionGroup) => optionGroup.label === collection.labels.plural); const optionsToAddTo = newOptions.find((optionGroup) => optionGroup.label === collection.labels.plural);
@@ -57,10 +64,17 @@ const optionsReducer = (state: OptionGroup[], action: Action): OptionGroup[] =>
if (loadedIDs.indexOf(doc.id) === -1) { if (loadedIDs.indexOf(doc.id) === -1) {
loadedIDs.push(doc.id); loadedIDs.push(doc.id);
const docTitle = formatUseAsTitle({
doc,
collection,
i18n,
config,
});
return [ return [
...docSubOptions, ...docSubOptions,
{ {
label: doc[labelKey] || `${i18n.t('general:untitled')} - ID: ${doc.id}`, label: docTitle || `${i18n.t('general:untitled')} - ID: ${doc.id}`,
relationTo: relation, relationTo: relation,
value: doc.id, value: doc.id,
}, },
@@ -74,7 +88,7 @@ const optionsReducer = (state: OptionGroup[], action: Action): OptionGroup[] =>
if (!loadedIDs.includes(id)) { if (!loadedIDs.includes(id)) {
newSubOptions.push({ newSubOptions.push({
relationTo: relation, relationTo: relation,
label: labelKey === 'id' ? id : `${i18n.t('general:untitled')} - ID: ${id}`, label: `${i18n.t('general:untitled')} - ID: ${id}`,
value: id, value: id,
}); });
} }

View File

@@ -2,6 +2,7 @@ import i18n from 'i18next';
import { SanitizedCollectionConfig } from '../../../../../collections/config/types'; import { SanitizedCollectionConfig } from '../../../../../collections/config/types';
import { RelationshipField } from '../../../../../fields/config/types'; import { RelationshipField } from '../../../../../fields/config/types';
import { Where } from '../../../../../types'; import { Where } from '../../../../../types';
import { SanitizedConfig } from '../../../../../config/types';
export type Props = Omit<RelationshipField, 'type'> & { export type Props = Omit<RelationshipField, 'type'> & {
path?: string path?: string
@@ -35,6 +36,7 @@ type UPDATE = {
doc: any doc: any
collection: SanitizedCollectionConfig collection: SanitizedCollectionConfig
i18n: typeof i18n i18n: typeof i18n
config: SanitizedConfig
} }
type ADD = { type ADD = {
@@ -42,8 +44,9 @@ type ADD = {
docs: any[] docs: any[]
collection: SanitizedCollectionConfig collection: SanitizedCollectionConfig
sort?: boolean sort?: boolean
ids?: unknown[] ids?: (string | number)[]
i18n: typeof i18n i18n: typeof i18n
config: SanitizedConfig
} }
export type Action = CLEAR | ADD | UPDATE export type Action = CLEAR | ADD | UPDATE

View File

@@ -3,6 +3,7 @@
.field-type.textarea { .field-type.textarea {
position: relative; position: relative;
margin-bottom: $baseline; margin-bottom: $baseline;
padding-bottom: base(2.5);
.textarea-outer { .textarea-outer {
@include formInput(); @include formInput();
@@ -21,8 +22,8 @@
} }
&.error { &.error {
textarea { .textarea-outer {
background-color: var(--theme-error-200); background: var(--theme-error-200);
} }
} }
@@ -82,4 +83,8 @@
content: attr(data-after); content: attr(data-after);
opacity: 0.5; opacity: 0.5;
} }
@include mid-break {
padding: 0;
}
} }

View 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;
}
}

View 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;

View File

@@ -5,7 +5,7 @@ import qs from 'qs';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useConfig } from '../Config'; import { useConfig } from '../Config';
import { PaginatedDocs } from '../../../../mongoose/types'; import { PaginatedDocs } from '../../../../mongoose/types';
import { ContextType, DocumentPermissions, EntityType, Props, Version } from './types'; import { ContextType, DocumentPermissions, Props, Version } from './types';
import { TypeWithID } from '../../../../globals/config/types'; import { TypeWithID } from '../../../../globals/config/types';
import { TypeWithTimestamps } from '../../../../collections/config/types'; import { TypeWithTimestamps } from '../../../../collections/config/types';
import { Where } from '../../../../types'; import { Where } from '../../../../types';
@@ -15,6 +15,8 @@ import { useAuth } from '../Auth';
const Context = createContext({} as ContextType); const Context = createContext({} as ContextType);
export const useDocumentInfo = (): ContextType => useContext(Context);
export const DocumentInfoProvider: React.FC<Props> = ({ export const DocumentInfoProvider: React.FC<Props> = ({
children, children,
global, global,
@@ -32,7 +34,7 @@ export const DocumentInfoProvider: React.FC<Props> = ({
const baseURL = `${serverURL}${api}`; const baseURL = `${serverURL}${api}`;
let slug: string; let slug: string;
let type: EntityType; let type: 'global' | 'collection';
let pluralType: 'globals' | 'collections'; let pluralType: 'globals' | 'collections';
let preferencesKey: string; let preferencesKey: string;
@@ -233,5 +235,3 @@ export const DocumentInfoProvider: React.FC<Props> = ({
</Context.Provider> </Context.Provider>
); );
}; };
export const useDocumentInfo = (): ContextType => useContext(Context);

View File

@@ -9,13 +9,9 @@ export type Version = TypeWithVersion<any>
export type DocumentPermissions = null | GlobalPermission | CollectionPermission export type DocumentPermissions = null | GlobalPermission | CollectionPermission
export type EntityType = 'global' | 'collection'
export type ContextType = { export type ContextType = {
collection?: SanitizedCollectionConfig collection?: SanitizedCollectionConfig
global?: SanitizedGlobalConfig global?: SanitizedGlobalConfig
type: EntityType
/** Slug of the collection or global */
slug?: string slug?: string
id?: string | number id?: string | number
preferencesKey?: string preferencesKey?: string

View File

@@ -96,7 +96,7 @@ const DefaultAccount: React.FC<Props> = (props) => {
<h1> <h1>
<RenderTitle <RenderTitle
data={data} data={data}
collection={collection.slug} collection={collection}
useAsTitle={useAsTitle} useAsTitle={useAsTitle}
fallback={`[${t('general:untitled')}]`} fallback={`[${t('general:untitled')}]`}
/> />

View File

@@ -20,6 +20,7 @@
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
margin: 0; margin: 0;
line-height: 1.25;
} }
margin-bottom: base(1); margin-bottom: base(1);

View File

@@ -14,9 +14,9 @@ const baseClass = 'select-diff';
const getOptionsToRender = (value: string, options: SelectField['options'], hasMany: boolean): string | OptionObject | (OptionObject | string)[] => { const getOptionsToRender = (value: string, options: SelectField['options'], hasMany: boolean): string | OptionObject | (OptionObject | string)[] => {
if (hasMany && Array.isArray(value)) { if (hasMany && Array.isArray(value)) {
return value.map((val) => options.find((option) => (typeof option === 'string' ? option : option.value) === val) || val); return value.map((val) => options.find((option) => (typeof option === 'string' ? option : option.value) === val) || String(val));
} }
return options.find((option) => (typeof option === 'string' ? option : option.value) === value) || value; return options.find((option) => (typeof option === 'string' ? option : option.value) === value) || String(value);
}; };
const getTranslatedOptions = (options: string | OptionObject | (OptionObject | string)[], i18n: Ii18n): string => { const getTranslatedOptions = (options: string | OptionObject | (OptionObject | string)[], i18n: Ii18n): string => {

View File

@@ -21,8 +21,8 @@ const Tabs: React.FC<Props & { field: TabsField }> = ({
return ( return (
<Nested <Nested
key={i} key={i}
version={version[tab.name]} version={version?.[tab.name]}
comparison={comparison[tab.name]} comparison={comparison?.[tab.name]}
permissions={permissions} permissions={permissions}
field={tab} field={tab}
locales={locales} locales={locales}

View File

@@ -3,6 +3,7 @@ import { useRouteMatch } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useConfig } from '../../utilities/Config'; import { useConfig } from '../../utilities/Config';
import { useAuth } from '../../utilities/Auth'; import { useAuth } from '../../utilities/Auth';
import { useDocumentInfo } from '../../utilities/DocumentInfo';
import usePayloadAPI from '../../../hooks/usePayloadAPI'; import usePayloadAPI from '../../../hooks/usePayloadAPI';
import Eyebrow from '../../elements/Eyebrow'; import Eyebrow from '../../elements/Eyebrow';
import { useStepNav } from '../../elements/StepNav'; import { useStepNav } from '../../elements/StepNav';
@@ -36,6 +37,7 @@ const VersionView: React.FC<Props> = ({ collection, global }) => {
const { permissions } = useAuth(); const { permissions } = useAuth();
const locale = useLocale(); const locale = useLocale();
const { t, i18n } = useTranslation('version'); const { t, i18n } = useTranslation('version');
const { docPermissions } = useDocumentInfo();
let originalDocFetchURL: string; let originalDocFetchURL: string;
let versionFetchURL: string; let versionFetchURL: string;
@@ -163,6 +165,8 @@ const VersionView: React.FC<Props> = ({ collection, global }) => {
comparison = publishedDoc; comparison = publishedDoc;
} }
const canUpdate = docPermissions?.update?.permission;
return ( return (
<React.Fragment> <React.Fragment>
<div className={baseClass}> <div className={baseClass}>
@@ -179,6 +183,7 @@ const VersionView: React.FC<Props> = ({ collection, global }) => {
<h2> <h2>
{formattedCreatedAt} {formattedCreatedAt}
</h2> </h2>
{canUpdate && (
<Restore <Restore
className={`${baseClass}__restore`} className={`${baseClass}__restore`}
collection={collection} collection={collection}
@@ -187,6 +192,7 @@ const VersionView: React.FC<Props> = ({ collection, global }) => {
versionID={versionID} versionID={versionID}
versionDate={formattedCreatedAt} versionDate={formattedCreatedAt}
/> />
)}
</header> </header>
<div className={`${baseClass}__controls`}> <div className={`${baseClass}__controls`}>
<CompareVersion <CompareVersion

View File

@@ -128,7 +128,7 @@ const DefaultEditView: React.FC<Props> = (props) => {
<h1> <h1>
<RenderTitle <RenderTitle
data={data} data={data}
collection={collection.slug} collection={collection}
useAsTitle={useAsTitle} useAsTitle={useAsTitle}
fallback={`[${t('untitled')}]`} fallback={`[${t('untitled')}]`}
/> />

View File

@@ -26,7 +26,7 @@ export const SetStepNav: React.FC<{
const { t, i18n } = useTranslation('general'); const { t, i18n } = useTranslation('general');
const { routes: { admin } } = useConfig(); const { routes: { admin } } = useConfig();
const title = useTitle(useAsTitle, collection.slug); const title = useTitle(collection);
useEffect(() => { useEffect(() => {
const nav: StepNavItem[] = [{ const nav: StepNavItem[] = [{

View File

@@ -2,6 +2,7 @@ import React, {
useState, useRef, useEffect, useCallback, useState, useRef, useEffect, useCallback,
} from 'react'; } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useDocumentInfo } from '../../../../utilities/DocumentInfo';
import useField from '../../../../forms/useField'; import useField from '../../../../forms/useField';
import Button from '../../../../elements/Button'; import Button from '../../../../elements/Button';
import FileDetails from '../../../../elements/FileDetails'; import FileDetails from '../../../../elements/FileDetails';
@@ -40,6 +41,7 @@ const Upload: React.FC<Props> = (props) => {
const [replacingFile, setReplacingFile] = useState(false); const [replacingFile, setReplacingFile] = useState(false);
const { t } = useTranslation('upload'); const { t } = useTranslation('upload');
const [doc, setDoc] = useState(reduceFieldsToValues(internalState || {}, true)); const [doc, setDoc] = useState(reduceFieldsToValues(internalState || {}, true));
const { docPermissions } = useDocumentInfo();
const { const {
value, value,
@@ -129,6 +131,8 @@ const Upload: React.FC<Props> = (props) => {
'field-type', 'field-type',
].filter(Boolean).join(' '); ].filter(Boolean).join(' ');
const canRemoveUpload = docPermissions?.update?.permission && 'delete' in docPermissions && docPermissions?.delete?.permission;
return ( return (
<div className={classes}> <div className={classes}>
<Error <Error
@@ -139,10 +143,10 @@ const Upload: React.FC<Props> = (props) => {
<FileDetails <FileDetails
doc={doc} doc={doc}
collection={collection} collection={collection}
handleRemove={() => { handleRemove={canRemoveUpload ? () => {
setReplacingFile(true); setReplacingFile(true);
setValue(null); setValue(null);
}} } : undefined}
/> />
)} )}
{(!doc.filename || replacingFile) && ( {(!doc.filename || replacingFile) && (

View File

@@ -212,9 +212,13 @@
padding-right: var(--gutter-h); padding-right: var(--gutter-h);
} }
&__sidebar-wrap {
border-left: 0;
}
&__sidebar-fields { &__sidebar-fields {
margin-bottom: base(1); margin-bottom: 0;
padding-top: base(1); padding-top: 0;
padding-right: var(--gutter-h); padding-right: var(--gutter-h);
.preview-btn { .preview-btn {
@@ -234,6 +238,7 @@
&__sidebar { &__sidebar {
padding-bottom: base(3.5); padding-bottom: base(3.5);
overflow: visible;
} }
} }
} }

View File

@@ -0,0 +1,18 @@
@import '../../../../../../../scss/styles.scss';
.file {
display: flex;
flex-wrap: nowrap;
margin: base(-.25) 0;
&__thumbnail {
display: inline-block;
max-width: base(3);
height: base(3);
}
&__filename {
align-self: center;
margin-left: base(1);
}
}

View File

@@ -0,0 +1,25 @@
import React from 'react';
import Thumbnail from '../../../../../../elements/Thumbnail';
import './index.scss';
const baseClass = 'file';
const File = ({ rowData, data, collection }) => {
return (
<div className={baseClass}>
<Thumbnail
size="small"
className={`${baseClass}__thumbnail`}
doc={{
...rowData,
filename: data,
}}
collection={collection}
/>
<span className={`${baseClass}__filename`}>{ String(data) }</span>
</div>
);
};
export default File;

View File

@@ -4,6 +4,8 @@ import { useConfig } from '../../../../../../utilities/Config';
import useIntersect from '../../../../../../../hooks/useIntersect'; import useIntersect from '../../../../../../../hooks/useIntersect';
import { useListRelationships } from '../../../RelationshipProvider'; import { useListRelationships } from '../../../RelationshipProvider';
import { getTranslation } from '../../../../../../../../utilities/getTranslation'; import { getTranslation } from '../../../../../../../../utilities/getTranslation';
import { formatUseAsTitle } from '../../../../../../../hooks/useTitle';
import { Props as DefaultCellProps } from '../../types';
import './index.scss'; import './index.scss';
@@ -11,9 +13,13 @@ type Value = { relationTo: string, value: number | string };
const baseClass = 'relationship-cell'; const baseClass = 'relationship-cell';
const totalToShow = 3; const totalToShow = 3;
const RelationshipCell = (props) => { const RelationshipCell: React.FC<{
field: DefaultCellProps['field']
data: DefaultCellProps['cellData']
}> = (props) => {
const { field, data: cellData } = props; const { field, data: cellData } = props;
const { collections, routes } = useConfig(); const config = useConfig();
const { collections, routes } = config;
const [intersectionRef, entry] = useIntersect(); const [intersectionRef, entry] = useIntersect();
const [values, setValues] = useState<Value[]>([]); const [values, setValues] = useState<Value[]>([]);
const { getRelationships, documents } = useListRelationships(); const { getRelationships, documents } = useListRelationships();
@@ -31,7 +37,7 @@ const RelationshipCell = (props) => {
if (typeof cell === 'object' && 'relationTo' in cell && 'value' in cell) { if (typeof cell === 'object' && 'relationTo' in cell && 'value' in cell) {
formattedValues.push(cell); formattedValues.push(cell);
} }
if ((typeof cell === 'number' || typeof cell === 'string') && typeof field.relationTo === 'string') { if ((typeof cell === 'number' || typeof cell === 'string') && 'relationTo' in field && typeof field.relationTo === 'string') {
formattedValues.push({ formattedValues.push({
value: cell, value: cell,
relationTo: field.relationTo, relationTo: field.relationTo,
@@ -52,13 +58,19 @@ const RelationshipCell = (props) => {
{values.map(({ relationTo, value }, i) => { {values.map(({ relationTo, value }, i) => {
const document = documents[relationTo][value]; const document = documents[relationTo][value];
const relatedCollection = collections.find(({ slug }) => slug === relationTo); const relatedCollection = collections.find(({ slug }) => slug === relationTo);
const label = document?.[relatedCollection.admin.useAsTitle] ? document[relatedCollection.admin.useAsTitle] : `${t('untitled')} - ID: ${value}`;
const label = formatUseAsTitle({
doc: document === false ? null : document,
collection: relatedCollection,
i18n,
config,
});
return ( return (
<React.Fragment key={i}> <React.Fragment key={i}>
{document === false && `${t('untitled')} - ID: ${value}`} {document === false && `${t('untitled')} - ID: ${value}`}
{document === null && `${t('loading')}...`} {document === null && `${t('loading')}...`}
{document && label} {document && (label || `${t('untitled')} - ID: ${value}`)}
{values.length > i + 1 && ', '} {values.length > i + 1 && ', '}
</React.Fragment> </React.Fragment>
); );
@@ -67,7 +79,7 @@ const RelationshipCell = (props) => {
Array.isArray(cellData) && cellData.length > totalToShow Array.isArray(cellData) && cellData.length > totalToShow
&& t('fields:itemsAndMore', { items: '', count: cellData.length - totalToShow }) && t('fields:itemsAndMore', { items: '', count: cellData.length - totalToShow })
} }
{values.length === 0 && t('noLabel', { label: getTranslation(field.label, i18n) })} {values.length === 0 && t('noLabel', { label: getTranslation(field?.label || '', i18n) })}
</div> </div>
); );
}; };

View File

@@ -8,6 +8,7 @@ import relationship from './Relationship';
import richText from './Richtext'; import richText from './Richtext';
import select from './Select'; import select from './Select';
import textarea from './Textarea'; import textarea from './Textarea';
import File from './File';
export default { export default {
@@ -23,4 +24,5 @@ export default {
radio: select, radio: select,
textarea, textarea,
upload: relationship, upload: relationship,
File,
}; };

View File

@@ -6,14 +6,17 @@ import RenderCustomComponent from '../../../../utilities/RenderCustomComponent';
import cellComponents from './field-types'; import cellComponents from './field-types';
import { Props } from './types'; import { Props } from './types';
import { getTranslation } from '../../../../../../utilities/getTranslation'; import { getTranslation } from '../../../../../../utilities/getTranslation';
import { fieldAffectsData } from '../../../../../../fields/config/types';
const DefaultCell: React.FC<Props> = (props) => { const DefaultCell: React.FC<Props> = (props) => {
const { const {
field, field,
collection,
collection: { collection: {
slug, slug,
}, },
cellData, cellData,
rowData,
rowData: { rowData: {
id, id,
} = {}, } = {},
@@ -49,9 +52,12 @@ const DefaultCell: React.FC<Props> = (props) => {
}; };
} }
const CellComponent = cellData && cellComponents[field.type]; let CellComponent = cellData && cellComponents[field.type];
if (!CellComponent) { if (!CellComponent) {
if (collection.upload && fieldAffectsData(field) && field.name === 'filename') {
CellComponent = cellComponents.File;
} else {
return ( return (
<WrapElement {...wrapElementProps}> <WrapElement {...wrapElementProps}>
{((cellData === '' || typeof cellData === 'undefined') && 'label' in field) && t('noLabel', { label: getTranslation(typeof field.label === 'function' ? 'data' : field.label || 'data', i18n) })} {((cellData === '' || typeof cellData === 'undefined') && 'label' in field) && t('noLabel', { label: getTranslation(typeof field.label === 'function' ? 'data' : field.label || 'data', i18n) })}
@@ -61,12 +67,15 @@ const DefaultCell: React.FC<Props> = (props) => {
</WrapElement> </WrapElement>
); );
} }
}
return ( return (
<WrapElement {...wrapElementProps}> <WrapElement {...wrapElementProps}>
<CellComponent <CellComponent
field={field} field={field}
data={cellData} data={cellData}
collection={collection}
rowData={rowData}
/> />
</WrapElement> </WrapElement>
); );

View File

@@ -1,11 +1,10 @@
import React, { Fragment } from 'react'; import React, { Fragment } from 'react';
import { useHistory } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useConfig } from '../../../utilities/Config'; import { useWindowInfo } from '@faceless-ui/window-info';
import UploadGallery from '../../../elements/UploadGallery';
import Eyebrow from '../../../elements/Eyebrow'; import Eyebrow from '../../../elements/Eyebrow';
import Paginator from '../../../elements/Paginator'; import Paginator from '../../../elements/Paginator';
import ListControls from '../../../elements/ListControls'; import ListControls from '../../../elements/ListControls';
import ListSelection from '../../../elements/ListSelection';
import Pill from '../../../elements/Pill'; import Pill from '../../../elements/Pill';
import Button from '../../../elements/Button'; import Button from '../../../elements/Button';
import { Table } from '../../../elements/Table'; import { Table } from '../../../elements/Table';
@@ -17,6 +16,11 @@ import { Gutter } from '../../../elements/Gutter';
import { RelationshipProvider } from './RelationshipProvider'; import { RelationshipProvider } from './RelationshipProvider';
import { getTranslation } from '../../../../../utilities/getTranslation'; import { getTranslation } from '../../../../../utilities/getTranslation';
import { StaggeredShimmers } from '../../../elements/ShimmerEffect'; import { StaggeredShimmers } from '../../../elements/ShimmerEffect';
import { SelectionProvider } from './SelectionProvider';
import EditMany from '../../../elements/EditMany';
import DeleteMany from '../../../elements/DeleteMany';
import PublishMany from '../../../elements/PublishMany';
import UnpublishMany from '../../../elements/UnpublishMany';
import './index.scss'; import './index.scss';
@@ -26,8 +30,6 @@ const DefaultList: React.FC<Props> = (props) => {
const { const {
collection, collection,
collection: { collection: {
upload,
slug,
labels: { labels: {
singular: singularLabel, singular: singularLabel,
plural: pluralLabel, plural: pluralLabel,
@@ -42,17 +44,15 @@ const DefaultList: React.FC<Props> = (props) => {
hasCreatePermission, hasCreatePermission,
disableEyebrow, disableEyebrow,
modifySearchParams, modifySearchParams,
disableCardLink,
onCardClick,
handleSortChange, handleSortChange,
handleWhereChange, handleWhereChange,
handlePageChange, handlePageChange,
handlePerPageChange, handlePerPageChange,
customHeader, customHeader,
resetParams,
} = props; } = props;
const { routes: { admin } } = useConfig(); const { breakpoints: { s: smallBreak } } = useWindowInfo();
const history = useHistory();
const { t, i18n } = useTranslation('general'); const { t, i18n } = useTranslation('general');
return ( return (
@@ -60,6 +60,10 @@ const DefaultList: React.FC<Props> = (props) => {
<Meta <Meta
title={getTranslation(collection.labels.plural, i18n)} title={getTranslation(collection.labels.plural, i18n)}
/> />
<SelectionProvider
docs={data.docs}
totalDocs={data.totalDocs}
>
{!disableEyebrow && ( {!disableEyebrow && (
<Eyebrow /> <Eyebrow />
)} )}
@@ -76,6 +80,11 @@ const DefaultList: React.FC<Props> = (props) => {
{t('createNew')} {t('createNew')}
</Pill> </Pill>
)} )}
{!smallBreak && (
<ListSelection
label={getTranslation(collection.labels.plural, i18n)}
/>
)}
{description && ( {description && (
<div className={`${baseClass}__sub-header`}> <div className={`${baseClass}__sub-header`}>
<ViewDescription description={description} /> <ViewDescription description={description} />
@@ -86,41 +95,22 @@ const DefaultList: React.FC<Props> = (props) => {
</header> </header>
<ListControls <ListControls
collection={collection} collection={collection}
enableColumns={Boolean(!upload)}
enableSort={Boolean(upload)}
modifySearchQuery={modifySearchParams} modifySearchQuery={modifySearchParams}
handleSortChange={handleSortChange} handleSortChange={handleSortChange}
handleWhereChange={handleWhereChange} handleWhereChange={handleWhereChange}
resetParams={resetParams}
/> />
{!data.docs && ( {!data.docs && (
<StaggeredShimmers <StaggeredShimmers
className={[ className={[`${baseClass}__shimmer`, `${baseClass}__shimmer--rows`].join(' ')}
`${baseClass}__shimmer`,
upload ? `${baseClass}__shimmer--uploads` : `${baseClass}__shimmer--rows`,
].filter(Boolean).join(' ')}
count={6} count={6}
width={upload ? 'unset' : '100%'}
/> />
)} )}
{(data.docs && data.docs.length > 0) && ( {(data.docs && data.docs.length > 0) && (
<React.Fragment>
{!upload && (
<RelationshipProvider> <RelationshipProvider>
<Table data={data.docs} /> <Table data={data.docs} />
</RelationshipProvider> </RelationshipProvider>
)} )}
{upload && (
<UploadGallery
docs={data.docs}
collection={collection}
onCardClick={(doc) => {
if (typeof onCardClick === 'function') onCardClick(doc);
if (!disableCardLink) history.push(`${admin}/collections/${slug}/${doc.id}`);
}}
/>
)}
</React.Fragment>
)}
{data.docs && data.docs.length === 0 && ( {data.docs && data.docs.length === 0 && (
<div className={`${baseClass}__no-results`}> <div className={`${baseClass}__no-results`}>
<p> <p>
@@ -167,10 +157,38 @@ const DefaultList: React.FC<Props> = (props) => {
handleChange={handlePerPageChange} handleChange={handlePerPageChange}
resetPage={data.totalDocs <= data.pagingCounter} resetPage={data.totalDocs <= data.pagingCounter}
/> />
<div className={`${baseClass}__list-selection`}>
{smallBreak && (
<Fragment>
<ListSelection
label={getTranslation(collection.labels.plural, i18n)}
/>
<div className={`${baseClass}__list-selection-actions`}>
<EditMany
collection={collection}
resetParams={resetParams}
/>
<PublishMany
collection={collection}
resetParams={resetParams}
/>
<UnpublishMany
collection={collection}
resetParams={resetParams}
/>
<DeleteMany
collection={collection}
resetParams={resetParams}
/>
</div>
</Fragment>
)}
</div>
</Fragment> </Fragment>
)} )}
</div> </div>
</Gutter> </Gutter>
</SelectionProvider>
</div> </div>
); );
}; };

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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;
}
}
}
}
}

View File

@@ -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;

View File

@@ -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);

View File

@@ -1,10 +1,11 @@
import { TFunction } from 'react-i18next'; import { TFunction } from 'react-i18next';
import React from 'react';
import { SanitizedCollectionConfig } from '../../../../../collections/config/types'; import { SanitizedCollectionConfig } from '../../../../../collections/config/types';
import { Field, fieldAffectsData, fieldIsPresentationalOnly } from '../../../../../fields/config/types'; import { Field, fieldAffectsData, fieldIsPresentationalOnly } from '../../../../../fields/config/types';
const formatFields = (config: SanitizedCollectionConfig, t: TFunction): Field[] => { const formatFields = (config: SanitizedCollectionConfig, t: TFunction): Field[] => {
const hasID = config.fields.findIndex((field) => fieldAffectsData(field) && field.name === 'id') > -1; const hasID = config.fields.findIndex((field) => fieldAffectsData(field) && field.name === 'id') > -1;
let fields: Field[] = config.fields.reduce((formatted, field) => { const fields: Field[] = config.fields.reduce((formatted, field) => {
if (!fieldIsPresentationalOnly(field) && (field.hidden === true || field?.admin?.disabled === true)) { if (!fieldIsPresentationalOnly(field) && (field.hidden === true || field?.admin?.disabled === true)) {
return formatted; return formatted;
} }
@@ -13,30 +14,34 @@ const formatFields = (config: SanitizedCollectionConfig, t: TFunction): Field[]
...formatted, ...formatted,
field, field,
]; ];
}, hasID ? [] : [{ name: 'id', label: 'ID', type: 'text' }]); }, hasID ? [] : [{
name: 'id',
label: 'ID',
type: 'text',
admin: {
disableBulkEdit: true,
},
}]);
if (config.timestamps) { if (config.timestamps) {
fields = fields.concat([ fields.push(
{ {
name: 'createdAt', name: 'createdAt',
label: t('general:createdAt'), label: t('general:createdAt'),
type: 'date', type: 'date',
}, { admin: {
disableBulkEdit: true,
},
},
{
name: 'updatedAt', name: 'updatedAt',
label: t('general:updatedAt'), label: t('general:updatedAt'),
type: 'date', type: 'date',
admin: {
disableBulkEdit: true,
}, },
]);
}
if (config.upload) {
fields = fields.concat([
{
name: 'filename',
label: t('upload:fileName'),
type: 'text',
}, },
]); );
} }
return fields; return fields;

View File

@@ -37,6 +37,12 @@
table { table {
width: 100%; width: 100%;
overflow: auto; overflow: auto;
#heading-_select,
.cell-_select {
min-width: unset;
width: auto;
}
} }
} }
@@ -55,34 +61,43 @@
margin-left: auto; margin-left: auto;
} }
&__list-selection {
position: fixed;
bottom: 0;
z-index: 10;
padding: base(.75) 0;
width: 100%;
background-color: var(--theme-bg);
.btn {
margin: 0 0 0 base(.5);
}
.btn {
background-color: var(--theme-elevation-100);
cursor: pointer;
padding: 0 base(.25);
border-radius: $style-radius-s;
&:hover {
background-color: var(--theme-elevation-200);
}
}
}
&__list-selection-actions {
display: flex;
gap: base(.25);
}
&__shimmer { &__shimmer {
margin-top: base(1.75); margin-top: base(1.75);
} width: 100%;
&__shimmer--rows {
>div { >div {
margin-top: 8px; margin-top: 8px;
} }
} }
&__shimmer--uploads {
// match upload cards
margin: base(2) -#{base(.5)};
width: calc(100% + #{$baseline});
display: flex;
flex-wrap: wrap;
>div {
min-width: 0;
width: calc(16.66%);
>div {
margin: base(.5);
padding-bottom: 110%;
}
}
}
@include mid-break { @include mid-break {
&__wrap { &__wrap {
padding-top: 0; padding-top: 0;
@@ -111,19 +126,9 @@
width: 100%; width: 100%;
margin-bottom: $baseline; margin-bottom: $baseline;
} }
&__shimmer--uploads {
>div {
width: 33.33%;
}
}
} }
@include small-break { @include small-break {
&__shimmer--uploads { margin-bottom: base(3);
>div {
width: 50%;
}
}
} }
} }

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useState } from 'react'; import { v4 as uuid } from 'uuid';
import React, { useEffect, useState, useCallback } from 'react';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import queryString from 'qs'; import queryString from 'qs';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -9,10 +10,11 @@ import DefaultList from './Default';
import RenderCustomComponent from '../../../utilities/RenderCustomComponent'; import RenderCustomComponent from '../../../utilities/RenderCustomComponent';
import { useStepNav } from '../../../elements/StepNav'; import { useStepNav } from '../../../elements/StepNav';
import formatFields from './formatFields'; import formatFields from './formatFields';
import { ListIndexProps, ListPreferences } from './types'; import { Props, ListIndexProps, ListPreferences } from './types';
import { usePreferences } from '../../../utilities/Preferences'; import { usePreferences } from '../../../utilities/Preferences';
import { useSearchParams } from '../../../utilities/SearchParams'; import { useSearchParams } from '../../../utilities/SearchParams';
import { Field } from '../../../../../fields/config/types'; import { TableColumnsProvider } from '../../../elements/TableColumns';
import type { Field } from '../../../../../fields/config/types';
const ListView: React.FC<ListIndexProps> = (props) => { const ListView: React.FC<ListIndexProps> = (props) => {
const { const {
@@ -48,7 +50,7 @@ const ListView: React.FC<ListIndexProps> = (props) => {
const collectionPermissions = permissions?.collections?.[slug]; const collectionPermissions = permissions?.collections?.[slug];
const hasCreatePermission = collectionPermissions?.create?.permission; const hasCreatePermission = collectionPermissions?.create?.permission;
const newDocumentURL = `${admin}/collections/${slug}/create`; const newDocumentURL = `${admin}/collections/${slug}/create`;
const [{ data }, { setParams: setFetchParams }] = usePayloadAPI(fetchURL, { initialParams: { page: 1 } }); const [{ data }, { setParams }] = usePayloadAPI(fetchURL, { initialParams: { page: 1 } });
useEffect(() => { useEffect(() => {
setStepNav([ setStepNav([
@@ -62,27 +64,31 @@ const ListView: React.FC<ListIndexProps> = (props) => {
// Set up Payload REST API query params // Set up Payload REST API query params
// ///////////////////////////////////// // /////////////////////////////////////
useEffect(() => { const resetParams = useCallback<Props['resetParams']>((overrides = {}) => {
const params = { const params: Record<string, unknown> = {
depth: 0, depth: 0,
draft: 'true', draft: 'true',
page: undefined, page: overrides?.page,
sort: undefined, sort: overrides?.sort,
where: undefined, where: overrides?.where,
limit, limit,
}; };
if (page) params.page = page; if (page) params.page = page;
if (sort) params.sort = sort; if (sort) params.sort = sort;
if (where) params.where = where; if (where) params.where = where;
params.invoke = uuid();
setParams(params);
}, [limit, page, setParams, sort, where]);
useEffect(() => {
// Performance enhancement // Performance enhancement
// Setting the Fetch URL this way // Setting the Fetch URL this way
// prevents a double-fetch // prevents a double-fetch
setFetchURL(`${serverURL}${api}/${slug}`); setFetchURL(`${serverURL}${api}/${slug}`);
resetParams();
setFetchParams(params); }, [api, resetParams, serverURL, slug]);
}, [setFetchParams, page, sort, where, collection, limit, serverURL, api, slug]);
// ///////////////////////////////////// // /////////////////////////////////////
// Fetch preferences on first load // Fetch preferences on first load
@@ -128,7 +134,28 @@ const ListView: React.FC<ListIndexProps> = (props) => {
})(); })();
}, [sort, limit, preferenceKey, setPreference, getPreference]); }, [sort, limit, preferenceKey, setPreference, getPreference]);
// /////////////////////////////////////
// Prevent going beyond page limit
// /////////////////////////////////////
useEffect(() => {
if (data?.totalDocs && data.pagingCounter > data.totalDocs) {
const params = queryString.parse(history.location.search, {
ignoreQueryPrefix: true,
depth: 0,
});
const newSearchQuery = queryString.stringify({
...params,
page: data.totalPages,
}, { addQueryPrefix: true });
history.replace({
search: newSearchQuery,
});
}
}, [data, history, resetParams]);
return ( return (
<TableColumnsProvider collection={collection}>
<RenderCustomComponent <RenderCustomComponent
DefaultComponent={DefaultList} DefaultComponent={DefaultList}
CustomComponent={CustomList} CustomComponent={CustomList}
@@ -138,8 +165,10 @@ const ListView: React.FC<ListIndexProps> = (props) => {
hasCreatePermission, hasCreatePermission,
data, data,
limit: limit || defaultLimit, limit: limit || defaultLimit,
resetParams,
}} }}
/> />
</TableColumnsProvider>
); );
}; };

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