Compare commits

..

67 Commits

Author SHA1 Message Date
Dan Ribbens
3825041393 chore(release): v1.6.24 2023-03-23 13:15:50 -04:00
Dan Ribbens
0fedbabe9e feat: bulk-operations (#2346)
Co-authored-by: PatrikKozak <patrik@trbl.design>
2023-03-23 12:33:13 -04:00
Dan Ribbens
c5cb08c5b8 chore(release): v1.6.23 2023-03-22 14:29:45 -04:00
Jarrod Flesch
833899c893 chore: exposes AccessArgs export from payload/types 2023-03-22 13:22:11 -04:00
Jessica Chowdhury
1f480c4cd5 feat: exposes defaultSort property for collection list view (#2382) 2023-03-22 12:21:04 -04:00
Jarrod Flesch
b74a59947d chore: exports AccessArgs type for granular typing when imported 2023-03-22 11:58:23 -04:00
Dan Ribbens
21b8da7f41 fix: #2363 version tabs and select field comparisons (#2364) 2023-03-22 10:22:14 -04:00
fiona
fb2fd3e9b7 fix: DateField admin type (#2256) 2023-03-22 10:18:37 -04:00
Christian Gil
c0ff75c164 fix: Fix missing Spanish translations (#2372) 2023-03-22 10:17:47 -04:00
Dan Ribbens
e1a6e08aa1 fix: fallback to default locale showing on non-localized fields (#2316) 2023-03-22 10:16:54 -04:00
Jarrod Flesch
ac4cc5548a Update reproduction-guide.md 2023-03-21 23:40:18 -04:00
Jarrod Flesch
e0e1b09b77 chore: adds info in reproduction guide 2023-03-21 23:29:33 -04:00
Jarrod Flesch
fe86707c53 Chore/issue template (#2380) 2023-03-21 23:16:45 -04:00
Jarrod Flesch
2ed7e325b8 Issue template improvements (#2231) 2023-03-21 22:34:01 -04:00
PatrikKozak
e09ebfffa0 fix: allows base64 thumbnails (#2361) 2023-03-21 09:33:16 -04:00
Christian Gil
a8766d00a8 feat: adds title attribute to ThumbnailCard (#2368) 2023-03-20 23:15:03 -04:00
Jacob Fletcher
ef9606bf5b chore: retrofits formatUseAsTitle into ThumbnailCard #2270 (#2367) 2023-03-20 22:51:06 -04:00
Jacob Fletcher
10dd819863 fix: relationship field useAsTitle #2333 (#2350) 2023-03-20 22:21:49 -04:00
Jarrod Flesch
c8594a7e7a chore: ensures code editor and loading shimmer use height from props 2023-03-20 12:06:36 -04:00
Jacob Fletcher
959567aade docs: middleware order #2327 (#2351) 2023-03-20 09:47:57 -04:00
Jarrod Flesch
7a8c7f3429 chore: ensures monaco editor loader is the same height as it's parent 2023-03-20 08:26:44 -04:00
James
4d578f1bfd fix: #2315 - deleting files if overwriteExistingFiles is true 2023-03-15 17:37:02 -04:00
Dan Ribbens
eabfd91655 chore(release): v1.6.22 2023-03-15 15:39:21 -04:00
Dan Ribbens
a4c6c4891e chore: update webpack dependencies 2023-03-15 15:28:16 -04:00
Dan Ribbens
11c15720d4 chore: update dependencies (#2326) 2023-03-15 14:36:56 -04:00
Franck Martin
24e92cfe69 add: missing french translations for rich-text link editor (#2322) 2023-03-15 14:12:02 -04:00
James
4b243c9007 chore(release): v1.6.21 2023-03-15 10:34:47 -04:00
Jarrod Flesch
8d65ba1efd fix: hidden fields being mutated on patch (#2317) 2023-03-14 15:35:58 -04:00
Dan Ribbens
5f1b0c21eb chore(release): v1.6.20 2023-03-13 17:47:46 -04:00
Dan Ribbens
af164159fb fix: undefined point fields saving as empty object (#2313) 2023-03-13 17:34:51 -04:00
Elliot Lintz
39e303add6 fix: keep drop zone active when hovering inner elements (#2295) 2023-03-13 16:05:56 -04:00
Jarrod Flesch
9b5c889187 Merge pull request #2301 from payloadcms/fix/2270
fix: allow thumbnails in upload gallery to show useAsTitle value
2023-03-13 15:39:54 -04:00
Jarrod Flesch
dd9c15c672 chore: ensures block drawer thumbnail cards render a title properly 2023-03-13 15:26:10 -04:00
Jarrod Flesch
92e9602329 Merge pull request #2310 from payloadcms/fix/2292
fix: allows useListDrawer to work without collectionSlugs defined
2023-03-13 15:12:33 -04:00
James Mikrut
dbf976ee5e Merge pull request #2278 from wkillerud/fix/relationmap-undefined
fix: check relationships indexed access for undefined
2023-03-13 12:08:32 -07:00
James Mikrut
927b3fb6d3 Merge pull request #2291 from joas8211/feat/provide-refresh-permissions
feature: provide refresh permissions
2023-03-13 12:07:10 -07:00
James Mikrut
5e84ca3ce7 Merge pull request #2294 from Elliot67/fix/favicon-404
fix: Prevent browser initial favicon request
2023-03-13 12:05:26 -07:00
James Mikrut
3b2daa1992 Merge pull request #2296 from Firfi/master
chore: rename index.tsx of Pages collection of Preview example into index.ts
2023-03-13 12:04:54 -07:00
James Mikrut
a19c42f1bd fix: tooltip position #2108
fix: tooltip position #2108
2023-03-13 11:47:35 -07:00
PatrikKozak
fc82661b54 Merge pull request #2311 from payloadcms/fix/search-row-titles
Fix/search row titles
2023-03-13 14:46:09 -04:00
James Mikrut
4e95a39132 Merge pull request #2306 from payloadcms/fix/pagination-global-admin-type
Fix/pagination global admin type
2023-03-13 11:38:23 -07:00
PatrikKozak
5a637a8b09 Merge branch 'master' of https://github.com/payloadcms/payload into fix/search-row-titles 2023-03-13 14:30:05 -04:00
PatrikKozak
75e776ddb4 fix: flattens title fields to allow seaching by title if title inside Row field 2023-03-13 14:29:59 -04:00
Jessica Boezwinkle
e1553c2fc8 fix: allows useListDrawer to work without collectionSlugs defined 2023-03-13 18:23:58 +00:00
James Mikrut
db6d35bc03 Merge pull request #2308 from payloadcms/fix/#2265
fix: cancels existing fetches if new fetches are started
2023-03-13 11:19:45 -07:00
James
d5bf957c8e chore: only throws errors in usePayloadAPI if signal is not aborted 2023-03-13 13:54:58 -04:00
Jarrod Flesch
566c45b0b4 fix: ensures documentID exists in doc documentDrawers (#2304) 2023-03-13 12:06:07 -04:00
Jarrod Flesch
39ee306630 chore: adds duplicate caret to track intersection with 2023-03-13 12:04:26 -04:00
PatrikKozak
748475f785 Merge branch 'master' of https://github.com/payloadcms/payload into fix/pagination-global-admin-type 2023-03-13 11:41:58 -04:00
PatrikKozak
bf9929e9a9 fix: removes pagination type from top level admin config types 2023-03-13 11:41:50 -04:00
Jarrod Flesch
9aa1b8ec47 Merge remote-tracking branch 'origin/master' into fix/tooltip-position 2023-03-13 11:28:41 -04:00
James
ccc92fdb75 fix: cancels existing fetches if new fetches are started 2023-03-13 11:22:10 -04:00
James
657aa65e99 fix: removes forced require on array, block, group ts 2023-03-13 11:12:11 -04:00
James
abebde6b12 feat: exposes useTheme hook 2023-03-13 10:58:02 -04:00
Elliot Lintz
1df3d149e0 feat: #2280 Improve UX of paginator (#2293) 2023-03-13 10:12:41 -04:00
James Mikrut
8832d08a22 Merge pull request #2286 from payloadcms/fix/ui-columns
fix: renders presentational table columns
2023-03-13 06:52:07 -07:00
Jacob Fletcher
51dc66b5d9 poc: tooltip position #2108 2023-03-13 09:27:08 -04:00
Jessica Boezwinkle
aae6d716e5 fix: allow thumbnails in upload gallery to show useAsTitle value 2023-03-13 11:29:56 +00:00
Igor Loskutov
32b38439e3 chore: rename index.tsx of Pages collection of Preview example into index.ts 2023-03-12 16:08:50 +07:00
Elliot67
fd8ea88488 fix: Prevent browser initial favicon request 2023-03-12 00:09:07 +01:00
Jesse Sivonen
8d1df96637 docs: add refreshPermissions 2023-03-11 17:18:52 +02:00
Jesse Sivonen
c1f205c2cf test: refresh-permissions 2023-03-11 16:56:22 +02:00
Jesse Sivonen
e9c796e42c feat: provide refresh permissions for auth context 2023-03-11 16:56:01 +02:00
Jacob Fletcher
4e1748fb8a fix: renders presentational table columns 2023-03-10 08:59:34 -05:00
William Killerud
959f01739c fix: check relationships indexed access for undefined 2023-03-09 15:09:09 +01:00
James
85dee9a7bc chore(release): v1.6.19 2023-03-08 16:40:05 -08:00
James
057522c5bd fix: ensures nested fields save properly within link, upload rte 2023-03-08 16:36:46 -08:00
194 changed files with 6110 additions and 1699 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,72 @@
## [1.6.24](https://github.com/payloadcms/payload/compare/v1.6.23...v1.6.24) (2023-03-23)
### Features
* bulk-operations ([#2346](https://github.com/payloadcms/payload/issues/2346)) ([0fedbab](https://github.com/payloadcms/payload/commit/0fedbabe9e975f375dc12447fcdab4119bc6a4c4))
## [1.6.23](https://github.com/payloadcms/payload/compare/v1.6.22...v1.6.23) (2023-03-22)
### Bug Fixes
* [#2315](https://github.com/payloadcms/payload/issues/2315) - deleting files if overwriteExistingFiles is true ([4d578f1](https://github.com/payloadcms/payload/commit/4d578f1bfd05efab5cc8db95895eabb776b2d9d1))
* [#2363](https://github.com/payloadcms/payload/issues/2363) version tabs and select field comparisons ([#2364](https://github.com/payloadcms/payload/issues/2364)) ([21b8da7](https://github.com/payloadcms/payload/commit/21b8da7f415cdace9f7d5898c98f9c7a6bb39107))
* allows base64 thumbnails ([#2361](https://github.com/payloadcms/payload/issues/2361)) ([e09ebff](https://github.com/payloadcms/payload/commit/e09ebfffa0a7a7fdb3469f272de0e6930d97a336))
* DateField admin type ([#2256](https://github.com/payloadcms/payload/issues/2256)) ([fb2fd3e](https://github.com/payloadcms/payload/commit/fb2fd3e9b7e302d8069bfcb6f3cb698ac7abf0ca))
* fallback to default locale showing on non-localized fields ([#2316](https://github.com/payloadcms/payload/issues/2316)) ([e1a6e08](https://github.com/payloadcms/payload/commit/e1a6e08aa140cf21597d6009b811f7fdd2106f4f))
* Fix missing Spanish translations ([#2372](https://github.com/payloadcms/payload/issues/2372)) ([c0ff75c](https://github.com/payloadcms/payload/commit/c0ff75c1647a36219549e20fc081883f8cf1d7e4))
* relationship field useAsTitle [#2333](https://github.com/payloadcms/payload/issues/2333) ([#2350](https://github.com/payloadcms/payload/issues/2350)) ([10dd819](https://github.com/payloadcms/payload/commit/10dd819863ecac4a5cea2e13f820df2224ac57f4))
### Features
* adds title attribute to ThumbnailCard ([#2368](https://github.com/payloadcms/payload/issues/2368)) ([a8766d0](https://github.com/payloadcms/payload/commit/a8766d00a8365c8e6ffe507944fbe49aaa39d4bd))
* exposes defaultSort property for collection list view ([#2382](https://github.com/payloadcms/payload/issues/2382)) ([1f480c4](https://github.com/payloadcms/payload/commit/1f480c4cd5673a6fe08360183fe1c7c1d4e05de0))
## [1.6.22](https://github.com/payloadcms/payload/compare/v1.6.21...v1.6.22) (2023-03-15)
## [1.6.21](https://github.com/payloadcms/payload/compare/v1.6.20...v1.6.21) (2023-03-15)
### Bug Fixes
* hidden fields being mutated on patch ([#2317](https://github.com/payloadcms/payload/issues/2317)) ([8d65ba1](https://github.com/payloadcms/payload/commit/8d65ba1efd8744042bbaf669c10b6837a6b972f8))
## [1.6.20](https://github.com/payloadcms/payload/compare/v1.6.19...v1.6.20) (2023-03-13)
### Bug Fixes
* allow thumbnails in upload gallery to show useAsTitle value ([aae6d71](https://github.com/payloadcms/payload/commit/aae6d716e5608270ca142f2f4df214f9e271deb4))
* allows useListDrawer to work without collectionSlugs defined ([e1553c2](https://github.com/payloadcms/payload/commit/e1553c2fc88ac582744cd72d15c9e9ef3b8ec549))
* cancels existing fetches if new fetches are started ([ccc92fd](https://github.com/payloadcms/payload/commit/ccc92fdb7519e14ff1092f19ae4e7060fa413aab))
* check relationships indexed access for undefined ([959f017](https://github.com/payloadcms/payload/commit/959f01739c30450f3a6d052dd6083fdacf1527a4))
* ensures documentID exists in doc documentDrawers ([#2304](https://github.com/payloadcms/payload/issues/2304)) ([566c45b](https://github.com/payloadcms/payload/commit/566c45b0b436a9a3ea8eff27de2ea829dd6a2f0c))
* flattens title fields to allow seaching by title if title inside Row field ([75e776d](https://github.com/payloadcms/payload/commit/75e776ddb43b292eae6c1204589d9dc22deab50c))
* keep drop zone active when hovering inner elements ([#2295](https://github.com/payloadcms/payload/issues/2295)) ([39e303a](https://github.com/payloadcms/payload/commit/39e303add62d2dbd3e72d17e64e1ea5d940b0298))
* Prevent browser initial favicon request ([fd8ea88](https://github.com/payloadcms/payload/commit/fd8ea88488c80627346733e0595a2ef34c964a87))
* removes forced require on array, block, group ts ([657aa65](https://github.com/payloadcms/payload/commit/657aa65e993d13e9a294456b73adcd57f20d7c87))
* removes pagination type from top level admin config types ([bf9929e](https://github.com/payloadcms/payload/commit/bf9929e9a9919488f6de0e172909fa27719ecb04))
* renders presentational table columns ([4e1748f](https://github.com/payloadcms/payload/commit/4e1748fb8a3554586b377e60738130d03ec12f38))
* undefined point fields saving as empty object ([#2313](https://github.com/payloadcms/payload/issues/2313)) ([af16415](https://github.com/payloadcms/payload/commit/af164159fb52f4b0ef97e2fa34b881f97bc07310))
### Features
* [#2280](https://github.com/payloadcms/payload/issues/2280) Improve UX of paginator ([#2293](https://github.com/payloadcms/payload/issues/2293)) ([1df3d14](https://github.com/payloadcms/payload/commit/1df3d149e06cc955a61c4371371b601c0d9aad2b))
* exposes useTheme hook ([abebde6](https://github.com/payloadcms/payload/commit/abebde6b120a9dddc9971325b616b9cb31bcba90))
* provide refresh permissions for auth context ([e9c796e](https://github.com/payloadcms/payload/commit/e9c796e42c1bb1e0ce72d057ee88dee624b94c24))
## [1.6.19](https://github.com/payloadcms/payload/compare/v1.6.18...v1.6.19) (2023-03-09)
### Bug Fixes
* ensures nested fields save properly within link, upload rte ([057522c](https://github.com/payloadcms/payload/commit/057522c5bdade430c6e60f589a32f174739d400c))
## [1.6.18](https://github.com/payloadcms/payload/compare/v1.6.17...v1.6.18) (2023-03-09)

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

@@ -4,3 +4,4 @@ exports.useDocumentInfo = require('../dist/admin/components/utilities/DocumentIn
exports.useConfig = require('../dist/admin/components/utilities/Config').useConfig;
exports.useAuth = require('../dist/admin/components/utilities/Auth').useAuth;
exports.useEditDepth = require('../dist/admin/components/utilities/EditDepth').useEditDepth;
exports.useTheme = require('../dist/admin/components/utilities/Theme').useTheme;

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

@@ -226,14 +226,15 @@ const Greeting: React.FC = () => {
Useful to retrieve info about the currently logged in user as well as methods for interacting with it. It sends back an object with the following properties:
| Property | Description |
|---------------------|-----------------------------------------------------------------------------------------|
| **`user`** | The currently logged in user |
| **`logOut`** | A method to log out the currently logged in user |
| **`refreshCookie`** | A method to trigger the silent refreshing of a user's auth token |
| **`setToken`** | Set the token of the user, to be decoded and used to reset the user and token in memory |
| **`token`** | The logged in user's token (useful for creating preview links, etc.) |
| **`permissions`** | The permissions of the current user |
| Property | Description |
|--------------------------|-----------------------------------------------------------------------------------------|
| **`user`** | The currently logged in user |
| **`logOut`** | A method to log out the currently logged in user |
| **`refreshCookie`** | A method to trigger the silent refreshing of a user's auth token |
| **`setToken`** | Set the token of the user, to be decoded and used to reset the user and token in memory |
| **`token`** | The logged in user's token (useful for creating preview links, etc.) |
| **`refreshPermissions`** | Load new permissions (useful when content that effects permissions has been changed) |
| **`permissions`** | The permissions of the current user |
```tsx
import { useAuth } from 'payload/components/utilities';

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

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

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

View File

@@ -12,7 +12,7 @@ export const requests = {
}
return fetch(`${url}${query}`, {
credentials: 'include',
headers: options.headers,
...options,
});
},

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -189,12 +189,10 @@ const Routes = () => {
render={(routeProps) => {
if (permissions?.collections?.[collection.slug]?.read?.permission) {
return (
<TableColumnsProvider collection={collection}>
<List
{...routeProps}
collection={collection}
/>
</TableColumnsProvider>
<List
{...routeProps}
collection={collection}
/>
);
}

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

@@ -93,7 +93,10 @@ export const DocumentDrawerContent: React.FC<DocumentDrawerProps> = ({
if (isError) return null;
return (
<DocumentInfoProvider collection={collectionConfig}>
<DocumentInfoProvider
collection={collectionConfig}
id={id}
>
<RenderCustomComponent
DefaultComponent={DefaultEdit}
CustomComponent={collectionConfig.admin?.components?.views?.Edit}

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';
@@ -10,8 +11,15 @@ import Button from '../Button';
import { Props } from './types';
import { useSearchParams } from '../../utilities/SearchParams';
import validateWhereQuery from '../WhereBuilder/validateWhereQuery';
import flattenFields from '../../../../utilities/flattenTopLevelFields';
import { getTextFieldsToBeSearched } from './getTextFieldsToBeSearched';
import { getTranslation } from '../../../../utilities/getTranslation';
import Pill from '../Pill';
import Chevron from '../../icons/Chevron';
import EditMany from '../EditMany';
import DeleteMany from '../DeleteMany';
import PublishMany from '../PublishMany';
import UnpublishMany from '../UnpublishMany';
import './index.scss';
@@ -25,6 +33,7 @@ const ListControls: React.FC<Props> = (props) => {
handleSortChange,
handleWhereChange,
modifySearchQuery = true,
resetParams,
collection: {
fields,
admin: {
@@ -37,10 +46,14 @@ const ListControls: React.FC<Props> = (props) => {
const params = useSearchParams();
const shouldInitializeWhereOpened = validateWhereQuery(params?.where);
const [titleField] = useState(() => fields.find((field) => fieldAffectsData(field) && field.name === useAsTitle));
const [titleField] = useState(() => {
const topLevelFields = flattenFields(fields);
return topLevelFields.find((field) => fieldAffectsData(field) && field.name === useAsTitle);
});
const [textFieldsToBeSearched] = useState(getTextFieldsToBeSearched(listSearchableFields, fields));
const [visibleDrawer, setVisibleDrawer] = useState<'where' | 'sort' | 'columns'>(shouldInitializeWhereOpened ? 'where' : undefined);
const { t, i18n } = useTranslation('general');
const { breakpoints: { s: smallBreak } } = useWindowInfo();
return (
<div className={baseClass}>
@@ -54,26 +67,44 @@ const ListControls: React.FC<Props> = (props) => {
/>
<div className={`${baseClass}__buttons`}>
<div className={`${baseClass}__buttons-wrap`}>
{ !smallBreak && (
<React.Fragment>
<EditMany
collection={collection}
resetParams={resetParams}
/>
<PublishMany
collection={collection}
resetParams={resetParams}
/>
<UnpublishMany
collection={collection}
resetParams={resetParams}
/>
<DeleteMany
collection={collection}
resetParams={resetParams}
/>
</React.Fragment>
)}
{enableColumns && (
<Button
className={`${baseClass}__toggle-columns`}
buttonStyle={visibleDrawer === 'columns' ? undefined : 'secondary'}
<Pill
pillStyle="dark"
className={`${baseClass}__toggle-columns ${visibleDrawer === 'columns' ? `${baseClass}__buttons-active` : ''}`}
onClick={() => setVisibleDrawer(visibleDrawer !== 'columns' ? 'columns' : undefined)}
icon="chevron"
iconStyle="none"
icon={<Chevron />}
>
{t('columns')}
</Button>
</Pill>
)}
<Button
className={`${baseClass}__toggle-where`}
buttonStyle={visibleDrawer === 'where' ? undefined : 'secondary'}
<Pill
pillStyle="dark"
className={`${baseClass}__toggle-where ${visibleDrawer === 'where' ? `${baseClass}__buttons-active` : ''}`}
onClick={() => setVisibleDrawer(visibleDrawer !== 'where' ? 'where' : undefined)}
icon="chevron"
iconStyle="none"
icon={<Chevron />}
>
{t('filters')}
</Button>
</Pill>
{enableSort && (
<Button
className={`${baseClass}__toggle-sort`}

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

@@ -4,6 +4,7 @@ import { ListDrawerProps, ListTogglerProps, UseListDrawer } from './types';
import { Drawer, DrawerToggler } from '../Drawer';
import { useEditDepth } from '../../utilities/EditDepth';
import { ListDrawerContent } from './DrawerContent';
import { useConfig } from '../../utilities/Config';
import './index.scss';
@@ -49,21 +50,26 @@ export const ListDrawer: React.FC<ListDrawerProps> = (props) => {
header={false}
gutter={false}
>
<ListDrawerContent {...props} />
<ListDrawerContent
{...props}
/>
</Drawer>
);
};
export const useListDrawer: UseListDrawer = ({
collectionSlugs,
collectionSlugs: collectionSlugsFromProps,
uploads,
selectedCollection,
filterOptions,
}) => {
const { collections } = useConfig();
const drawerDepth = useEditDepth();
const uuid = useId();
const { modalState, toggleModal, closeModal, openModal } = useModal();
const [isOpen, setIsOpen] = useState(false);
const [collectionSlugs, setCollectionSlugs] = useState(collectionSlugsFromProps);
const drawerSlug = formatListDrawerSlug({
depth: drawerDepth,
uuid,
@@ -73,6 +79,18 @@ export const useListDrawer: UseListDrawer = ({
setIsOpen(Boolean(modalState[drawerSlug]?.isOpen));
}, [modalState, drawerSlug]);
useEffect(() => {
if (!collectionSlugs || collectionSlugs.length === 0) {
const filteredCollectionSlugs = collections.filter(({ upload }) => {
if (uploads) {
return Boolean(upload) === true;
}
return true;
});
setCollectionSlugs(filteredCollectionSlugs.map(({ slug }) => slug));
}
}, [collectionSlugs, uploads, collections]);
const toggleDrawer = useCallback(() => {
toggleModal(drawerSlug);
}, [toggleModal, drawerSlug]);

View File

@@ -22,7 +22,7 @@ export type ListTogglerProps = HTMLAttributes<HTMLButtonElement> & {
}
export type UseListDrawer = (args: {
collectionSlugs: string[]
collectionSlugs?: string[]
selectedCollection?: string
uploads?: boolean // finds all collections with upload: true
filterOptions?: FilterOptionsResult

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

@@ -2,9 +2,14 @@
.clickable-arrow {
cursor: pointer;
transform: rotate(-90deg);
&--left {
&--right {
.icon {
transform: rotate(-90deg);
}
}
&--left .icon {
transform: rotate(90deg);
}

View File

@@ -18,6 +18,10 @@
}
}
.clickable-arrow--right {
margin-right: base(.25);
}
.clickable-arrow,
&__page {
@extend %btn-reset;

View File

@@ -93,16 +93,7 @@ const Pagination: React.FC<Props> = (props) => {
}
// Add prev and next arrows based on necessity
nodes.push({
type: 'ClickableArrow',
props: {
updatePage: () => updatePage(prevPage),
isDisabled: !hasPrevPage,
direction: 'left',
},
});
nodes.push({
nodes.unshift({
type: 'ClickableArrow',
props: {
updatePage: () => updatePage(nextPage),
@@ -111,6 +102,15 @@ const Pagination: React.FC<Props> = (props) => {
},
});
nodes.unshift({
type: 'ClickableArrow',
props: {
updatePage: () => updatePage(prevPage),
isDisabled: !hasPrevPage,
direction: 'left',
},
});
return (
<div className={baseClass}>
{nodes.map((node, i) => {

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

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

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

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

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

View File

@@ -1,15 +1,13 @@
@import '../../../scss/styles.scss';
$caretSize: 6;
.tooltip {
--caret-size: 6px;
opacity: 0;
background-color: var(--theme-elevation-800);
position: absolute;
z-index: 2;
bottom: 100%;
left: 50%;
transform: translate3d(-50%, calc(#{$caretSize}px * -1), 0);
padding: base(.2) base(.4);
color: var(--theme-elevation-0);
line-height: base(.75);
@@ -22,14 +20,12 @@ $caretSize: 6;
content: ' ';
display: block;
position: absolute;
bottom: 0;
left: 50%;
transform: translate3d(-50%, 100%, 0);
width: 0;
height: 0;
border-left: #{$caretSize}px solid transparent;
border-right: #{$caretSize}px solid transparent;
border-top: #{$caretSize}px solid var(--theme-elevation-800);
border-left: var(--caret-size) solid transparent;
border-right: var(--caret-size) solid transparent;
}
&--show {
@@ -39,6 +35,26 @@ $caretSize: 6;
cursor: default;
}
&--position-top {
bottom: 100%;
transform: translate3d(-50%, calc(var(--caret-size) * -1), 0);
&::after {
bottom: 1px;
border-top: var(--caret-size) solid var(--theme-elevation-800);
}
}
&--position-bottom {
top: 100%;
transform: translate3d(-50%, var(--caret-size), 0);
&::after {
bottom: calc(100% + var(--caret-size) - 1px);
border-bottom: var(--caret-size) solid var(--theme-elevation-800);
}
}
@include mid-break {
display: none;
}

View File

@@ -1,5 +1,6 @@
import React, { useEffect } from 'react';
import { Props } from './types';
import useIntersect from '../../../hooks/useIntersect';
import './index.scss';
@@ -9,9 +10,18 @@ const Tooltip: React.FC<Props> = (props) => {
children,
show: showFromProps = true,
delay = 350,
boundingRef,
} = props;
const [show, setShow] = React.useState(showFromProps);
const [position, setPosition] = React.useState<'top' | 'bottom'>('top');
const [ref, intersectionEntry] = useIntersect({
threshold: 0,
rootMargin: '-145px 0px 0px 100px',
root: boundingRef?.current || null,
});
useEffect(() => {
let timerId: NodeJS.Timeout;
@@ -30,16 +40,35 @@ const Tooltip: React.FC<Props> = (props) => {
};
}, [showFromProps, delay]);
useEffect(() => {
setPosition(intersectionEntry?.isIntersecting ? 'top' : 'bottom');
}, [intersectionEntry]);
return (
<aside
className={[
'tooltip',
className,
show && 'tooltip--show',
].filter(Boolean).join(' ')}
>
{children}
</aside>
<React.Fragment>
<aside
ref={ref}
className={[
'tooltip',
className,
'tooltip--position-top',
].filter(Boolean).join(' ')}
aria-hidden="true"
>
{children}
</aside>
<aside
className={[
'tooltip',
className,
show && 'tooltip--show',
`tooltip--position-${position}`,
].filter(Boolean).join(' ')}
>
{children}
</aside>
</React.Fragment>
);
};

View File

@@ -3,4 +3,5 @@ export type Props = {
children: React.ReactNode
show?: boolean
delay?: number
boundingRef?: React.RefObject<HTMLElement>
}

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

@@ -154,7 +154,7 @@ const Form: React.FC<Props> = (props) => {
// If submit handler comes through via props, run that
if (onSubmit) {
const data = {
...reduceFieldsToValues(fields),
...reduceFieldsToValues(fields, true),
...overrides,
};

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,
@@ -260,6 +261,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,
@@ -257,6 +258,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: {
@@ -31,7 +31,11 @@ export const createRelationMap: CreateRelationMap = ({
const add = (relation: string, id: unknown) => {
if (((typeof id === 'string') || typeof id === 'number') && typeof relation === 'string') {
relationMap[relation].push(id);
if (relationMap[relation]) {
relationMap[relation].push(id);
} else {
relationMap[relation] = [id];
}
}
};

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

@@ -14,6 +14,7 @@ import RenderFields from '../../../../../../RenderFields';
import FormSubmit from '../../../../../../Submit';
import buildStateFromSchema from '../../../../../../Form/buildStateFromSchema';
import { getTranslation } from '../../../../../../../../../utilities/getTranslation';
import deepCopyObject from '../../../../../../../../../utilities/deepCopyObject';
export const UploadDrawer: React.FC<ElementProps & {
drawerSlug: string
@@ -52,7 +53,7 @@ export const UploadDrawer: React.FC<ElementProps & {
useEffect(() => {
const awaitInitialState = async () => {
const state = await buildStateFromSchema({ fieldSchema, data: { ...element?.fields || {} }, user, operation: 'update', locale, t });
const state = await buildStateFromSchema({ fieldSchema, data: deepCopyObject(element?.fields || {}), user, operation: 'update', locale, t });
setInitialState(state);
};

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

@@ -81,6 +81,21 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
requests.post(`${serverURL}${api}/${userSlug}/logout`);
}, [serverURL, api, userSlug]);
const refreshPermissions = useCallback(async () => {
const request = await requests.get(`${serverURL}${api}/access`, {
headers: {
'Accept-Language': i18n.language,
},
});
if (request.status === 200) {
const json: Permissions = await request.json();
setPermissions(json);
} else {
throw new Error("Fetching permissions failed with status code " + request.status);
}
}, [serverURL, api, i18n]);
// On mount, get user and set
useEffect(() => {
const fetchMe = async () => {
@@ -117,21 +132,8 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
// When user changes, get new access
useEffect(() => {
async function getPermissions() {
const request = await requests.get(`${serverURL}${api}/access`, {
headers: {
'Accept-Language': i18n.language,
},
});
if (request.status === 200) {
const json: Permissions = await request.json();
setPermissions(json);
}
}
if (id) {
getPermissions();
refreshPermissions();
}
}, [i18n, id, api, serverURL]);
@@ -174,6 +176,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
user,
logOut,
refreshCookie,
refreshPermissions,
permissions,
setToken,
token: tokenInMemory,

View File

@@ -6,5 +6,6 @@ export type AuthContext<T = User> = {
refreshCookie: () => void
setToken: (token: string) => void
token?: string
refreshPermissions: () => Promise<void>
permissions?: Permissions
}

View File

@@ -4,6 +4,7 @@ import { useConfig } from '../Config';
import { Props } from './types';
import payloadFavicon from '../../../assets/images/favicon.svg';
import payloadOgImage from '../../../assets/images/og-image.png';
import useMountEffect from '../../../hooks/useMountEffect';
const Meta: React.FC<Props> = ({
description,
@@ -17,6 +18,13 @@ const Meta: React.FC<Props> = ({
const favicon = config.admin.meta.favicon ?? payloadFavicon;
const ogImage = config.admin.meta.ogImage ?? payloadOgImage;
useMountEffect(() => {
const faviconElement = document.querySelector<HTMLLinkElement>('link[data-placeholder-favicon]');
if (faviconElement) {
faviconElement.remove();
}
});
return (
<Helmet
htmlAttributes={{

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

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

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

@@ -59,6 +59,10 @@
.file-field__drop-zone {
border-color: var(--theme-success-500);
background: var(--theme-success-150);
* {
pointer-events: none;
}
}
}

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

@@ -0,0 +1,17 @@
@import '../../../../../../../scss/styles.scss';
.upload-cell {
display: flex;
flex-wrap: nowrap;
margin: base(-.25) 0;
.thumbnail {
max-width: base(3);
height: base(3);
}
&__filename {
align-self: center;
margin-left: base(1);
}
}

View File

@@ -0,0 +1,32 @@
import React from 'react';
import { Link } from 'react-router-dom';
import Thumbnail from '../../../../../../elements/Thumbnail';
import type { Props } from './types';
import { useConfig } from '../../../../../../utilities/Config';
import './index.scss';
const baseClass = 'upload-cell';
const UploadCell:React.FC<Props> = ({ rowData, cellData, collection }: Props) => {
const { routes: { admin } } = useConfig();
return (
<Link
className={baseClass}
to={`${admin}/collections/${collection.slug}/${rowData.id}`}
>
<Thumbnail
size="small"
doc={{
...rowData,
filename: cellData,
}}
collection={collection}
/>
<span className={`${baseClass}__filename`}>{ String(cellData) }</span>
</Link>
);
};
export default UploadCell;

View File

@@ -0,0 +1,6 @@
import { SanitizedCollectionConfig } from '../../../../../../../../collections/config/types';
import { Props as CellProps } from '../../types';
export type Props = CellProps & {
collection: SanitizedCollectionConfig
}

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

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

View File

@@ -1,3 +1,4 @@
import { Where } from '../../../../../types';
import { SanitizedCollectionConfig } from '../../../../../collections/config/types';
import { PaginatedDocs } from '../../../../../mongoose/types';
import { Props as ListControlsProps } from '../../../elements/ListControls/types';
@@ -11,15 +12,16 @@ export type Props = {
setListControls: (controls: unknown) => void
setSort: (sort: string) => void
toggleColumn: (column: string) => void
resetParams: (overrides?: { page?: number, sort?: string, where?: Where }) => void
hasCreatePermission: boolean
setLimit: (limit: number) => void
limit: number
disableEyebrow?: boolean
modifySearchParams?: boolean
onCardClick?: (doc: any) => void
disableCardLink?: boolean
handleSortChange?: ListControlsProps['handleSortChange']
handleWhereChange?: ListControlsProps['handleWhereChange']
handleDelete?: () => void
handlePageChange?: PaginatorProps['onChange']
handlePerPageChange?: PerPageProps['handleChange']
onCreateNewClick?: () => void

View File

@@ -43,12 +43,15 @@ const usePayloadAPI: UsePayloadAPI = (url, options = {}) => {
});
useEffect(() => {
const abortController = new AbortController();
const fetchData = async () => {
setIsError(false);
setIsLoading(true);
try {
const response = await requests.get(`${url}${search}`, {
signal: abortController.signal,
headers: {
'Accept-Language': i18n.language,
},
@@ -62,8 +65,10 @@ const usePayloadAPI: UsePayloadAPI = (url, options = {}) => {
setData(json);
setIsLoading(false);
} catch (error) {
setIsError(true);
setIsLoading(false);
if (!abortController.signal.aborted) {
setIsError(true);
setIsLoading(false);
}
}
};
@@ -73,6 +78,10 @@ const usePayloadAPI: UsePayloadAPI = (url, options = {}) => {
setIsError(false);
setIsLoading(false);
}
return () => {
abortController.abort();
};
}, [url, locale, search, i18n.language]);
return [{ data, isLoading, isError }, { setParams }];

View File

@@ -3,6 +3,7 @@ import { SanitizedCollectionConfig } from '../../collections/config/types';
import isImage from '../../uploads/isImage';
const absoluteURLPattern = new RegExp('^(?:[a-z]+:)?//', 'i');
const base64Pattern = new RegExp(/^data:image\/[a-z]+;base64,/);
const useThumbnail = (collection: SanitizedCollectionConfig, doc: Record<string, unknown>): string | false => {
const {
@@ -29,7 +30,7 @@ const useThumbnail = (collection: SanitizedCollectionConfig, doc: Record<string,
if (typeof adminThumbnail === 'function') {
const thumbnailURL = adminThumbnail({ doc });
if (absoluteURLPattern.test(thumbnailURL)) {
if (absoluteURLPattern.test(thumbnailURL) || base64Pattern.test(thumbnailURL)) {
return thumbnailURL;
}

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