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
contact_links:
- name: Security Vulnerability
url: https://github.com/payloadcms/payload/blob/master/SECURITY.md
about: See instructions to privately disclose any security concerns
- name: Feature Request
url: https://github.com/payloadcms/payload/discussions
about: Suggest an idea to improve Payload in our GitHub Discussions

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

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.
When switching between test directories, you will want to remove your `node_modules/.cache ` manually or by running `yarn clean:cache`.
NOTE: It is recommended to add the test credentials (located in `test/credentials.ts`) to your autofill for `localhost:3000/admin` as this will be required on every nodemon restart. The default credentials are `dev@payloadcms.com` as E-Mail and `test` as password.
## Pull Requests

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 |
| **`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 |
| **`preferencesKey`** | The `preferences` key to use when interacting with document-level user preferences |
| **`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.
<Banner type="success">
You can make full use of Payload's built-in authentication within your own custom Express endpoints by adding Payload's authentication middleware.
You can make full use of Payload's built-in authentication within your own
custom Express endpoints by adding Payload's authentication middleware.
</Banner>
<Banner type="warning">
Payload must be initialized before the `payload.authenticate` middleware can
be used. This is done by calling `payload.init()` prior to adding the
middleware.
</Banner>
Example in `server.js`:
```ts
import express from 'express';
import payload from 'payload';
import express from "express";
import payload from "payload";
const app = express();
payload.init({
secret: 'PAYLOAD_SECRET_KEY',
mongoURL: 'mongodb://localhost/payload',
secret: "PAYLOAD_SECRET_KEY",
mongoURL: "mongodb://localhost/payload",
express: app,
});
const router = express.Router();
// Note: Payload must be initialized before the `payload.authenticate` middleware can be used
router.use(payload.authenticate); // highlight-line
router.get('/', (req, res) => {
router.get("/", (req, res) => {
if (req.user) {
return res.send(`Authenticated successfully as ${req.user.email}.`);
}
return res.send('Not authenticated');
return res.send("Not authenticated");
});
app.use('/some-route-here', router);
app.use("/some-route-here", router);
app.listen(3000, async () => {
payload.logger.info(`listening on ${3000}...`);
});
```

View File

@@ -12,21 +12,23 @@ It's often best practice to write your Collections in separate files and then im
## Options
| Option | Description |
|------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **`slug`** * | Unique, URL-friendly string that will act as an identifier for this Collection. |
| **`fields`** * | Array of field types that will determine the structure and functionality of the data stored within this Collection. [Click here](/docs/fields/overview) for a full list of field types as well as how to configure them. |
| **`labels`** | Singular and plural labels for use in identifying this Collection throughout Payload. Auto-generated from slug if not defined. |
| **`admin`** | Admin-specific configuration. See below for [more detail](#admin-options). |
| **`hooks`** | Entry points to "tie in" to Collection actions at specific points. [More](/docs/hooks/overview#collection-hooks) |
| **`access`** | Provide access control functions to define exactly who should be able to do what with Documents in this Collection. [More](/docs/access-control/overview/#collections) |
| **`auth`** | Specify options if you would like this Collection to feature authentication. For more, consult the [Authentication](/docs/authentication/config) documentation. |
| **`upload`** | Specify options if you would like this Collection to support file uploads. For more, consult the [Uploads](/docs/upload/overview) documentation. |
| **`timestamps`** | Set to false to disable documents' automatically generated `createdAt` and `updatedAt` timestamps. |
| **`versions`** | Set to true to enable default options, or configure with object properties. [More](/docs/versions/overview#collection-config) |
| **`endpoints`** | Add custom routes to the REST API. [More](/docs/rest-api/overview#custom-endpoints) |
| **`graphQL`** | An object with `singularName` and `pluralName` strings used in schema generation. Auto-generated from slug if not defined. |
| **`typescript`** | An object with property `interface` as the text used in schema generation. Auto-generated from slug if not defined. |
| Option | Description |
|--------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **`slug`** * | Unique, URL-friendly string that will act as an identifier for this Collection. |
| **`fields`** * | Array of field types that will determine the structure and functionality of the data stored within this Collection. [Click here](/docs/fields/overview) for a full list of field types as well as how to configure them. |
| **`labels`** | Singular and plural labels for use in identifying this Collection throughout Payload. Auto-generated from slug if not defined. |
| **`admin`** | Admin-specific configuration. See below for [more detail](#admin-options). |
| **`hooks`** | Entry points to "tie in" to Collection actions at specific points. [More](/docs/hooks/overview#collection-hooks) |
| **`access`** | Provide access control functions to define exactly who should be able to do what with Documents in this Collection. [More](/docs/access-control/overview/#collections) |
| **`auth`** | Specify options if you would like this Collection to feature authentication. For more, consult the [Authentication](/docs/authentication/config) documentation. |
| **`upload`** | Specify options if you would like this Collection to support file uploads. For more, consult the [Uploads](/docs/upload/overview) documentation. |
| **`timestamps`** | Set to false to disable documents' automatically generated `createdAt` and `updatedAt` timestamps. |
| **`versions`** | Set to true to enable default options, or configure with object properties. [More](/docs/versions/overview#collection-config) |
| **`endpoints`** | Add custom routes to the REST API. [More](/docs/rest-api/overview#custom-endpoints) |
| **`graphQL`** | An object with `singularName` and `pluralName` strings used in schema generation. Auto-generated from slug if not defined. |
| **`typescript`** | An object with property `interface` as the text used in schema generation. Auto-generated from slug if not defined. |
| **`defaultSort`** | Pass a top-level field to sort by default in the collection List view. Prefix the name of the field with a minus symbol ("-") to sort in descending order. |
| **`pagination`** | Set pagination-specific options for this collection. [More](#pagination) |
*\* 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
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.
</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
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
```
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:**
```

View File

@@ -18,7 +18,7 @@ This field uses [`react-datepicker`](https://www.npmjs.com/package/react-datepic
| Option | Description |
| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`name`** \* | To be used as the property name when stored and retrieved from the database. [More](/docs/fields/overview#field-names) |
| **`label`** | Text used as a field label in the Admin panel or an object with keys for each language. |
| **`label`** | Text used as a field label in the Admin panel or an object with keys for each language. |
| **`index`** | Build a [MongoDB index](https://docs.mongodb.com/manual/indexes/) for this field to produce faster queries. Set this field to `true` if your users will perform queries on this field's data often. |
| **`validate`** | Provide a custom validation function that will be executed on both the Admin panel and the backend. [More](/docs/fields/overview#validation) |
| **`saveToJWT`** | If this field is top-level and nested in a config supporting [Authentication](/docs/authentication/config), include its data in the user JWT. |
@@ -28,7 +28,7 @@ This field uses [`react-datepicker`](https://www.npmjs.com/package/react-datepic
| **`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. |
| **`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). |
_\* An asterisk denotes that a property is required._
@@ -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.
| Option | 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. |
| **`monthsToShow`** | Number of months to display max is 2. Defaults to 1. |
| **`minDate`** | Passed directly to [react-datepicker](https://github.com/Hacker0x01/react-datepicker/blob/master/docs/datepicker.md). |
| **`maxDate`** | Passed directly to [react-datepicker](https://github.com/Hacker0x01/react-datepicker/blob/master/docs/datepicker.md). |
| **`minTime`** | Passed directly to [react-datepicker](https://github.com/Hacker0x01/react-datepicker/blob/master/docs/datepicker.md). |
| **`maxTime`** | Passed directly to [react-datepicker](https://github.com/Hacker0x01/react-datepicker/blob/master/docs/datepicker.md). |
| **`timeIntervals`** | Passed directly to [react-datepicker](https://github.com/Hacker0x01/react-datepicker/blob/master/docs/datepicker.md). Defaults to 30 minutes. |
| **`timeFormat`** | Passed directly to [react-datepicker](https://github.com/Hacker0x01/react-datepicker/blob/master/docs/datepicker.md). Defaults to `'h:mm aa'`. |
| Property | Description |
| ------------------------------ | ------------------------------------------------------------------------------------------- |
| **`placeholder`** | Placeholder text for the field. |
| **`date`** | Pass options to customize date field appearance. |
| **`date.displayFormat`** | Format date to be shown in field **cell**. |
| **`date.pickerAppearance`** \* | Determines the appearance of the datepicker: `dayAndTime` `timeOnly` `dayOnly` `monthOnly`. |
| **`date.monthsToShow`** \* | Number of months to display max is 2. Defaults to 1. |
| **`date.minDate`** \* | Min date value to allow. |
| **`date.maxDate`** \* | Max date value to allow. |
| **`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
`collections/ExampleCollection.ts`
```ts
import { CollectionConfig } from 'payload/types';
import { CollectionConfig } from "payload/types";
const ExampleCollection: CollectionConfig = {
slug: 'example-collection',
slug: "example-collection",
fields: [
{
name: 'time', // required
type: 'date', // required
label: 'Event Start Time',
defaultValue: '1988-11-05T8:00:00.000+05:00',
name: "dateOnly",
type: "date",
admin: {
date: {
// All config options above should be placed here
pickerAppearance: 'timeOnly',
}
}
}
]
pickerAppearance: "dayOnly",
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

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

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) |
| **`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. |
| **`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. |
| **`required`** | Require this field to have a value. |
| **`admin`** | Admin-specific configuration. See below for [more detail](#admin-config). |

View File

@@ -79,7 +79,13 @@ const beforeOperationHook: CollectionBeforeOperationHook = async ({
### 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
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. |
| **`req`** | The Express `request` object. It is mocked for Local API operations. |
| **`value`** | The value of the field. |
| **`previousValue`** | The previous value of the field, before changes were applied, only in `afterChange` hooks. |
#### Return value

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -20,15 +20,11 @@
flex-grow: 1;
align-items: flex-start;
.pill {
button .pill {
pointer-events: none;
margin: 0;
margin-top: base(0.25);
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';
const formatListDrawerSlug = ({
export const formatListDrawerSlug = ({
depth,
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}
filterOption={filterOption}
onMenuOpen={onMenuOpen}
menuPlacement="auto"
selectProps={{
...selectProps,
}}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -2,6 +2,8 @@ import React from 'react';
import { useTranslation } from 'react-i18next';
import { Props } from './types';
import Thumbnail from '../Thumbnail';
import { useConfig } from '../../utilities/Config';
import { formatUseAsTitle } from '../../../hooks/useTitle';
import './index.scss';
@@ -14,12 +16,13 @@ export const ThumbnailCard: React.FC<Props> = (props) => {
doc,
collection,
thumbnail,
label,
label: labelFromProps,
alignLabel,
onKeyDown,
} = props;
const { t } = useTranslation('general');
const { t, i18n } = useTranslation('general');
const config = useConfig();
const classes = [
baseClass,
@@ -28,10 +31,20 @@ export const ThumbnailCard: React.FC<Props> = (props) => {
alignLabel && `${baseClass}--align-label-${alignLabel}`,
].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 (
<div
title={title}
className={classes}
onClick={typeof onClick === 'function' ? onClick : undefined}
onKeyDown={typeof onKeyDown === 'function' ? onKeyDown : undefined}
@@ -47,7 +60,7 @@ export const ThumbnailCard: React.FC<Props> = (props) => {
)}
</div>
<div className={`${baseClass}__label`}>
{label || title}
{title}
</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';
type NullifyLocaleFieldProps = {
localized: boolean
path: string
fieldValue?: null | [] | number
}
export const NullifyLocaleField: React.FC<NullifyLocaleFieldProps> = ({ path, fieldValue }) => {
export const NullifyLocaleField: React.FC<NullifyLocaleFieldProps> = ({ localized, path, fieldValue }) => {
const { dispatchFields, setModified } = useForm();
const currentLocale = useLocale();
const { localization } = useConfig();
@@ -30,8 +31,8 @@ export const NullifyLocaleField: React.FC<NullifyLocaleFieldProps> = ({ path, fi
setChecked(useFallback);
};
if (currentLocale === defaultLocale || (localization && !localization.fallback)) {
// hide when editing default locale or when fallback is disabled
if (!localized || currentLocale === defaultLocale || (localization && !localization.fallback)) {
// hide when field is not localized or editing default locale or when fallback is disabled
return null;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,7 @@
.field-type.textarea {
position: relative;
margin-bottom: $baseline;
padding-bottom: base(2.5);
.textarea-outer {
@include formInput();
@@ -21,8 +22,8 @@
}
&.error {
textarea {
background-color: var(--theme-error-200);
.textarea-outer {
background: var(--theme-error-200);
}
}
@@ -82,4 +83,8 @@
content: attr(data-after);
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 { useConfig } from '../Config';
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 { TypeWithTimestamps } from '../../../../collections/config/types';
import { Where } from '../../../../types';
@@ -15,6 +15,8 @@ import { useAuth } from '../Auth';
const Context = createContext({} as ContextType);
export const useDocumentInfo = (): ContextType => useContext(Context);
export const DocumentInfoProvider: React.FC<Props> = ({
children,
global,
@@ -32,7 +34,7 @@ export const DocumentInfoProvider: React.FC<Props> = ({
const baseURL = `${serverURL}${api}`;
let slug: string;
let type: EntityType;
let type: 'global' | 'collection';
let pluralType: 'globals' | 'collections';
let preferencesKey: string;
@@ -233,5 +235,3 @@ export const DocumentInfoProvider: React.FC<Props> = ({
</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 EntityType = 'global' | 'collection'
export type ContextType = {
collection?: SanitizedCollectionConfig
global?: SanitizedGlobalConfig
type: EntityType
/** Slug of the collection or global */
slug?: string
id?: string | number
preferencesKey?: string

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,10 @@
import React, { Fragment } from 'react';
import { useHistory } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useConfig } from '../../../utilities/Config';
import UploadGallery from '../../../elements/UploadGallery';
import { useWindowInfo } from '@faceless-ui/window-info';
import Eyebrow from '../../../elements/Eyebrow';
import Paginator from '../../../elements/Paginator';
import ListControls from '../../../elements/ListControls';
import ListSelection from '../../../elements/ListSelection';
import Pill from '../../../elements/Pill';
import Button from '../../../elements/Button';
import { Table } from '../../../elements/Table';
@@ -17,6 +16,11 @@ import { Gutter } from '../../../elements/Gutter';
import { RelationshipProvider } from './RelationshipProvider';
import { getTranslation } from '../../../../../utilities/getTranslation';
import { StaggeredShimmers } from '../../../elements/ShimmerEffect';
import { SelectionProvider } from './SelectionProvider';
import EditMany from '../../../elements/EditMany';
import DeleteMany from '../../../elements/DeleteMany';
import PublishMany from '../../../elements/PublishMany';
import UnpublishMany from '../../../elements/UnpublishMany';
import './index.scss';
@@ -26,8 +30,6 @@ const DefaultList: React.FC<Props> = (props) => {
const {
collection,
collection: {
upload,
slug,
labels: {
singular: singularLabel,
plural: pluralLabel,
@@ -42,17 +44,15 @@ const DefaultList: React.FC<Props> = (props) => {
hasCreatePermission,
disableEyebrow,
modifySearchParams,
disableCardLink,
onCardClick,
handleSortChange,
handleWhereChange,
handlePageChange,
handlePerPageChange,
customHeader,
resetParams,
} = props;
const { routes: { admin } } = useConfig();
const history = useHistory();
const { breakpoints: { s: smallBreak } } = useWindowInfo();
const { t, i18n } = useTranslation('general');
return (
@@ -60,117 +60,135 @@ const DefaultList: React.FC<Props> = (props) => {
<Meta
title={getTranslation(collection.labels.plural, i18n)}
/>
{!disableEyebrow && (
<Eyebrow />
)}
<Gutter className={`${baseClass}__wrap`}>
<header className={`${baseClass}__header`}>
{customHeader && customHeader}
{!customHeader && (
<Fragment>
<h1>
{getTranslation(pluralLabel, i18n)}
</h1>
{hasCreatePermission && (
<Pill to={newDocumentURL}>
{t('createNew')}
</Pill>
)}
{description && (
<div className={`${baseClass}__sub-header`}>
<ViewDescription description={description} />
</div>
)}
</Fragment>
)}
</header>
<ListControls
collection={collection}
enableColumns={Boolean(!upload)}
enableSort={Boolean(upload)}
modifySearchQuery={modifySearchParams}
handleSortChange={handleSortChange}
handleWhereChange={handleWhereChange}
/>
{!data.docs && (
<StaggeredShimmers
className={[
`${baseClass}__shimmer`,
upload ? `${baseClass}__shimmer--uploads` : `${baseClass}__shimmer--rows`,
].filter(Boolean).join(' ')}
count={6}
width={upload ? 'unset' : '100%'}
<SelectionProvider
docs={data.docs}
totalDocs={data.totalDocs}
>
{!disableEyebrow && (
<Eyebrow />
)}
<Gutter className={`${baseClass}__wrap`}>
<header className={`${baseClass}__header`}>
{customHeader && customHeader}
{!customHeader && (
<Fragment>
<h1>
{getTranslation(pluralLabel, i18n)}
</h1>
{hasCreatePermission && (
<Pill to={newDocumentURL}>
{t('createNew')}
</Pill>
)}
{!smallBreak && (
<ListSelection
label={getTranslation(collection.labels.plural, i18n)}
/>
)}
{description && (
<div className={`${baseClass}__sub-header`}>
<ViewDescription description={description} />
</div>
)}
</Fragment>
)}
</header>
<ListControls
collection={collection}
modifySearchQuery={modifySearchParams}
handleSortChange={handleSortChange}
handleWhereChange={handleWhereChange}
resetParams={resetParams}
/>
)}
{(data.docs && data.docs.length > 0) && (
<React.Fragment>
{!upload && (
<RelationshipProvider>
<Table data={data.docs} />
</RelationshipProvider>
)}
{upload && (
<UploadGallery
docs={data.docs}
collection={collection}
onCardClick={(doc) => {
if (typeof onCardClick === 'function') onCardClick(doc);
if (!disableCardLink) history.push(`${admin}/collections/${slug}/${doc.id}`);
}}
/>
)}
</React.Fragment>
)}
{data.docs && data.docs.length === 0 && (
<div className={`${baseClass}__no-results`}>
<p>
{t('noResults', { label: getTranslation(pluralLabel, i18n) })}
</p>
{hasCreatePermission && newDocumentURL && (
<Button
el="link"
to={newDocumentURL}
>
{t('createNewLabel', { label: getTranslation(singularLabel, i18n) })}
</Button>
{!data.docs && (
<StaggeredShimmers
className={[`${baseClass}__shimmer`, `${baseClass}__shimmer--rows`].join(' ')}
count={6}
/>
)}
{(data.docs && data.docs.length > 0) && (
<RelationshipProvider>
<Table data={data.docs} />
</RelationshipProvider>
)}
{data.docs && data.docs.length === 0 && (
<div className={`${baseClass}__no-results`}>
<p>
{t('noResults', { label: getTranslation(pluralLabel, i18n) })}
</p>
{hasCreatePermission && newDocumentURL && (
<Button
el="link"
to={newDocumentURL}
>
{t('createNewLabel', { label: getTranslation(singularLabel, i18n) })}
</Button>
)}
</div>
)}
<div className={`${baseClass}__page-controls`}>
<Paginator
limit={data.limit}
totalPages={data.totalPages}
page={data.page}
hasPrevPage={data.hasPrevPage}
hasNextPage={data.hasNextPage}
prevPage={data.prevPage}
nextPage={data.nextPage}
numberOfNeighbors={1}
disableHistoryChange={modifySearchParams === false}
onChange={handlePageChange}
/>
{data?.totalDocs > 0 && (
<Fragment>
<div className={`${baseClass}__page-info`}>
{(data.page * data.limit) - (data.limit - 1)}
-
{data.totalPages > 1 && data.totalPages !== data.page ? (data.limit * data.page) : data.totalDocs}
{' '}
{t('of')}
{' '}
{data.totalDocs}
</div>
<PerPage
limits={collection?.admin?.pagination?.limits}
limit={limit}
modifySearchParams={modifySearchParams}
handleChange={handlePerPageChange}
resetPage={data.totalDocs <= data.pagingCounter}
/>
<div className={`${baseClass}__list-selection`}>
{smallBreak && (
<Fragment>
<ListSelection
label={getTranslation(collection.labels.plural, i18n)}
/>
<div className={`${baseClass}__list-selection-actions`}>
<EditMany
collection={collection}
resetParams={resetParams}
/>
<PublishMany
collection={collection}
resetParams={resetParams}
/>
<UnpublishMany
collection={collection}
resetParams={resetParams}
/>
<DeleteMany
collection={collection}
resetParams={resetParams}
/>
</div>
</Fragment>
)}
</div>
</Fragment>
)}
</div>
)}
<div className={`${baseClass}__page-controls`}>
<Paginator
limit={data.limit}
totalPages={data.totalPages}
page={data.page}
hasPrevPage={data.hasPrevPage}
hasNextPage={data.hasNextPage}
prevPage={data.prevPage}
nextPage={data.nextPage}
numberOfNeighbors={1}
disableHistoryChange={modifySearchParams === false}
onChange={handlePageChange}
/>
{data?.totalDocs > 0 && (
<Fragment>
<div className={`${baseClass}__page-info`}>
{(data.page * data.limit) - (data.limit - 1)}
-
{data.totalPages > 1 && data.totalPages !== data.page ? (data.limit * data.page) : data.totalDocs}
{' '}
{t('of')}
{' '}
{data.totalDocs}
</div>
<PerPage
limits={collection?.admin?.pagination?.limits}
limit={limit}
modifySearchParams={modifySearchParams}
handleChange={handlePerPageChange}
resetPage={data.totalDocs <= data.pagingCounter}
/>
</Fragment>
)}
</div>
</Gutter>
</Gutter>
</SelectionProvider>
</div>
);
};

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

View File

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

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

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