Merge branch 'master' into feat/json-field

This commit is contained in:
Jarrod Flesch
2023-01-03 10:03:16 -05:00
173 changed files with 3707 additions and 2111 deletions

View File

@@ -1,5 +1,50 @@
## [1.4.1](https://github.com/payloadcms/payload/compare/v1.4.0...v1.4.1) (2022-12-24)
### Bug Fixes
* [#1761](https://github.com/payloadcms/payload/issues/1761), avoids rich text modifying form due to selection change ([9f4ce8d](https://github.com/payloadcms/payload/commit/9f4ce8d756742a6e1b2644ea49d0778774aae457))
# [1.4.0](https://github.com/payloadcms/payload/compare/v1.3.4...v1.4.0) (2022-12-23)
### Bug Fixes
* [#1611](https://github.com/payloadcms/payload/issues/1611), unable to query draft versions with draft=true ([44b31a9](https://github.com/payloadcms/payload/commit/44b31a9e585aad515557b749bf05253139a17bd9))
* [#1656](https://github.com/payloadcms/payload/issues/1656) remove size data ([389ee26](https://github.com/payloadcms/payload/commit/389ee261d4ebae0b773bca375ed8a74685506aa0))
* [#1698](https://github.com/payloadcms/payload/issues/1698) - globals and autosave not working ([915f1e2](https://github.com/payloadcms/payload/commit/915f1e2b3a0c9618d5699a0ee6f5e74c6f4038ee))
* [#1738](https://github.com/payloadcms/payload/issues/1738) save image dimensions to svg uploads ([2de435f](https://github.com/payloadcms/payload/commit/2de435f43a2e75391a655e91a0cda251da776bcb))
* [#1747](https://github.com/payloadcms/payload/issues/1747), rich text in arrays improperly updating initialValue when moving rows ([d417e50](https://github.com/payloadcms/payload/commit/d417e50d52fc0824fb5aaedd3e1208c3e1468bdd))
* [#1748](https://github.com/payloadcms/payload/issues/1748), bails out of autosave if doc is published while autosaving ([95e9300](https://github.com/payloadcms/payload/commit/95e9300d109c9bfd377d5b5efbb68ddca306bbec))
* [#1752](https://github.com/payloadcms/payload/issues/1752), removes label from row field type ([ff3ab18](https://github.com/payloadcms/payload/commit/ff3ab18d1690e50473be2d77897fb9de48361413))
* [#551](https://github.com/payloadcms/payload/issues/551) - rich text nested list structure ([542ea8e](https://github.com/payloadcms/payload/commit/542ea8eb81a6e608c7368882da9692d656f1d36b))
* allows cleared file to be reselected ([35abe81](https://github.com/payloadcms/payload/commit/35abe811c1534ba4f7e926edd3a2978ee67b181e))
* get relationships in locale of i18n language setting ([#1648](https://github.com/payloadcms/payload/issues/1648)) ([60bb265](https://github.com/payloadcms/payload/commit/60bb2652f0aa63747513e771173362985123519c))
* missing file after reselect in upload component ([6bc1758](https://github.com/payloadcms/payload/commit/6bc1758dc0cad3f52ce332e71134ee527e17fff0))
* prevents special characters breaking relationship field search ([#1710](https://github.com/payloadcms/payload/issues/1710)) ([9af4c1d](https://github.com/payloadcms/payload/commit/9af4c1dde7f4a68dc629738dff4fc314626cabb8))
* refreshes document drawer on save ([9567328](https://github.com/payloadcms/payload/commit/9567328d28709c5721b33e5bd61c9535568ffffd))
* removes update and created at fields when duplicating, ensures updatedAt data is reactive ([bd4ed5b](https://github.com/payloadcms/payload/commit/bd4ed5b99b5026544c910592c3bff6040e2058bc))
* safely clears sort [#1736](https://github.com/payloadcms/payload/issues/1736) ([341c163](https://github.com/payloadcms/payload/commit/341c163b36c330df76a6eb5146fccc80059eb9d7))
* simplifies radio validation ([0dfed3b](https://github.com/payloadcms/payload/commit/0dfed3b30a15829f9454332a4cbd7d9ce1fddea3))
* translated tab classnames ([238bada](https://github.com/payloadcms/payload/commit/238badabb4f38e691608219c54a541993d9f3010))
* updates relationship label on drawer save and prevents stepnav update ([59de4f7](https://github.com/payloadcms/payload/commit/59de4f7e82dc4f08240b13d48054589b561688fa))
* updates richtext toolbar position if inside a drawer ([468b0d2](https://github.com/payloadcms/payload/commit/468b0d2a55616993f10eac7d1709620d114ad7d6))
* use the slug for authentication header API Key ([5b70ebd](https://github.com/payloadcms/payload/commit/5b70ebd119b557cff66e97e3554af730657b4071))
### Features
* add Czech translation ([#1705](https://github.com/payloadcms/payload/issues/1705)) ([0be4285](https://github.com/payloadcms/payload/commit/0be428530512c3babdfe39be259dd165bb66b5f4))
* adds doc permissions to account view ([8d643fb](https://github.com/payloadcms/payload/commit/8d643fb29d3604b78f6cb46582720dde2a46affb))
* **graphql:** upgrade to graphql 16 ([57f5f5e](https://github.com/payloadcms/payload/commit/57f5f5ec439b5aee1d46bff0bf31aac6148f16b2))
### BREAKING CHANGES
* replaced the useAPIKey authentication header format to use the collection slug instead of the collection label. Previous: `${collection.labels.singular} API-Key ${apiKey}`, updated: `${collection.slug} API-Key ${apiKey}`
## [1.3.4](https://github.com/payloadcms/payload/compare/v1.3.3...v1.3.4) (2022-12-16)

101
README.md
View File

@@ -1,30 +1,76 @@
<h1 align="center">Payload</h1>
<p align="center">A free and open-source TypeScript headless CMS & application framework built with Express, MongoDB and React.</p>
<h1 align="center">
<img width="350" src="/src/admin/assets/images/payload-logo-dark.svg#gh-light-mode-only" alt="payload cms">
<img width="350" src="/src/admin/assets/images/payload-logo-light.svg#gh-dark-mode-only" alt="payload cms">
</h1>
<h2 align="center" style="padding-bottom: 24px !important;">The most powerful TypeScript CMS</h2>
<p align="center">Code-first Headless CMS that bridges the gap between CMS and application framework</p>
<h3 align="center">
<br />
<a href="https://payloadcms.com/docs/getting-started/what-is-payload" rel="dofollow"><strong>Explore the docs »</strong></a>
<br />
<br/>
</h3>
<h4 align="center">
<a target="_blank" href="https://github.com/payloadcms/payload/discussions">Request Feature</a>
·
<a target="_blank" href="https://github.com/payloadcms/payload/issues/new?assignees=&labels=possible-bug&template=BUG_REPORT.md">Report Bug</a>
·
<a target="_blank" href="https://discord.com/invite/r6sCXqVk3v">Join Discord</a>
·
<a target="_blank" rel="dofollow" href="https://payloadcms.com/docs/getting-started/what-is-payload">Docs</a>
·
<a target="_blank" rel="dofollow" href="https://payloadcms.com/">Website</a>
</h4>
<br />
<p align="center">
<a href="https://opensource.org/licenses/MIT">
<img src="https://img.shields.io/badge/License-MIT-blue.svg" />
</a>
&nbsp;
<a href="https://github.com/payloadcms/payload/actions">
<img src="https://github.com/payloadcms/payload/workflows/build/badge.svg" />
</a>
<a href="https://www.npmjs.com/package/payload">
<img alt="npm" src="https://img.shields.io/npm/v/payload" />
&nbsp;
<a href="https://github.com/payloadcms/payload/commits">
<img src="https://img.shields.io/github/commit-activity/m/payloadcms/payload" alt="git commit activity"/>
</a>
<a href="https://twitter.com/intent/tweet?text=Payload%20-%20A%20self-hosted%2C%20headless%20JavaScript%20CMS%20%26%20application%20framework&url=https%3A%2F%2Fgithub.com%2Fpayloadcms%2Fpayload">
<img alt="Tweet Payload" src="https://img.shields.io/twitter/url/http/shields.io.svg?style=social" />
</a>
&nbsp;
<a href="https://discord.com/invite/r6sCXqVk3v">
<img alt="Discord" src="https://img.shields.io/discord/967097582721572934?label=Discord&color=7289da" />
</a>
&nbsp;
<a href="https://www.npmjs.com/package/payload">
<img alt="npm" src="https://img.shields.io/npm/v/payload" />
</a>
&nbsp;
<a href="https://twitter.com/payloadcms">
<img src="https://img.shields.io/twitter/follow/payloadcms?label=Follow" alt="Payload CMS Twitter" />
</a>
</p>
<br />
<a href="https://payloadcms.com">
<img src="https://payloadcms.com/images/og-image.jpg" alt="Payload headless CMS Admin panel built with React" />
<img src="https://cms.payloadcms.com/media/payload-github-header.jpg" alt="Payload headless CMS Admin panel built with React" />
</a>
### Features
<br />
## ⭐ Why Payload?
Payload is a CMS that has been designed for developers from the ground up to deliver them what they need to build great digital products. If you know JavaScript, you know Payload. It's a _code-first_ CMS, which allows us to do a lot of things right:
- Payload gives you everything you need, but then steps back and lets you build what you want in JavaScript or TypeScript - with no unnecessary complexity brought by GUIs. You'll understand how your CMS works because you will have written it exactly how you want it.
- Bring your own Express server and do whatever you need on top of Payload. Payload doesn't impose anything on you or your app.
- Completely control the Admin panel by using your own React components. Swap out fields or even entire views with ease.
- Use your data however and wherever you need thanks to auto-generated, yet fully extensible REST, GraphQL, and Local Node APIs.
## ✨ Features
- Completely free and open-source
- [GraphQL](https://payloadcms.com/docs/graphql/overview), [REST](https://payloadcms.com/docs/rest-api/overview), and [Local](https://payloadcms.com/docs/local-api/overview) APIs
@@ -44,41 +90,38 @@
- Intensely fast API
- Highly secure thanks to HTTP-only cookies, CSRF protection, and more
### Code-first
Payload is a CMS that has been designed for developers from the ground up to deliver them what they need to build great digital products. If you know JavaScript, you know Payload. It's a _code-first_ CMS, which allows us to do a lot of things right:
- Payload gives you everything you need, but then steps back and lets you build what you want in JavaScript or TypeScript - with no unnecessary complexity brought by GUIs. You'll understand how your CMS works because you will have written it exactly how you want it.
- Bring your own Express server and do whatever you need on top of Payload. Payload doesn't impose anything on you or your app.
- Completely control the Admin panel by using your own React components. Swap out fields or even entire views with ease.
- Use your data however and wherever you need thanks to auto-generated, yet fully extensible REST, GraphQL, and Local Node APIs.
### Quick Start
## 🚀 Quick Start
Before beginning to work with Payload, make sure you have all of the [required software](https://payloadcms.com/docs/getting-started/installation).
From there, the easiest way to get started with Payload is to use the `create-payload-app` package:
```
```text
npx create-payload-app
```
Alternatively, it only takes about five minutes to [create an app from scratch](https://payloadcms.com/docs/getting-started/installation#from-scratch).
### Documentation
## 🗒️ Documentation
Check out the [Payload website](https://payloadcms.com/docs/getting-started/what-is-payload) to find in-depth documentation for everything that Payload offers.
### Contributing
## 🙋 Contributing
If you want to add contributions to this repository, please follow the instructions in [contributing.md](./contributing.md).
### Other Resources
## 🚨 Need help?
##### Discussions
There are lots of good conversations and resources in our Github Discussions board & our Discord Server. If you're struggling with something, chances are, someone's already solved what you're up against. :point_down:
There are lots of good conversations and resources in our [GitHub Discussions board](https://github.com/payloadcms/payload/discussions). If you're struggling with something, chances are, someone's already solved what you're up against. Searching Discussions will often provide very helpful tips and tricks.
- [GitHub Discussions](https://github.com/payloadcms/payload/discussions)
- [GitHub Issues](https://github.com/payloadcms/payload/issues)
- [Discord](https://t.co/30APlsQUPB)
##### Discord
## ⭐ Like what we're doing? Give us a star
Join [Payload's Discord channel](https://discord.com/invite/r6sCXqVk3v) to interact with Payload developers in realtime.
![payload-github-star](https://cms.payloadcms.com/media/payload-github-star.gif)
## 👏 Thanks to all our contributors
<img align="left" src="https://contributors-img.web.app/image?repo=payloadcms/payload"/>

View File

@@ -43,22 +43,22 @@ To enable API keys on a collection, set the `useAPIKey` auth option to `true`. F
is compromised, your API keys will not be.
</Banner>
##### Authenticating via API Key
#### Authenticating via API Key
To utilize your API key while interacting with the REST or GraphQL API, add the `Authorization` header.
To authenticate REST or GraphQL API requests using an API key, set the `Authorization` header. The header is case-sensitive and needs the slug of the `auth.useAPIKey` enabled collection, then " API-Key ", followed by the `apiKey` that has been assigned. Payload's built-in middleware will then assign the user document to `req.user` and handle requests with the proper access control.
**For example, using Fetch:**
```ts
import User from '../collections/User';
const response = await fetch("http://localhost:3000/api/pages", {
headers: {
Authorization: `${collection.labels.singular} API-Key ${YOUR_API_KEY}`,
Authorization: `${User.slug} API-Key ${YOUR_API_KEY}`,
},
});
```
Note: The label portion of the header is case-sensitive and will likely have a capitalized first character unless the label has been customized.
### Forgot Password
You can customize how the Forgot Password workflow operates with the following options on the `auth.forgotPassword` property:

View File

@@ -6,16 +6,16 @@ desc: Set up your Global config for your needs by defining fields, adding slugs
keywords: globals, config, configuration, documentation, Content Management System, cms, headless, javascript, node, react, express
---
Global configs are in many ways similar to [Collections](/docs/configuration/collections). The big difference is that Collections will potentially contain *many* documents, while a Global is a "one-off". Globals are perfect for things like header nav, site-wide banner alerts, app-wide localized strings, and other "global" data that your site or app might rely on.
Global configs are in many ways similar to [Collections](/docs/configuration/collections). The big difference is that Collections will potentially contain _many_ documents, while a Global is a "one-off". Globals are perfect for things like header nav, site-wide banner alerts, app-wide localized strings, and other "global" data that your site or app might rely on.
As with Collection configs, it's often best practice to write your Globals in separate files and then import them into the main Payload config.
## Options
| Option | Description |
|--------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **`slug`** * | Unique, URL-friendly string that will act as an identifier for this Global. |
| **`fields`** * | Array of field types that will determine the structure and functionality of the data stored within this Global. [Click here](/docs/fields/overview) for a full list of field types as well as how to configure them. |
| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`slug`** \* | Unique, URL-friendly string that will act as an identifier for this Global. |
| **`fields`** \* | Array of field types that will determine the structure and functionality of the data stored within this Global. [Click here](/docs/fields/overview) for a full list of field types as well as how to configure them. |
| **`label`** | Text for the name in the Admin panel or an object with keys for each language. Auto-generated from slug if not defined. |
| **`description`** | Text or React component to display below the Global header to give editors more information. |
| **`admin`** | Admin-specific configuration. See below for [more detail](/docs/configuration/globals#admin-options). |
@@ -26,31 +26,31 @@ As with Collection configs, it's often best practice to write your Globals in se
| **`graphQL.name`** | Text used in schema generation. Auto-generated from slug if not defined. |
| **`typescript`** | An object with property `interface` as the text used in schema generation. Auto-generated from slug if not defined. |
*\* An asterisk denotes that a property is required.*
_\* An asterisk denotes that a property is required._
#### Simple Global example
```ts
import { GlobalConfig } from 'payload/types';
import { GlobalConfig } from "payload/types";
const Nav: GlobalConfig = {
slug: 'nav',
slug: "nav",
fields: [
{
name: 'items',
type: 'array',
name: "items",
type: "array",
required: true,
maxRows: 8,
fields: [
{
name: 'page',
type: 'relationship',
relationTo: 'pages', // "pages" is the slug of an existing collection
name: "page",
type: "relationship",
relationTo: "pages", // "pages" is the slug of an existing collection
required: true,
}
]
},
]
],
},
],
};
export default Nav;
@@ -65,8 +65,46 @@ You can find an [example Global config](https://github.com/payloadcms/public-dem
You can customize the way that the Admin panel behaves on a Global-by-Global basis by defining the `admin` property on a Global's config.
| Option | Description |
| ---------------------------- | -------------|
| ------------ | ----------------------------------------------------------------------------------------------------------------------- |
| `components` | Swap in your own React components to be used within this Global. [More](/docs/admin/components#globals) |
| `preview` | Function to generate a preview URL within the Admin panel for this global that can point to your app. [More](#preview). |
### Preview
Global `admin` options can accept a `preview` function that will be used to generate a link pointing to the frontend of your app to preview data.
If the function is specified, a Preview button will automatically appear in the corresponding global's Edit view. Clicking the Preview button will link to the URL that is generated by the function.
**The preview function accepts two arguments:**
1. The document being edited
1. An `options` object, containing `locale` and `token` properties. The `token` is the currently logged-in user's JWT.
**Example global with preview function:**
```ts
import { GlobalConfig } from "payload/types";
const MyGlobal: CollectionConfig = {
slug: "my-global",
fields: [
{
name: "slug",
type: "text",
required: true,
},
],
admin: {
preview: (doc, { locale }) => {
if (doc?.slug) {
return `https://bigbird.com/preview/${doc.slug}?locale=${locale}`;
}
return null;
},
},
};
```
### Access control
@@ -85,14 +123,14 @@ Globals support all field types that Payload has to offer—including simple fie
You can import global types as follows:
```ts
import { GlobalConfig } from 'payload/types';
import { GlobalConfig } from "payload/types";
// This is the type used for incoming global configs.
// Only the bare minimum properties are marked as required.
```
```ts
import { SanitizedGlobalConfig } from 'payload/types';
import { SanitizedGlobalConfig } from "payload/types";
// This is the type used after an incoming global config is fully sanitized.
// Generally, this is only used internally by Payload.

View File

@@ -23,7 +23,7 @@ keywords: array, fields, config, configuration, documentation, Content Managemen
| Option | Description |
| ---------------- |-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **`name`** * | To be used as the property name when stored and retrieved from the database. |
| **`name`** * | To be used as the property name when stored and retrieved from the database. [More](/docs/fields/overview#field-names) |
| **`label`** | Text used as the heading in the Admin panel or an object with keys for each language. Auto-generated from name if not defined. |
| **`fields`** * | Array of field types to correspond to each row of the Array. |
| **`validate`** | Provide a custom validation function that will be executed on both the Admin panel and the backend. [More](/docs/fields/overview#validation) |
@@ -46,7 +46,7 @@ In addition to the default [field admin config](/docs/fields/overview#admin-conf
| Option | Description |
| ---------------------- | ------------------------------- |
| **`initCollapsed`** | Set the initial collapsed state |
| **`components.RowLabel`** | Function or React component to be rendered as the label on the array row. Recieves `({ data, index, path })` as args |
| **`components.RowLabel`** | Function or React component to be rendered as the label on the array row. Receives `({ data, index, path })` as args |
### Example

View File

@@ -24,7 +24,7 @@ keywords: blocks, fields, config, configuration, documentation, Content Manageme
| Option | Description |
|-------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **`name`** * | To be used as the property name when stored and retrieved from the database. |
| **`name`** * | To be used as the property name when stored and retrieved from the database. [More](/docs/fields/overview#field-names) |
| **`label`** | Text used as the heading in the Admin panel or an object with keys for each language. Auto-generated from name if not defined. |
| **`blocks`** * | Array of [block configs](/docs/fields/blocks#block-configs) to be made available to this field. |
| **`validate`** | Provide a custom validation function that will be executed on both the Admin panel and the backend. [More](/docs/fields/overview#validation) |

View File

@@ -14,7 +14,7 @@ keywords: checkbox, fields, config, configuration, documentation, Content Manage
| Option | Description |
| ---------------- | ----------- |
| **`name`** * | To be used as the property name when stored and retrieved from the database. |
| **`name`** * | To be used as the property name when stored and retrieved from the database. [More](/docs/fields/overview#field-names) |
| **`label`** | Text used as a field label in the Admin panel or an object with keys for each language. |
| **`validate`** | Provide a custom validation function that will be executed on both the Admin panel and the backend. [More](/docs/fields/overview#validation) |
| **`index`** | Build a [MongoDB index](https://docs.mongodb.com/manual/indexes/) for this field to produce faster queries. Set this field to `true` if your users will perform queries on this field's data often. |

View File

@@ -17,7 +17,7 @@ This field uses `prismjs` for syntax highlighting and `react-simple-code-editor`
| Option | Description |
| ---------------- | ----------- |
| **`name`** * | To be used as the property name when stored and retrieved from the database. |
| **`name`** * | To be used as the property name when stored and retrieved from the database. [More](/docs/fields/overview#field-names) |
| **`label`** | Text used as a field label in the Admin panel or an object with keys for each language. |
| **`unique`** | Enforce that each entry in the Collection has a unique value for this field. |
| **`index`** | Build a [MongoDB index](https://docs.mongodb.com/manual/indexes/) for this field to produce faster queries. Set this field to `true` if your users will perform queries on this field's data often. |

View File

@@ -17,7 +17,7 @@ This field uses [`react-datepicker`](https://www.npmjs.com/package/react-datepic
| Option | Description |
| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`name`** \* | To be used as the property name when stored and retrieved from the database. |
| **`name`** \* | To be used as the property name when stored and retrieved from the database. [More](/docs/fields/overview#field-names) |
| **`label`** | Text used as a field label in the Admin panel or an object with keys for each language. |
| **`index`** | Build a [MongoDB index](https://docs.mongodb.com/manual/indexes/) for this field to produce faster queries. Set this field to `true` if your users will perform queries on this field's data often. |
| **`validate`** | Provide a custom validation function that will be executed on both the Admin panel and the backend. [More](/docs/fields/overview#validation) |

View File

@@ -14,7 +14,7 @@ keywords: email, fields, config, configuration, documentation, Content Managemen
| Option | Description |
| ---------------- | ----------- |
| **`name`** * | To be used as the property name when stored and retrieved from the database. |
| **`name`** * | To be used as the property name when stored and retrieved from the database. [More](/docs/fields/overview#field-names) |
| **`label`** | Text used as a field label in the Admin panel or an object with keys for each language. |
| **`unique`** | Enforce that each entry in the Collection has a unique value for this field. |
| **`index`** | Build a [MongoDB index](https://docs.mongodb.com/manual/indexes/) for this field to produce faster queries. Set this field to `true` if your users will perform queries on this field's data often. |

View File

@@ -14,7 +14,7 @@ keywords: group, fields, config, configuration, documentation, Content Managemen
| Option | Description |
| ---------------- | ----------- |
| **`name`** * | To be used as the property name when stored and retrieved from the database. |
| **`name`** * | To be used as the property name when stored and retrieved from the database. [More](/docs/fields/overview#field-names) |
| **`fields`** * | Array of field types to nest within this Group. |
| **`label`** | Used as a heading in the Admin panel and to name the generated GraphQL type. |
| **`validate`** | Provide a custom validation function that will be executed on both the Admin panel and the backend. [More](/docs/fields/overview#validation) |

View File

@@ -14,7 +14,7 @@ keywords: number, fields, config, configuration, documentation, Content Manageme
| Option | Description |
| ---------------- | ----------- |
| **`name`** * | To be used as the property name when stored and retrieved from the database. |
| **`name`** * | To be used as the property name when stored and retrieved from the database. [More](/docs/fields/overview#field-names) |
| **`label`** | Text used as a field label in the Admin panel or an object with keys for each language. |
| **`min`** | Minimum value accepted. Used in the default `validation` function. |
| **`max`** | Maximum value accepted. Used in the default `validation` function. |

View File

@@ -64,6 +64,10 @@ One of the most powerful parts about Payload is its ability for you to define fi
In addition to being able to define access control on a document-level, you can define extremely granular permissions on a field by field level. For more information about field-level access control, [click here](/docs/access-control/overview#fields).
### Field names
Some fields use their `name` property as a unique identifier to store and retrieve from the database. `__v`, `salt`, and `hash` are all reserved field names which are sanitized from Payload's config and cannot be used.
### Validation
Field validation is enforced automatically based on the field type and other properties such as `required` or `min` and `max` value constraints on certain field types. This default behavior can be replaced by providing your own validate function for any field. It will be used on both the frontend and the backend, so it should not rely on any Node-specific packages. The validation function can be either synchronous or asynchronous and expects to return either `true` or a string error message to display in both API responses and within the Admin panel.

View File

@@ -17,7 +17,7 @@ The data structure in the database matches the GeoJSON structure to represent po
| Option | Description |
| ---------------- | ----------- |
| **`name`** * | To be used as the property name when stored and retrieved from the database. |
| **`name`** * | To be used as the property name when stored and retrieved from the database. [More](/docs/fields/overview#field-names) |
| **`label`** | Used as a field label in the Admin panel and to name the generated GraphQL type. |
| **`unique`** | Enforce that each entry in the Collection has a unique value for this field. |
| **`index`** | Build a [MongoDB index](https://docs.mongodb.com/manual/indexes/) for this field to produce faster queries. To support location queries, point index defaults to `2dsphere`, to disable the index set to `false`. |

View File

@@ -14,7 +14,7 @@ keywords: radio, fields, config, configuration, documentation, Content Managemen
| Option | Description |
| ---------------- | ----------- |
| **`name`** * | To be used as the property name when stored and retrieved from the database. |
| **`name`** * | To be used as the property name when stored and retrieved from the database. [More](/docs/fields/overview#field-names) |
| **`options`** * | Array of options to allow the field to store. Can either be an array of strings, or an array of objects containing an `label` string and a `value` string. |
| **`label`** | Text used as a field label in the Admin panel or an object with keys for each language. |
| **`validate`** | Provide a custom validation function that will be executed on both the Admin panel and the backend. [More](/docs/fields/overview#validation) |

View File

@@ -20,7 +20,7 @@ keywords: relationship, fields, config, configuration, documentation, Content Ma
| Option | Description |
| ---------------- | ----------- |
| **`name`** * | To be used as the property name when stored and retrieved from the database. |
| **`name`** * | To be used as the property name when stored and retrieved from the database. [More](/docs/fields/overview#field-names) |
| **`relationTo`** * | Provide one or many collection `slug`s to be able to assign relationships to. |
| **`filterOptions`** | A query to filter which options appear in the UI and validate against. [More](#filtering-relationship-options). |
| **`hasMany`** | Boolean when, if set to `true`, allows this field to have many relations instead of only one. |

View File

@@ -20,7 +20,7 @@ The Admin component is built on the powerful [`slatejs`](https://docs.slatejs.or
| Option | Description |
| ---------------- | ----------- |
| **`name`** * | To be used as the property name when stored and retrieved from the database. |
| **`name`** * | To be used as the property name when stored and retrieved from the database. [More](/docs/fields/overview#field-names) |
| **`label`** | Text used as a field label in the Admin panel or an object with keys for each language. |
| **`validate`** | Provide a custom validation function that will be executed on both the Admin panel and the backend. [More](/docs/fields/overview#validation) |
| **`saveToJWT`** | If this field is top-level and nested in a config supporting [Authentication](/docs/authentication/config), include its data in the user JWT. |

View File

@@ -15,7 +15,7 @@ keywords: select, multi-select, fields, config, configuration, documentation, Co
| Option | Description |
| ------------------ |-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **`name`** \* | To be used as the property name when stored and retrieved from the database. |
| **`name`** \* | To be used as the property name when stored and retrieved from the database. [More](/docs/fields/overview#field-names) |
| **`options`** \* | Array of options to allow the field to store. Can either be an array of strings, or an array of objects containing a `label` string and a `value` string. |
| **`hasMany`** | Boolean when, if set to `true`, allows this field to have many selections instead of only one. |
| **`label`** | Text used as a field label in the Admin panel or an object with keys for each language. |

View File

@@ -26,7 +26,7 @@ Each tab has its own required `label` and `fields` array. You can also optionall
| Option | Description |
| ----------------- | ----------- |
| **`name`** | An optional property name to be used when stored and retrieved from the database, similar to a [Group](/docs/fields/group). |
| **`name`** | An optional property name to be used when stored and retrieved from the database. [More](/docs/fields/overview#field-names) |
| **`label`** * | The label to render on the tab itself. |
| **`fields`** * | The fields to render within this tab. |
| **`description`** | Optionally render a description within this tab to describe the contents of the tab itself. |

View File

@@ -14,7 +14,7 @@ keywords: text, fields, config, configuration, documentation, Content Management
| Option | Description |
| ---------------- | ----------- |
| **`name`** * | To be used as the property name when stored and retrieved from the database. |
| **`name`** * | To be used as the property name when stored and retrieved from the database. [More](/docs/fields/overview#field-names) |
| **`label`** | Text used as a field label in the Admin panel or an object with keys for each language. |
| **`unique`** | Enforce that each entry in the Collection has a unique value for this field. |
| **`minLength`** | Used by the default validation function to ensure values are of a minimum character length. |

View File

@@ -14,7 +14,7 @@ keywords: textarea, fields, config, configuration, documentation, Content Manage
| Option | Description |
| ---------------- | ----------- |
| **`name`** * | To be used as the property name when stored and retrieved from the database. |
| **`name`** * | To be used as the property name when stored and retrieved from the database. [More](/docs/fields/overview#field-names) |
| **`label`** | Text used as a field label in the Admin panel or an object with keys for each language. |
| **`unique`** | Enforce that each entry in the Collection has a unique value for this field. |
| **`minLength`** | Used by the default validation function to ensure values are of a minimum character length. |

View File

@@ -25,7 +25,7 @@ keywords: upload, images media, fields, config, configuration, documentation, Co
| Option | Description |
| ---------------- |-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **`name`** * | To be used as the property name when stored and retrieved from the database. |
| **`name`** * | To be used as the property name when stored and retrieved from the database. [More](/docs/fields/overview#field-names) |
| **`*relationTo`** * | Provide a single collection `slug` to allow this field to accept a relation to. <strong>Note: the related collection must be configured to support Uploads.</strong> |
| **`filterOptions`** | A query to filter which options appear in the UI and validate against. [More](#filtering-upload-options). |
| **`maxDepth`** | Sets a number limit on iterations of related documents to populate when queried. [Depth](/docs/getting-started/concepts#depth) |

View File

@@ -131,3 +131,7 @@ A function that is called immediately following startup that receives the Payloa
After you've gotten this far, it's time to boot up Payload. At the command line, run `npm install` and then `node server.js` in your application's folder to start up your app and initialize Payload.
After it starts, you can go to `http://localhost:3000/admin` to create your first Payload user!
### Docker
Looking to deploy Payload with Docker? New projects with `create-payload-app` come with a Dockerfile and docker-compose.yml file ready to go. Examples of these files can be seen in our [Deployment docs](/docs/deployment#Docker).

View File

@@ -128,3 +128,77 @@ DigitalOcean provides extremely helpful documentation that can walk you through
1. [Install and secure MongoDB](https://www.digitalocean.com/community/tutorials/how-to-install-mongodb-on-ubuntu-20-04)
1. [Create a new MongoDB and user](https://medium.com/@mhagemann/how-to-add-a-new-user-to-a-mongodb-database-d896776b5362)
1. [Set up Node for production](https://www.digitalocean.com/community/tutorials/how-to-set-up-a-node-js-application-for-production-on-ubuntu-20-04)
## Docker
This is an example of a multi-stage docker build of Payload for production. Ensure you are setting your environment variables on deployment, like `PAYLOAD_SECRET`, `PAYLOAD_CONFIG_PATH`, and `MONGODB_URI` if needed.
```dockerfile
FROM node:18-alpine as base
FROM base as builder
WORKDIR /home/node
COPY package*.json ./
COPY . .
RUN yarn install
RUN yarn build
FROM base as runtime
ENV NODE_ENV=production
WORKDIR /home/node
COPY package*.json ./
RUN yarn install --production
COPY --from=builder /home/node/dist ./dist
COPY --from=builder /home/node/build ./build
EXPOSE 3000
CMD ["node", "dist/server.js"]
```
## Docker Compose
Here is an example of a docker-compose.yml file that can be used for development
```yml
version: '3'
services:
payload:
image: node:18-alpine
ports:
- "3000:3000"
volumes:
- .:/home/node/app
- node_modules:/home/node/app/node_modules
working_dir: /home/node/app/
command: sh -c "yarn install && yarn dev"
depends_on:
- mongo
environment:
MONGODB_URI: mongodb://mongo:27017/payload
PORT: 3000
NODE_ENV: development
PAYLOAD_SECRET: TESTING
mongo:
image: mongo:latest
ports:
- "27017:27017"
command:
- --storageEngine=wiredTiger
volumes:
- data:/data/db
logging:
driver: none
volumes:
data:
node_modules:
```

View File

@@ -1,6 +1,6 @@
{
"name": "payload",
"version": "1.3.4",
"version": "1.4.1",
"description": "Node, React and MongoDB Headless CMS and Application Framework",
"license": "MIT",
"engines": {

View File

@@ -0,0 +1,88 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 25.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 124 30" style="enable-background:new 0 0 124 30;" xml:space="preserve">
<style type="text/css">
.st0{fill:#333333;}
</style>
<path class="st0" d="M34.813099,6.555H41.3451c1.385273-0.070708,2.751808,0.344133,3.863998,1.173
c1.039825,0.899059,1.586941,2.241208,1.472,3.611c0.111458,1.37593-0.434399,2.723513-1.472,3.634
c-1.118271,0.814574-2.4823,1.220535-3.863998,1.15h-4.461998V23h-2.07L34.813099,6.555z M41.483101,14.283
c0.822922,0.06823,1.639957-0.18761,2.277-0.713c0.549107-0.607606,0.823647-1.41459,0.758999-2.231
c0.067909-0.809881-0.207489-1.611044-0.758999-2.208c-0.630104-0.539837-1.450134-0.804896-2.277-0.736h-4.599998v5.888
L41.483101,14.283z M51.1646,23.321999c-1.024158,0.052488-2.03162-0.275143-2.828999-0.92
c-0.688705-0.609777-1.068405-1.495747-1.035-2.414999c-0.036377-0.737299,0.174046-1.465687,0.598-2.07
c0.430389-0.544542,1.003754-0.958641,1.655998-1.195999c0.89489-0.325996,1.819183-0.564774,2.759998-0.712999
c0.58239-0.095267,1.158451-0.225842,1.724998-0.391c0.333458-0.086607,0.64114-0.252282,0.896999-0.483
c0.209099-0.241613,0.316288-0.554937,0.299-0.874c0-1.150001-0.812668-1.725001-2.438-1.725
c-0.591373-0.020433-1.182041,0.057286-1.748001,0.23c-0.385189,0.128752-0.706268,0.400434-0.896999,0.759
c-0.208683,0.431378-0.32613,0.901169-0.345001,1.38h-1.931999c0.013546-1.106599,0.488823-2.157211,1.311001-2.898
c1.053455-0.797718,2.362473-1.182241,3.68-1.081c1.069542-0.048652,2.123863,0.267645,2.990002,0.897
c0.835869,0.720024,1.272713,1.799289,1.173,2.898v5.635c-0.016964,0.358055,0.029758,0.716278,0.138,1.058001
c0.066074,0.196636,0.252625,0.327223,0.459999,0.322001l0.276001-0.023001V23c-0.293846,0.071281-0.594673,0.10985-0.896999,0.115
c-0.448277,0.029144-0.891262-0.110313-1.242001-0.391001c-0.327774-0.411825-0.506161-0.922657-0.506001-1.448999h-0.046001
c-0.384689,0.646172-0.942337,1.171953-1.610001,1.518C52.844852,23.164421,52.008141,23.345972,51.1646,23.321999z
M49.232601,19.894999c-0.036285,0.504335,0.148373,0.999548,0.506001,1.357c0.461899,0.345982,1.035038,0.509735,1.610001,0.459999
c0.998661,0.049717,1.98558-0.233416,2.806-0.805c0.737389-0.586956,1.141094-1.497444,1.081001-2.438v-1.862997
c-0.836132,0.516708-1.781067,0.831686-2.759998,0.92c-0.881981,0.135984-1.731636,0.432194-2.507,0.874001
C49.478649,18.735281,49.199535,19.302231,49.232601,19.894999z M60.820301,27.116999
c-0.408855-0.003748-0.816658-0.042219-1.219002-0.115V25.438c0.305218,0.039368,0.612324,0.062401,0.919998,0.069
c0.560413-0.003313,1.097176-0.226274,1.494999-0.621c0.540234-0.546392,0.901249-1.244356,1.035-2.000999L58.290298,11.27h2.07
l3.611,9.338001h0.045998l3.449997-9.338h1.977997l-4.737999,12.075c-0.363663,1.095852-0.959949,2.100128-1.748001,2.944
C62.381443,26.833206,61.613956,27.130299,60.820301,27.116999z M70.9216,6.555h1.839996V23H70.9216V6.555z M80.542297,23.345001
c-1.079521,0.026121-2.145523-0.244358-3.082001-0.782c-0.857864-0.520388-1.54528-1.279743-1.977997-2.184999
c-0.473877-1.014378-0.709877-2.123568-0.690002-3.243c-0.021645-1.112206,0.214523-2.214319,0.690002-3.22
c0.42762-0.914654,1.1157-1.682744,1.977997-2.208c0.936478-0.537642,2.00248-0.80812,3.082001-0.782
c1.06498-0.026295,2.116249,0.244486,3.036003,0.782c0.872543,0.519647,1.569481,1.28868,2.000999,2.208
c0.475479,1.005681,0.711647,2.107795,0.690002,3.22c0.019875,1.119432-0.216125,2.228622-0.690002,3.243
c-0.436684,0.909891-1.132935,1.670162-2.000999,2.184999C82.658546,23.100515,81.607277,23.371294,80.542297,23.345001z
M76.724297,17.135002c-0.046257,1.179615,0.292839,2.342216,0.966003,3.312c1.374138,1.568766,3.759834,1.72654,5.328598,0.3524
c0.125099-0.109579,0.242821-0.227301,0.352402-0.3524c0.673164-0.969784,1.01226-2.132385,0.966003-3.312
c0.048729-1.172768-0.290817-2.328851-0.966003-3.289c-1.337448-1.568765-3.693398-1.756284-5.262161-0.418835
c-0.15049,0.128296-0.290543,0.268349-0.418839,0.418835C77.015114,14.80615,76.675568,15.962234,76.724297,17.135002z
M91.527,23.322001c-1.024162,0.052523-2.031639-0.275112-2.829002-0.92c-0.688705-0.609776-1.068413-1.495747-1.035004-2.415001
c-0.03643-0.737303,0.174004-1.465708,0.598-2.07c0.430344-0.544586,1.003731-0.958694,1.655998-1.195999
c0.894882-0.326023,1.819183-0.564798,2.760002-0.712999c0.582382-0.09529,1.158447-0.225863,1.724998-0.391
c0.333443-0.086654,0.641113-0.252322,0.897003-0.483c0.209061-0.241635,0.316246-0.554943,0.299004-0.874
c0-1.150001-0.812668-1.725001-2.438004-1.725c-0.59137-0.020441-1.182045,0.057279-1.748001,0.23
c-0.385193,0.128751-0.706268,0.400434-0.897003,0.759c-0.208679,0.431378-0.326126,0.901169-0.345001,1.38h-1.931992
c0.013458-1.106618,0.488747-2.157265,1.310997-2.898c1.053444-0.797748,2.362473-1.182275,3.68-1.081
c1.069534-0.048619,2.123848,0.267673,2.989998,0.897c0.835869,0.720024,1.272713,1.799289,1.172997,2.898v5.635
c-0.016968,0.358055,0.029762,0.716278,0.138,1.058001c0.066048,0.196655,0.252617,0.327251,0.459999,0.322001l0.276001-0.023001V23
c-0.293846,0.07127-0.594681,0.109838-0.897003,0.115c-0.44828,0.029177-0.891273-0.110285-1.241997-0.391001
c-0.32782-0.411802-0.50621-0.922649-0.505997-1.448999h-0.045998c-0.384743,0.646128-0.942375,1.171896-1.610001,1.518
C93.207245,23.1644,92.370537,23.345951,91.527,23.322001z M89.595001,19.895c-0.036331,0.504341,0.148331,0.999575,0.505997,1.357
c0.461891,0.345993,1.035042,0.509748,1.610001,0.459999c0.998657,0.049686,1.985565-0.233444,2.806-0.805
c0.73735-0.586985,1.141045-1.497452,1.081001-2.438v-1.862999c-0.836136,0.516708-1.781067,0.831686-2.760002,0.92
c-0.881989,0.135948-1.731651,0.432159-2.507004,0.874001C89.841019,18.73526,89.561897,19.302227,89.595001,19.895z
M104.834999,23.322001c-0.947945,0.009354-1.879768-0.245502-2.691002-0.736c-0.8218-0.53285-1.482002-1.280552-1.908997-2.162001
c-0.480354-1.028526-0.716568-2.154137-0.690201-3.289c-0.026367-1.134863,0.209846-2.260472,0.690201-3.289
c0.423233-0.877855,1.084732-1.61905,1.908997-2.139c0.806282-0.504401,1.739983-0.767752,2.691002-0.759
c1.464203-0.073218,2.860054,0.624707,3.68,1.84h0.045998V6.555h1.839996V23h-1.839996v-1.448999h-0.045998
C107.654121,22.710478,106.278236,23.372623,104.834999,23.322001z M101.476997,17.135
c-0.005173,0.768642,0.127243,1.532007,0.390999,2.254c0.23764,0.658501,0.651306,1.239225,1.195999,1.679001
c0.556534,0.43626,1.248146,0.664085,1.955002,0.643999c1.011169,0.03722,1.975395-0.427675,2.575996-1.242001
c0.683739-0.973391,1.023643-2.146864,0.966003-3.335001c0.060226-1.181292-0.280151-2.348316-0.966003-3.312
c-1.090065-1.404866-3.103142-1.680769-4.530998-0.621c-0.548683,0.445539-0.962761,1.034796-1.195999,1.702
C101.602959,15.617669,101.470459,16.373734,101.476997,17.135z M4.67358,7.05762L14.7263,13.5488v12.336999l7.560599-4.7103
V8.83849L12.2462,2.33875L4.67358,7.05762z M11.4765,25.201799v-9.627l-7.5766,4.7189L11.4765,25.201799z M117.998001,8.75976
c-0.004463-0.341642,0.059807-0.680696,0.189003-0.997c0.120857-0.290308,0.29792-0.553858,0.521004-0.77549
c0.22216-0.217432,0.485695-0.388062,0.775002-0.50178c0.304459-0.122437,0.629852-0.184414,0.958-0.18247
c0.326248-0.002423,0.649773,0.059586,0.952003,0.18247c0.585327,0.233346,1.050858,0.694288,1.290001,1.27727
c0.261337,0.636756,0.261337,1.350824,0,1.98758c-0.120064,0.290741-0.297226,0.554445-0.521004,0.77551
c-0.220543,0.214946-0.482063,0.383349-0.768997,0.4952c-0.303246,0.118704-0.626358,0.178439-0.952003,0.176
c-0.327545,0.001974-0.652542-0.057732-0.958-0.176c-0.288139-0.112983-0.551407-0.281204-0.775002-0.4952
c-0.221207-0.223182-0.39801-0.486349-0.521004-0.77551C118.058205,9.436166,117.993927,9.099288,117.998001,8.75976z
M118.480003,8.75976c-0.00351,0.28082,0.047432,0.559659,0.150002,0.8211c0.098404,0.234851,0.239838,0.449244,0.417,0.63214
c0.179451,0.174817,0.391914,0.312154,0.625,0.404c0.497253,0.191203,1.047745,0.191203,1.544998,0
c0.473839-0.185051,0.847771-0.561208,1.029999-1.03614c0.198669-0.531618,0.198669-1.117102,0-1.64872
c-0.184547-0.474923-0.557365-0.852327-1.029999-1.04267c-0.495613-0.199844-1.049385-0.199844-1.544998,0
c-0.234268,0.092577-0.446999,0.232318-0.625,0.41055c-0.179039,0.181376-0.320724,0.396147-0.417,0.63212
C118.527046,8.195745,118.476112,8.47679,118.480003,8.75976z M120.520004,8.97476h-0.496002v0.99053h-0.533997v-2.6001h0.794998
c0.172218-0.001235,0.344307,0.009652,0.514999,0.03258c0.134796,0.016075,0.265205,0.058123,0.384003,0.12382
c0.102715,0.05436,0.186691,0.138377,0.240997,0.24112c0.061989,0.127668,0.091194,0.268765,0.084999,0.41055
c0.000862,0.140271-0.03759,0.277979-0.111,0.39751c-0.086395,0.13219-0.211388,0.234577-0.358002,0.29325l0.598999,1.10132
h-0.593002L120.520004,8.97476z M120.976006,8.17976c0.014854-0.129661-0.053123-0.254731-0.169998-0.3128
c-0.137817-0.057799-0.286682-0.084499-0.435997-0.0782h-0.346001v0.77549h0.377998
c0.152237,0.012898,0.304764-0.02122,0.436996-0.09775c0.091606-0.065936,0.14325-0.174038,0.136993-0.28673L120.976006,8.17976z"/>
</svg>

After

Width:  |  Height:  |  Size: 8.9 KiB

View File

@@ -0,0 +1,88 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 25.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 124 30" style="enable-background:new 0 0 124 30;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;}
</style>
<path class="st0" d="M34.813099,6.555H41.3451c1.385273-0.070708,2.751808,0.344133,3.863998,1.173
c1.039825,0.899059,1.586941,2.241208,1.472,3.611c0.111458,1.37593-0.434399,2.723513-1.472,3.634
c-1.118271,0.814574-2.4823,1.220535-3.863998,1.15h-4.461998V23h-2.07L34.813099,6.555z M41.483101,14.283
c0.822922,0.06823,1.639957-0.18761,2.277-0.713c0.549107-0.607606,0.823647-1.41459,0.758999-2.231
c0.067909-0.809881-0.207489-1.611044-0.758999-2.208c-0.630104-0.539837-1.450134-0.804896-2.277-0.736h-4.599998v5.888
L41.483101,14.283z M51.1646,23.321999c-1.024158,0.052488-2.03162-0.275143-2.828999-0.92
c-0.688705-0.609777-1.068405-1.495747-1.035-2.414999c-0.036377-0.737299,0.174046-1.465687,0.598-2.07
c0.430389-0.544542,1.003754-0.958641,1.655998-1.195999c0.89489-0.325996,1.819183-0.564774,2.759998-0.712999
c0.58239-0.095267,1.158451-0.225842,1.724998-0.391c0.333458-0.086607,0.64114-0.252282,0.896999-0.483
c0.209099-0.241613,0.316288-0.554937,0.299-0.874c0-1.150001-0.812668-1.725001-2.438-1.725
c-0.591373-0.020433-1.182041,0.057286-1.748001,0.23c-0.385189,0.128752-0.706268,0.400434-0.896999,0.759
c-0.208683,0.431378-0.32613,0.901169-0.345001,1.38h-1.931999c0.013546-1.106599,0.488823-2.157211,1.311001-2.898
c1.053455-0.797718,2.362473-1.182241,3.68-1.081c1.069542-0.048652,2.123863,0.267645,2.990002,0.897
c0.835869,0.720024,1.272713,1.799289,1.173,2.898v5.635c-0.016964,0.358055,0.029758,0.716278,0.138,1.058001
c0.066074,0.196636,0.252625,0.327223,0.459999,0.322001l0.276001-0.023001V23c-0.293846,0.071281-0.594673,0.10985-0.896999,0.115
c-0.448277,0.029144-0.891262-0.110313-1.242001-0.391001c-0.327774-0.411825-0.506161-0.922657-0.506001-1.448999h-0.046001
c-0.384689,0.646172-0.942337,1.171953-1.610001,1.518C52.844852,23.164421,52.008141,23.345972,51.1646,23.321999z
M49.232601,19.894999c-0.036285,0.504335,0.148373,0.999548,0.506001,1.357c0.461899,0.345982,1.035038,0.509735,1.610001,0.459999
c0.998661,0.049717,1.98558-0.233416,2.806-0.805c0.737389-0.586956,1.141094-1.497444,1.081001-2.438v-1.862997
c-0.836132,0.516708-1.781067,0.831686-2.759998,0.92c-0.881981,0.135984-1.731636,0.432194-2.507,0.874001
C49.478649,18.735281,49.199535,19.302231,49.232601,19.894999z M60.820301,27.116999
c-0.408855-0.003748-0.816658-0.042219-1.219002-0.115V25.438c0.305218,0.039368,0.612324,0.062401,0.919998,0.069
c0.560413-0.003313,1.097176-0.226274,1.494999-0.621c0.540234-0.546392,0.901249-1.244356,1.035-2.000999L58.290298,11.27h2.07
l3.611,9.338001h0.045998l3.449997-9.338h1.977997l-4.737999,12.075c-0.363663,1.095852-0.959949,2.100128-1.748001,2.944
C62.381443,26.833206,61.613956,27.130299,60.820301,27.116999z M70.9216,6.555h1.839996V23H70.9216V6.555z M80.542297,23.345001
c-1.079521,0.026121-2.145523-0.244358-3.082001-0.782c-0.857864-0.520388-1.54528-1.279743-1.977997-2.184999
c-0.473877-1.014378-0.709877-2.123568-0.690002-3.243c-0.021645-1.112206,0.214523-2.214319,0.690002-3.22
c0.42762-0.914654,1.1157-1.682744,1.977997-2.208c0.936478-0.537642,2.00248-0.80812,3.082001-0.782
c1.06498-0.026295,2.116249,0.244486,3.036003,0.782c0.872543,0.519647,1.569481,1.28868,2.000999,2.208
c0.475479,1.005681,0.711647,2.107795,0.690002,3.22c0.019875,1.119432-0.216125,2.228622-0.690002,3.243
c-0.436684,0.909891-1.132935,1.670162-2.000999,2.184999C82.658546,23.100515,81.607277,23.371294,80.542297,23.345001z
M76.724297,17.135002c-0.046257,1.179615,0.292839,2.342216,0.966003,3.312c1.374138,1.568766,3.759834,1.72654,5.328598,0.3524
c0.125099-0.109579,0.242821-0.227301,0.352402-0.3524c0.673164-0.969784,1.01226-2.132385,0.966003-3.312
c0.048729-1.172768-0.290817-2.328851-0.966003-3.289c-1.337448-1.568765-3.693398-1.756284-5.262161-0.418835
c-0.15049,0.128296-0.290543,0.268349-0.418839,0.418835C77.015114,14.80615,76.675568,15.962234,76.724297,17.135002z
M91.527,23.322001c-1.024162,0.052523-2.031639-0.275112-2.829002-0.92c-0.688705-0.609776-1.068413-1.495747-1.035004-2.415001
c-0.03643-0.737303,0.174004-1.465708,0.598-2.07c0.430344-0.544586,1.003731-0.958694,1.655998-1.195999
c0.894882-0.326023,1.819183-0.564798,2.760002-0.712999c0.582382-0.09529,1.158447-0.225863,1.724998-0.391
c0.333443-0.086654,0.641113-0.252322,0.897003-0.483c0.209061-0.241635,0.316246-0.554943,0.299004-0.874
c0-1.150001-0.812668-1.725001-2.438004-1.725c-0.59137-0.020441-1.182045,0.057279-1.748001,0.23
c-0.385193,0.128751-0.706268,0.400434-0.897003,0.759c-0.208679,0.431378-0.326126,0.901169-0.345001,1.38h-1.931992
c0.013458-1.106618,0.488747-2.157265,1.310997-2.898c1.053444-0.797748,2.362473-1.182275,3.68-1.081
c1.069534-0.048619,2.123848,0.267673,2.989998,0.897c0.835869,0.720024,1.272713,1.799289,1.172997,2.898v5.635
c-0.016968,0.358055,0.029762,0.716278,0.138,1.058001c0.066048,0.196655,0.252617,0.327251,0.459999,0.322001l0.276001-0.023001V23
c-0.293846,0.07127-0.594681,0.109838-0.897003,0.115c-0.44828,0.029177-0.891273-0.110285-1.241997-0.391001
c-0.32782-0.411802-0.50621-0.922649-0.505997-1.448999h-0.045998c-0.384743,0.646128-0.942375,1.171896-1.610001,1.518
C93.207245,23.1644,92.370537,23.345951,91.527,23.322001z M89.595001,19.895c-0.036331,0.504341,0.148331,0.999575,0.505997,1.357
c0.461891,0.345993,1.035042,0.509748,1.610001,0.459999c0.998657,0.049686,1.985565-0.233444,2.806-0.805
c0.73735-0.586985,1.141045-1.497452,1.081001-2.438v-1.862999c-0.836136,0.516708-1.781067,0.831686-2.760002,0.92
c-0.881989,0.135948-1.731651,0.432159-2.507004,0.874001C89.841019,18.73526,89.561897,19.302227,89.595001,19.895z
M104.834999,23.322001c-0.947945,0.009354-1.879768-0.245502-2.691002-0.736c-0.8218-0.53285-1.482002-1.280552-1.908997-2.162001
c-0.480354-1.028526-0.716568-2.154137-0.690201-3.289c-0.026367-1.134863,0.209846-2.260472,0.690201-3.289
c0.423233-0.877855,1.084732-1.61905,1.908997-2.139c0.806282-0.504401,1.739983-0.767752,2.691002-0.759
c1.464203-0.073218,2.860054,0.624707,3.68,1.84h0.045998V6.555h1.839996V23h-1.839996v-1.448999h-0.045998
C107.654121,22.710478,106.278236,23.372623,104.834999,23.322001z M101.476997,17.135
c-0.005173,0.768642,0.127243,1.532007,0.390999,2.254c0.23764,0.658501,0.651306,1.239225,1.195999,1.679001
c0.556534,0.43626,1.248146,0.664085,1.955002,0.643999c1.011169,0.03722,1.975395-0.427675,2.575996-1.242001
c0.683739-0.973391,1.023643-2.146864,0.966003-3.335001c0.060226-1.181292-0.280151-2.348316-0.966003-3.312
c-1.090065-1.404866-3.103142-1.680769-4.530998-0.621c-0.548683,0.445539-0.962761,1.034796-1.195999,1.702
C101.602959,15.617669,101.470459,16.373734,101.476997,17.135z M4.67358,7.05762L14.7263,13.5488v12.336999l7.560599-4.7103
V8.83849L12.2462,2.33875L4.67358,7.05762z M11.4765,25.201799v-9.627l-7.5766,4.7189L11.4765,25.201799z M117.998001,8.75976
c-0.004463-0.341642,0.059807-0.680696,0.189003-0.997c0.120857-0.290308,0.29792-0.553858,0.521004-0.77549
c0.22216-0.217432,0.485695-0.388062,0.775002-0.50178c0.304459-0.122437,0.629852-0.184414,0.958-0.18247
c0.326248-0.002423,0.649773,0.059586,0.952003,0.18247c0.585327,0.233346,1.050858,0.694288,1.290001,1.27727
c0.261337,0.636756,0.261337,1.350824,0,1.98758c-0.120064,0.290741-0.297226,0.554445-0.521004,0.77551
c-0.220543,0.214946-0.482063,0.383349-0.768997,0.4952c-0.303246,0.118704-0.626358,0.178439-0.952003,0.176
c-0.327545,0.001974-0.652542-0.057732-0.958-0.176c-0.288139-0.112983-0.551407-0.281204-0.775002-0.4952
c-0.221207-0.223182-0.39801-0.486349-0.521004-0.77551C118.058205,9.436166,117.993927,9.099288,117.998001,8.75976z
M118.480003,8.75976c-0.00351,0.28082,0.047432,0.559659,0.150002,0.8211c0.098404,0.234851,0.239838,0.449244,0.417,0.63214
c0.179451,0.174817,0.391914,0.312154,0.625,0.404c0.497253,0.191203,1.047745,0.191203,1.544998,0
c0.473839-0.185051,0.847771-0.561208,1.029999-1.03614c0.198669-0.531618,0.198669-1.117102,0-1.64872
c-0.184547-0.474923-0.557365-0.852327-1.029999-1.04267c-0.495613-0.199844-1.049385-0.199844-1.544998,0
c-0.234268,0.092577-0.446999,0.232318-0.625,0.41055c-0.179039,0.181376-0.320724,0.396147-0.417,0.63212
C118.527046,8.195745,118.476112,8.47679,118.480003,8.75976z M120.520004,8.97476h-0.496002v0.99053h-0.533997v-2.6001h0.794998
c0.172218-0.001235,0.344307,0.009652,0.514999,0.03258c0.134796,0.016075,0.265205,0.058123,0.384003,0.12382
c0.102715,0.05436,0.186691,0.138377,0.240997,0.24112c0.061989,0.127668,0.091194,0.268765,0.084999,0.41055
c0.000862,0.140271-0.03759,0.277979-0.111,0.39751c-0.086395,0.13219-0.211388,0.234577-0.358002,0.29325l0.598999,1.10132
h-0.593002L120.520004,8.97476z M120.976006,8.17976c0.014854-0.129661-0.053123-0.254731-0.169998-0.3128
c-0.137817-0.057799-0.286682-0.084499-0.435997-0.0782h-0.346001v0.77549h0.377998
c0.152237,0.012898,0.304764-0.02122,0.436996-0.09775c0.091606-0.065936,0.14325-0.174038,0.136993-0.28673L120.976006,8.17976z"/>
</svg>

After

Width:  |  Height:  |  Size: 8.9 KiB

View File

@@ -31,12 +31,18 @@ const Autosave: React.FC<Props> = ({ collection, global, id, publishedDocUpdated
const [lastSaved, setLastSaved] = useState<number>();
const debouncedFields = useDebounce(fields, interval);
const fieldRef = useRef(fields);
const modifiedRef = useRef(modified);
// Store fields in ref so the autosave func
// can always retrieve the most to date copies
// after the timeout has executed
fieldRef.current = fields;
// Store modified in ref so the autosave func
// can bail out if modified becomes false while
// timing out during autosave
modifiedRef.current = modified;
const createCollectionDoc = useCallback(async () => {
const res = await fetch(`${serverURL}${api}/${collection.slug}?locale=${locale}&fallback-locale=null&depth=0&draft=true`, {
method: 'POST',
@@ -95,6 +101,7 @@ const Autosave: React.FC<Props> = ({ collection, global, id, publishedDocUpdated
};
setTimeout(async () => {
if (modifiedRef.current) {
const res = await fetch(url, {
method,
credentials: 'include',
@@ -105,19 +112,20 @@ const Autosave: React.FC<Props> = ({ collection, global, id, publishedDocUpdated
body: JSON.stringify(body),
});
setSaving(false);
if (res.status === 200) {
setLastSaved(new Date().getTime());
getVersions();
}
}
setSaving(false);
}, 1000);
}
}
};
autosave();
}, [i18n, debouncedFields, modified, serverURL, api, collection, global, id, getVersions, locale]);
}, [i18n, debouncedFields, modified, serverURL, api, collection, global, id, getVersions, locale, modifiedRef]);
useEffect(() => {
if (versions?.docs?.[0]) {

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useEffect, useId, useState } from 'react';
import { useTranslation } from 'react-i18next';
import flattenTopLevelFields from '../../../../utilities/flattenTopLevelFields';
import Pill from '../Pill';
@@ -6,7 +6,7 @@ import Plus from '../../icons/Plus';
import X from '../../icons/X';
import { Props } from './types';
import { getTranslation } from '../../../../utilities/getTranslation';
import { useEditDepth } from '../../utilities/EditDepth';
import './index.scss';
const baseClass = 'column-selector';
@@ -18,8 +18,15 @@ const ColumnSelector: React.FC<Props> = (props) => {
setColumns,
} = props;
const [fields] = useState(() => flattenTopLevelFields(collection.fields, true));
const [fields, setFields] = useState(() => flattenTopLevelFields(collection.fields, true));
useEffect(() => {
setFields(flattenTopLevelFields(collection.fields, true));
}, [collection.fields]);
const { i18n } = useTranslation();
const uuid = useId();
const editDepth = useEditDepth();
return (
<div className={baseClass}>
@@ -38,7 +45,7 @@ const ColumnSelector: React.FC<Props> = (props) => {
setColumns(newState);
}}
alignIcon="left"
key={field.name || i}
key={`${field.name || i}${editDepth ? `-${editDepth}-` : ''}${uuid}`}
icon={isEnabled ? <X /> : <Plus />}
className={[
`${baseClass}__column`,

View File

@@ -44,7 +44,7 @@ const DeleteDocument: React.FC<Props> = (props) => {
const modalSlug = `delete-${id}`;
const addDefaultError = useCallback(() => {
toast.error(t('error:deletingError', { title }));
toast.error(t('error:deletingTitle', { title }));
}, [t, title]);
const handleDelete = useCallback(() => {

View File

@@ -0,0 +1,140 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useModal } from '@faceless-ui/modal';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import { DocumentDrawerProps } from './types';
import DefaultEdit from '../../views/collections/Edit/Default';
import X from '../../icons/X';
import { Fields } from '../../forms/Form/types';
import buildStateFromSchema from '../../forms/Form/buildStateFromSchema';
import { getTranslation } from '../../../../utilities/getTranslation';
import Button from '../Button';
import { useConfig } from '../../utilities/Config';
import { useLocale } from '../../utilities/Locale';
import { useAuth } from '../../utilities/Auth';
import { DocumentInfoProvider } from '../../utilities/DocumentInfo';
import RenderCustomComponent from '../../utilities/RenderCustomComponent';
import usePayloadAPI from '../../../hooks/usePayloadAPI';
import formatFields from '../../views/collections/Edit/formatFields';
import { useRelatedCollections } from '../../forms/field-types/Relationship/AddNew/useRelatedCollections';
import IDLabel from '../IDLabel';
import { baseClass } from '.';
export const DocumentDrawerContent: React.FC<DocumentDrawerProps> = ({
collectionSlug,
id,
drawerSlug,
onSave: onSaveFromProps,
customHeader,
}) => {
const { serverURL, routes: { api } } = useConfig();
const { toggleModal, modalState, closeModal } = useModal();
const locale = useLocale();
const { permissions, user } = useAuth();
const [initialState, setInitialState] = useState<Fields>();
const { t, i18n } = useTranslation(['fields', 'general']);
const hasInitializedState = useRef(false);
const [isOpen, setIsOpen] = useState(false);
const [collectionConfig] = useRelatedCollections(collectionSlug);
const [fields, setFields] = useState(() => formatFields(collectionConfig, true));
useEffect(() => {
setFields(formatFields(collectionConfig, true));
}, [collectionSlug, collectionConfig]);
const [{ data, isLoading: isLoadingDocument, isError }] = usePayloadAPI(
(id ? `${serverURL}${api}/${collectionSlug}/${id}` : null),
{ initialParams: { 'fallback-locale': 'null', depth: 0, draft: 'true' } },
);
useEffect(() => {
if (isLoadingDocument) {
return;
}
const awaitInitialState = async () => {
const state = await buildStateFromSchema({
fieldSchema: fields,
data,
user,
operation: id ? 'update' : 'create',
id,
locale,
t,
});
setInitialState(state);
};
awaitInitialState();
hasInitializedState.current = true;
}, [data, fields, id, user, locale, isLoadingDocument, t]);
useEffect(() => {
setIsOpen(Boolean(modalState[drawerSlug]?.isOpen));
}, [modalState, drawerSlug]);
useEffect(() => {
if (isOpen && !isLoadingDocument && isError) {
closeModal(drawerSlug);
toast.error(data.errors?.[0].message || t('error:unspecific'));
}
}, [isError, t, isOpen, data, drawerSlug, closeModal, isLoadingDocument]);
const onSave = useCallback<DocumentDrawerProps['onSave']>((args) => {
if (typeof onSaveFromProps === 'function') {
onSaveFromProps({
...args,
collectionConfig,
});
}
}, [collectionConfig, onSaveFromProps]);
if (isError) return null;
return (
<DocumentInfoProvider collection={collectionConfig}>
<RenderCustomComponent
DefaultComponent={DefaultEdit}
CustomComponent={collectionConfig.admin?.components?.views?.Edit}
componentProps={{
isLoading: !initialState,
data,
id,
collection: collectionConfig,
permissions: permissions.collections[collectionConfig.slug],
isEditing: Boolean(id),
apiURL: id ? `${serverURL}${api}/${collectionSlug}/${id}` : null,
onSave,
initialState,
hasSavePermission: true,
action: `${serverURL}${api}/${collectionSlug}${id ? `/${id}` : ''}?locale=${locale}&depth=0&fallback-locale=null`,
disableEyebrow: true,
disableActions: true,
me: true,
disableLeaveWithoutSaving: true,
customHeader: (
<div className={`${baseClass}__header`}>
<div className={`${baseClass}__header-content`}>
<h2 className={`${baseClass}__header-text`}>
{!customHeader ? t(!id ? 'fields:addNewLabel' : 'general:editLabel', { label: getTranslation(collectionConfig.labels.singular, i18n) }) : customHeader}
</h2>
<Button
buttonStyle="none"
className={`${baseClass}__header-close`}
onClick={() => toggleModal(drawerSlug)}
aria-label={t('general:close')}
>
<X />
</Button>
</div>
{id && (
<IDLabel id={id} />
)}
</div>
),
}}
/>
</DocumentInfoProvider>
);
};

View File

@@ -2,34 +2,60 @@
.doc-drawer {
&__header {
margin-top: base(2.5);
margin-bottom: base(1);
@include mid-break {
margin-top: base(1.5);
}
}
&__header-content {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-top: base(2.5);
margin-bottom: base(1);
}
&__header-text {
margin: 0;
}
&__toggler {
background: transparent;
border: 0;
padding: 0;
cursor: pointer;
&:focus,
&:focus-within {
outline: none;
}
&:disabled {
pointer-events: none;
}
}
&__header-close {
border: 0;
background-color: transparent;
padding: 0;
cursor: pointer;
overflow: hidden;
width: base(1);
height: base(1);
svg {
width: base(2.5);
height: base(2.5);
width: base(2.75);
height: base(2.75);
position: relative;
top: base(-.5);
right: base(-.75);
left: base(-.825);
top: base(-.825);
.stroke {
stroke-width: .5px;
}
}
}
@include mid-break {
&__header-close {
svg {
top: base(-.75);
stroke-width: 2px;
vector-effect: non-scaling-stroke;
}
}
}

View File

@@ -1,28 +1,16 @@
import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
import React, { useCallback, useEffect, useId, useMemo, useState } from 'react';
import { useModal } from '@faceless-ui/modal';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import { DocumentDrawerProps, DocumentTogglerProps, UseDocumentDrawer } from './types';
import DefaultEdit from '../../views/collections/Edit/Default';
import X from '../../icons/X';
import { Fields } from '../../forms/Form/types';
import buildStateFromSchema from '../../forms/Form/buildStateFromSchema';
import { getTranslation } from '../../../../utilities/getTranslation';
import { Drawer, DrawerToggler } from '../Drawer';
import Button from '../Button';
import { useConfig } from '../../utilities/Config';
import { useLocale } from '../../utilities/Locale';
import { useAuth } from '../../utilities/Auth';
import { DocumentInfoProvider } from '../../utilities/DocumentInfo';
import RenderCustomComponent from '../../utilities/RenderCustomComponent';
import usePayloadAPI from '../../../hooks/usePayloadAPI';
import formatFields from '../../views/collections/Edit/formatFields';
import { useRelatedCollections } from '../../forms/field-types/Relationship/AddNew/useRelatedCollections';
import IDLabel from '../IDLabel';
import { useEditDepth } from '../../utilities/EditDepth';
import { DocumentDrawerContent } from './DrawerContent';
import './index.scss';
const baseClass = 'doc-drawer';
export const baseClass = 'doc-drawer';
const formatDocumentDrawerSlug = ({
collectionSlug,
@@ -42,6 +30,7 @@ export const DocumentDrawerToggler: React.FC<DocumentTogglerProps> = ({
drawerSlug,
id,
collectionSlug,
disabled,
...rest
}) => {
const { t, i18n } = useTranslation(['fields', 'general']);
@@ -51,7 +40,11 @@ export const DocumentDrawerToggler: React.FC<DocumentTogglerProps> = ({
<DrawerToggler
slug={drawerSlug}
formatSlug={false}
className={className}
className={[
className,
`${baseClass}__toggler`,
].filter(Boolean).join(' ')}
disabled={disabled}
aria-label={t(!id ? 'fields:addNewLabel' : 'general:editLabel', { label: getTranslation(collectionConfig.labels.singular, i18n) })}
{...rest}
>
@@ -60,131 +53,24 @@ export const DocumentDrawerToggler: React.FC<DocumentTogglerProps> = ({
);
};
export const DocumentDrawer: React.FC<DocumentDrawerProps> = ({
collectionSlug,
id,
drawerSlug,
onSave,
customHeader,
}) => {
const { serverURL, routes: { api } } = useConfig();
const { toggleModal, modalState, closeModal } = useModal();
const locale = useLocale();
const { permissions, user } = useAuth();
const [initialState, setInitialState] = useState<Fields>();
const { t, i18n } = useTranslation(['fields', 'general']);
const hasInitializedState = useRef(false);
const [isOpen, setIsOpen] = useState(false);
const [collectionConfig] = useRelatedCollections(collectionSlug);
export const DocumentDrawer: React.FC<DocumentDrawerProps> = (props) => {
const { drawerSlug } = props;
const [fields, setFields] = useState(() => formatFields(collectionConfig, true));
useEffect(() => {
setFields(formatFields(collectionConfig, true));
}, [collectionSlug, collectionConfig]);
const [{ data, isLoading: isLoadingDocument, isError }] = usePayloadAPI(
(id ? `${serverURL}${api}/${collectionSlug}/${id}` : null),
{ initialParams: { 'fallback-locale': 'null', depth: 0, draft: 'true' } },
);
useEffect(() => {
if (isLoadingDocument || hasInitializedState.current === true) {
return;
}
const awaitInitialState = async () => {
const state = await buildStateFromSchema({
fieldSchema: fields,
data,
user,
operation: id ? 'update' : 'create',
id,
locale,
t,
});
setInitialState(state);
};
awaitInitialState();
hasInitializedState.current = true;
}, [data, fields, id, user, locale, isLoadingDocument, t]);
useEffect(() => {
setIsOpen(Boolean(modalState[drawerSlug]?.isOpen));
}, [modalState, drawerSlug]);
useEffect(() => {
if (isOpen && !isLoadingDocument && isError) {
closeModal(drawerSlug);
toast.error(data.errors?.[0].message || t('error:unspecific'));
}
}, [isError, t, isOpen, data, drawerSlug, closeModal, isLoadingDocument]);
if (isError) return null;
if (isOpen) {
// IMPORTANT: we must ensure that modals are not recursively rendered
// to do this, do not render the drawer until it is open
return (
<Drawer
slug={drawerSlug}
formatSlug={false}
className={baseClass}
>
<DocumentInfoProvider collection={collectionConfig}>
<RenderCustomComponent
DefaultComponent={DefaultEdit}
CustomComponent={collectionConfig.admin?.components?.views?.Edit}
componentProps={{
isLoading: !initialState,
data,
id,
collection: collectionConfig,
permissions: permissions.collections[collectionConfig.slug],
isEditing: Boolean(id),
apiURL: id ? `${serverURL}${api}/${collectionSlug}/${id}` : null,
onSave,
initialState,
hasSavePermission: true,
action: `${serverURL}${api}/${collectionSlug}${id ? `/${id}` : ''}?locale=${locale}&depth=0&fallback-locale=null`,
disableEyebrow: true,
disableActions: true,
me: true,
disableLeaveWithoutSaving: true,
customHeader: (
<div className={`${baseClass}__header`}>
<div className={`${baseClass}__header-content`}>
<h2>
{!customHeader ? t(!id ? 'fields:addNewLabel' : 'general:editLabel', { label: getTranslation(collectionConfig.labels.singular, i18n) }) : customHeader}
</h2>
<Button
buttonStyle="none"
className={`${baseClass}__header-close`}
onClick={() => toggleModal(drawerSlug)}
aria-label={t('general:close')}
>
<X />
</Button>
</div>
{id && (
<IDLabel id={id} />
)}
</div>
),
}}
/>
</DocumentInfoProvider>
<DocumentDrawerContent {...props} />
</Drawer>
);
}
return null;
};
export const useDocumentDrawer: UseDocumentDrawer = ({ id, collectionSlug }) => {
const drawerDepth = useEditDepth();
const uuid = useId();
const { modalState, toggleModal } = useModal();
const { modalState, toggleModal, closeModal, openModal } = useModal();
const [isOpen, setIsOpen] = useState(false);
const drawerSlug = formatDocumentDrawerSlug({
collectionSlug,
@@ -201,6 +87,14 @@ export const useDocumentDrawer: UseDocumentDrawer = ({ id, collectionSlug }) =>
toggleModal(drawerSlug);
}, [toggleModal, drawerSlug]);
const closeDrawer = useCallback(() => {
closeModal(drawerSlug);
}, [closeModal, drawerSlug]);
const openDrawer = useCallback(() => {
openModal(drawerSlug);
}, [openModal, drawerSlug]);
const MemoizedDrawer = useMemo(() => {
return ((props) => (
<DocumentDrawer
@@ -229,7 +123,9 @@ export const useDocumentDrawer: UseDocumentDrawer = ({ id, collectionSlug }) =>
drawerDepth,
isDrawerOpen: isOpen,
toggleDrawer,
}), [drawerDepth, drawerSlug, isOpen, toggleDrawer]);
closeDrawer,
openDrawer,
}), [drawerDepth, drawerSlug, isOpen, toggleDrawer, closeDrawer, openDrawer]);
return [
MemoizedDrawer,

View File

@@ -1,9 +1,14 @@
import React, { HTMLAttributes } from 'react';
import { SanitizedCollectionConfig } from '../../../../collections/config/types';
export type DocumentDrawerProps = {
collectionSlug: string
id?: string
onSave?: (json: Record<string, unknown>) => void
onSave?: (json: {
doc: Record<string, any>
message: string
collectionConfig: SanitizedCollectionConfig
}) => void
customHeader?: React.ReactNode
drawerSlug?: string
}
@@ -14,6 +19,7 @@ export type DocumentTogglerProps = HTMLAttributes<HTMLButtonElement> & {
drawerSlug?: string
id?: string
collectionSlug: string
disabled?: boolean
}
export type UseDocumentDrawer = (args: {
@@ -27,5 +33,7 @@ export type UseDocumentDrawer = (args: {
drawerDepth: number
isDrawerOpen: boolean
toggleDrawer: () => void
closeDrawer: () => void
openDrawer: () => void
}
]

View File

@@ -26,6 +26,7 @@
z-index: 2;
width: 100%;
transition: all 300ms ease-out;
overflow: hidden;
}
&__content-children {

View File

@@ -10,13 +10,13 @@ const baseClass = 'drawer';
const zBase = 100;
const formatDrawerSlug = ({
export const formatDrawerSlug = ({
slug,
depth,
}: {
slug: string,
depth: number,
}) => `drawer_${depth}_${slug}`;
}): string => `drawer_${depth}_${slug}`;
export const DrawerToggler: React.FC<TogglerProps> = ({
slug,
@@ -24,6 +24,7 @@ export const DrawerToggler: React.FC<TogglerProps> = ({
children,
className,
onClick,
disabled,
...rest
}) => {
const { openModal } = useModal();
@@ -39,6 +40,7 @@ export const DrawerToggler: React.FC<TogglerProps> = ({
onClick={handleClick}
type="button"
className={className}
disabled={disabled}
{...rest}
>
{children}
@@ -57,19 +59,27 @@ export const Drawer: React.FC<Props> = ({
const { breakpoints: { m: midBreak } } = useWindowInfo();
const drawerDepth = useEditDepth();
const [isOpen, setIsOpen] = useState(false);
const [animateIn, setAnimateIn] = useState(false);
const [modalSlug] = useState(() => (formatSlug !== false ? formatDrawerSlug({ slug, depth: drawerDepth }) : slug));
useEffect(() => {
setIsOpen(modalState[modalSlug].isOpen);
setIsOpen(modalState[modalSlug]?.isOpen);
}, [modalSlug, modalState]);
useEffect(() => {
setAnimateIn(isOpen);
}, [isOpen]);
if (isOpen) {
// IMPORTANT: do not render the drawer until it is explicitly open, this is to avoid large html trees especially when nesting drawers
return (
<Modal
slug={modalSlug}
className={[
className,
baseClass,
isOpen && `${baseClass}--is-open`,
animateIn && `${baseClass}--is-open`,
].filter(Boolean).join(' ')}
style={{
zIndex: zBase + drawerDepth,
@@ -97,4 +107,7 @@ export const Drawer: React.FC<Props> = ({
</div>
</Modal>
);
}
return null;
};

View File

@@ -12,4 +12,5 @@ export type TogglerProps = HTMLAttributes<HTMLButtonElement> & {
formatSlug?: boolean
children: React.ReactNode
className?: string
disabled?: boolean
}

View File

@@ -47,6 +47,9 @@ const Duplicate: React.FC<Props> = ({ slug, collection, id }) => {
});
let data = await response.json();
if ('createdAt' in data) delete data.createdAt;
if ('updatedAt' in data) delete data.updatedAt;
if (typeof collection.admin.hooks?.beforeDuplicate === 'function') {
data = await collection.admin.hooks.beforeDuplicate({
data,

View File

@@ -23,7 +23,11 @@
display: flex;
margin-left: - base(.5);
margin-right: - base(.5);
width: calc(100% + #{$baseline});
width: calc(100% + #{base(1)});
.btn {
margin: 0 base(.5);
}
}
&__toggle-columns,
@@ -31,10 +35,6 @@
&__toggle-sort {
min-width: 140px;
&.btn {
margin: 0 base(.5);
}
&.btn--style-primary {
svg {
transform: rotate(180deg);
@@ -52,6 +52,16 @@
&__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 {

View File

@@ -0,0 +1,310 @@
import React, { Fragment, useCallback, useEffect, useReducer, useState } from 'react';
import { useModal } from '@faceless-ui/modal';
import { useTranslation } from 'react-i18next';
import { ListDrawerProps } from './types';
import { getTranslation } from '../../../../utilities/getTranslation';
import { useConfig } from '../../utilities/Config';
import { useAuth } from '../../utilities/Auth';
import { DocumentInfoProvider } from '../../utilities/DocumentInfo';
import RenderCustomComponent from '../../utilities/RenderCustomComponent';
import usePayloadAPI from '../../../hooks/usePayloadAPI';
import { SanitizedCollectionConfig } from '../../../../collections/config/types';
import DefaultList from '../../views/collections/List/Default';
import Label from '../../forms/Label';
import ReactSelect from '../ReactSelect';
import { useDocumentDrawer } from '../DocumentDrawer';
import Pill from '../Pill';
import X from '../../icons/X';
import ViewDescription from '../ViewDescription';
import { Column } from '../Table/types';
import getInitialColumnState from '../../views/collections/List/getInitialColumns';
import buildListColumns from '../../views/collections/List/buildColumns';
import formatFields from '../../views/collections/List/formatFields';
import { ListPreferences } from '../../views/collections/List/types';
import { usePreferences } from '../../utilities/Preferences';
import { Field } from '../../../../fields/config/types';
import { baseClass } from '.';
const buildColumns = ({
collectionConfig,
columns,
onSelect,
t,
}) => buildListColumns({
collection: collectionConfig,
columns,
t,
cellProps: [{
link: false,
onClick: ({ collection, rowData }) => {
if (typeof onSelect === 'function') {
onSelect({
docID: rowData.id,
collectionConfig: collection,
});
}
},
className: `${baseClass}__first-cell`,
}],
});
const shouldIncludeCollection = ({
coll: {
admin: { enableRichTextRelationship },
upload,
slug,
},
uploads,
collectionSlugs,
}) => (enableRichTextRelationship && ((uploads && Boolean(upload)) || collectionSlugs?.includes(slug)));
export const ListDrawerContent: React.FC<ListDrawerProps> = ({
drawerSlug,
onSelect,
customHeader,
collectionSlugs,
uploads,
selectedCollection,
}) => {
const { t, i18n } = useTranslation(['upload', 'general']);
const { permissions } = useAuth();
const { getPreference, setPreference } = usePreferences();
const { isModalOpen, closeModal } = useModal();
const [limit, setLimit] = useState<number>();
const [sort, setSort] = useState(null);
const [page, setPage] = useState(1);
const [where, setWhere] = useState(null);
const { serverURL, routes: { api }, collections } = useConfig();
const [enabledCollectionConfigs] = useState(() => collections.filter((coll) => shouldIncludeCollection({ coll, uploads, collectionSlugs })));
const [selectedCollectionConfig, setSelectedCollectionConfig] = useState<SanitizedCollectionConfig>(() => {
let initialSelection: SanitizedCollectionConfig;
if (selectedCollection) {
// if passed a selection, find it and check if it's enabled
const foundSelection = collections.find(({ slug }) => slug === selectedCollection);
if (foundSelection && shouldIncludeCollection({ coll: foundSelection, uploads, collectionSlugs })) {
initialSelection = foundSelection;
}
} else {
// return the first one that is enabled
initialSelection = collections.find((coll) => shouldIncludeCollection({ coll, uploads, collectionSlugs }));
}
return initialSelection;
});
const [selectedOption, setSelectedOption] = useState<{ label: string, value: string }>(() => (selectedCollectionConfig ? { label: getTranslation(selectedCollectionConfig.labels.singular, i18n), value: selectedCollectionConfig.slug } : undefined));
const [fields, setFields] = useState<Field[]>(() => formatFields(selectedCollectionConfig, t));
const [tableColumns, setTableColumns] = useState<Column[]>(() => {
const initialColumns = getInitialColumnState(fields, selectedCollectionConfig.admin.useAsTitle, selectedCollectionConfig.admin.defaultColumns);
return buildColumns({
collectionConfig: selectedCollectionConfig,
columns: initialColumns,
t,
onSelect,
});
});
// allow external control of selected collection, same as the initial state logic above
useEffect(() => {
let newSelection: SanitizedCollectionConfig;
if (selectedCollection) {
// if passed a selection, find it and check if it's enabled
const foundSelection = collections.find(({ slug }) => slug === selectedCollection);
if (foundSelection && shouldIncludeCollection({ coll: foundSelection, uploads, collectionSlugs })) {
newSelection = foundSelection;
}
} else {
// return the first one that is enabled
newSelection = collections.find((coll) => shouldIncludeCollection({ coll, uploads, collectionSlugs }));
}
setSelectedCollectionConfig(newSelection);
}, [selectedCollection, collectionSlugs, uploads, collections, onSelect, t]);
const activeColumnNames = tableColumns.map(({ accessor }) => accessor);
const stringifiedActiveColumns = JSON.stringify(activeColumnNames);
const preferenceKey = `${selectedCollectionConfig.slug}-list`;
// this is the 'create new' drawer
const [
DocumentDrawer,
DocumentDrawerToggler,
{
drawerSlug: documentDrawerSlug,
},
] = useDocumentDrawer({
collectionSlug: selectedCollectionConfig.slug,
});
useEffect(() => {
if (selectedOption) {
setSelectedCollectionConfig(collections.find(({ slug }) => selectedOption.value === slug));
}
}, [selectedOption, collections]);
const collectionPermissions = permissions?.collections?.[selectedCollectionConfig?.slug];
const hasCreatePermission = collectionPermissions?.create?.permission;
// If modal is open, get active page of upload gallery
const isOpen = isModalOpen(drawerSlug);
const apiURL = isOpen ? `${serverURL}${api}/${selectedCollectionConfig.slug}` : null;
const [cacheBust, dispatchCacheBust] = useReducer((state) => state + 1, 0); // used to force a re-fetch even when apiURL is unchanged
const [{ data, isError }, { setParams }] = usePayloadAPI(apiURL, {});
const moreThanOneAvailableCollection = enabledCollectionConfigs.length > 1;
useEffect(() => {
const params: {
page?: number
sort?: string
where?: unknown
limit?: number
cacheBust?: number
} = {};
if (page) params.page = page;
if (where) params.where = where;
if (sort) params.sort = sort;
if (limit) params.limit = limit;
if (cacheBust) params.cacheBust = cacheBust;
setParams(params);
}, [setParams, page, sort, where, limit, cacheBust]);
useEffect(() => {
const syncColumnsFromPrefs = async () => {
const currentPreferences = await getPreference<ListPreferences>(preferenceKey);
const newFields = formatFields(selectedCollectionConfig, t);
setFields(newFields);
const initialColumns = getInitialColumnState(newFields, selectedCollectionConfig.admin.useAsTitle, selectedCollectionConfig.admin.defaultColumns);
setTableColumns(buildColumns({
collectionConfig: selectedCollectionConfig,
columns: currentPreferences?.columns || initialColumns,
t,
onSelect,
}));
};
syncColumnsFromPrefs();
}, [t, getPreference, preferenceKey, onSelect, selectedCollectionConfig]);
useEffect(() => {
const newPreferences = {
limit,
sort,
columns: JSON.parse(stringifiedActiveColumns),
};
setPreference(preferenceKey, newPreferences);
}, [sort, limit, stringifiedActiveColumns, setPreference, preferenceKey]);
const setActiveColumns = useCallback((columns: string[]) => {
setTableColumns(buildColumns({
collectionConfig: selectedCollectionConfig,
columns,
t,
onSelect,
}));
}, [selectedCollectionConfig, t, onSelect]);
const onCreateNew = useCallback(({ doc }) => {
if (typeof onSelect === 'function') {
onSelect({
docID: doc.id,
collectionConfig: selectedCollectionConfig,
});
}
dispatchCacheBust();
closeModal(documentDrawerSlug);
closeModal(drawerSlug);
}, [closeModal, documentDrawerSlug, drawerSlug, onSelect, selectedCollectionConfig]);
if (!selectedCollectionConfig || isError) {
return null;
}
return (
<Fragment>
<DocumentInfoProvider collection={selectedCollectionConfig}>
<RenderCustomComponent
DefaultComponent={DefaultList}
CustomComponent={selectedCollectionConfig?.admin?.components?.views?.List}
componentProps={{
collection: {
...selectedCollectionConfig,
fields,
},
customHeader: (
<header className={`${baseClass}__header`}>
<div className={`${baseClass}__header-wrap`}>
<div className={`${baseClass}__header-content`}>
<h2 className={`${baseClass}__header-text`}>
{!customHeader ? getTranslation(selectedCollectionConfig?.labels?.plural, i18n) : customHeader}
</h2>
{hasCreatePermission && (
<DocumentDrawerToggler
className={`${baseClass}__create-new-button`}
>
<Pill>
{t('general:createNew')}
</Pill>
</DocumentDrawerToggler>
)}
</div>
<button
type="button"
onClick={() => {
closeModal(drawerSlug);
}}
className={`${baseClass}__header-close`}
>
<X />
</button>
</div>
{selectedCollectionConfig?.admin?.description && (
<div className={`${baseClass}__sub-header`}>
<ViewDescription description={selectedCollectionConfig.admin.description} />
</div>
)}
{moreThanOneAvailableCollection && (
<div className={`${baseClass}__select-collection-wrap`}>
<Label label={t('selectCollectionToBrowse')} />
<ReactSelect
className={`${baseClass}__select-collection`}
value={selectedOption}
onChange={setSelectedOption} // this is only changing the options which is not rerunning my effect
options={enabledCollectionConfigs.map((coll) => ({ label: getTranslation(coll.labels.singular, i18n), value: coll.slug }))}
/>
</div>
)}
</header>
),
data,
limit: limit || selectedCollectionConfig?.admin?.pagination?.defaultLimit,
setLimit,
tableColumns,
setColumns: setActiveColumns,
setSort,
newDocumentURL: null,
hasCreatePermission,
columnNames: activeColumnNames,
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,
handlePerPageChange: setLimit,
}}
/>
</DocumentInfoProvider>
<DocumentDrawer onSave={onCreateNew} />
</Fragment>
);
};

View File

@@ -0,0 +1,97 @@
@import '../../../scss/styles.scss';
.list-drawer {
&__header {
margin-top: base(2.5);
width: 100%;
@include mid-break {
margin-top: base(1.5);
}
}
&__header-wrap {
display: flex;
}
&__header-content {
display: flex;
flex-wrap: wrap;
flex-grow: 1;
align-items: flex-start;
.pill {
pointer-events: none;
margin: 0;
margin-top: base(0.25);
margin-left: base(0.5);
@include mid-break {
margin-top: 0;
}
}
}
&__header-text {
margin: 0;
}
&__toggler {
background: transparent;
border: 0;
padding: 0;
cursor: pointer;
&:focus,
&:focus-within {
outline: none;
}
&:disabled {
pointer-events: none;
}
}
&__header-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;
}
}
}
&__select-collection-wrap {
margin-top: base(1);
}
&__first-cell {
border: 0;
background-color: transparent;
padding: 0;
cursor: pointer;
text-decoration: underline;
text-align: left;
white-space: nowrap;
}
@include mid-break {
.collection-list__header {
margin-bottom: base(0.5);
}
}
}

View File

@@ -0,0 +1,120 @@
import React, { useCallback, useEffect, useId, useMemo, useState } from 'react';
import { useModal } from '@faceless-ui/modal';
import { ListDrawerProps, ListTogglerProps, UseListDrawer } from './types';
import { Drawer, DrawerToggler } from '../Drawer';
import { useEditDepth } from '../../utilities/EditDepth';
import { ListDrawerContent } from './DrawerContent';
import './index.scss';
export const baseClass = 'list-drawer';
const formatListDrawerSlug = ({
depth,
uuid,
}: {
depth: number,
uuid: string, // supply when creating a new document and no id is available
}) => `list-drawer_${depth}_${uuid}`;
export const ListDrawerToggler: React.FC<ListTogglerProps> = ({
children,
className,
drawerSlug,
disabled,
...rest
}) => {
return (
<DrawerToggler
slug={drawerSlug}
formatSlug={false}
className={[
className,
`${baseClass}__toggler`,
].filter(Boolean).join(' ')}
disabled={disabled}
{...rest}
>
{children}
</DrawerToggler>
);
};
export const ListDrawer: React.FC<ListDrawerProps> = (props) => {
const { drawerSlug } = props;
return (
<Drawer
slug={drawerSlug}
formatSlug={false}
className={baseClass}
>
<ListDrawerContent {...props} />
</Drawer>
);
};
export const useListDrawer: UseListDrawer = ({ collectionSlugs, uploads, selectedCollection }) => {
const drawerDepth = useEditDepth();
const uuid = useId();
const { modalState, toggleModal, closeModal, openModal } = useModal();
const [isOpen, setIsOpen] = useState(false);
const drawerSlug = formatListDrawerSlug({
depth: drawerDepth,
uuid,
});
useEffect(() => {
setIsOpen(Boolean(modalState[drawerSlug]?.isOpen));
}, [modalState, drawerSlug]);
const toggleDrawer = useCallback(() => {
toggleModal(drawerSlug);
}, [toggleModal, drawerSlug]);
const closeDrawer = useCallback(() => {
closeModal(drawerSlug);
}, [drawerSlug, closeModal]);
const openDrawer = useCallback(() => {
openModal(drawerSlug);
}, [drawerSlug, openModal]);
const MemoizedDrawer = useMemo(() => {
return ((props) => (
<ListDrawer
{...props}
drawerSlug={drawerSlug}
collectionSlugs={collectionSlugs}
uploads={uploads}
closeDrawer={closeDrawer}
key={drawerSlug}
selectedCollection={selectedCollection}
/>
));
}, [drawerSlug, collectionSlugs, uploads, closeDrawer, selectedCollection]);
const MemoizedDrawerToggler = useMemo(() => {
return ((props) => (
<ListDrawerToggler
{...props}
drawerSlug={drawerSlug}
/>
));
}, [drawerSlug]);
const MemoizedDrawerState = useMemo(() => ({
drawerSlug,
drawerDepth,
isDrawerOpen: isOpen,
toggleDrawer,
closeDrawer,
openDrawer,
}), [drawerDepth, drawerSlug, isOpen, toggleDrawer, closeDrawer, openDrawer]);
return [
MemoizedDrawer,
MemoizedDrawerToggler,
MemoizedDrawerState,
];
};

View File

@@ -0,0 +1,38 @@
import React, { HTMLAttributes } from 'react';
import { SanitizedCollectionConfig } from '../../../../collections/config/types';
export type ListDrawerProps = {
onSelect?: (args: {
docID: string
collectionConfig: SanitizedCollectionConfig
}) => void
customHeader?: React.ReactNode
drawerSlug?: string
collectionSlugs?: string[]
uploads?: boolean
selectedCollection?: string
}
export type ListTogglerProps = HTMLAttributes<HTMLButtonElement> & {
children?: React.ReactNode
className?: string
drawerSlug?: string
disabled?: boolean
}
export type UseListDrawer = (args: {
collectionSlugs?: string[]
selectedCollection?: string
uploads?: boolean // finds all collections with upload: true
}) => [
React.FC<Omit<ListDrawerProps, 'collectionSlug' | 'id'>>, // drawer
React.FC<Omit<ListTogglerProps, 'collectionSlug' | 'id'>>, // toggler
{
drawerSlug: string,
drawerDepth: number
isDrawerOpen: boolean
toggleDrawer: () => void
closeDrawer: () => void
openDrawer: () => void
}
]

View File

@@ -13,7 +13,7 @@ const baseClass = 'per-page';
const defaultLimits = defaults.admin.pagination.limits;
type Props = {
export type Props = {
limits: number[]
limit: number
handleChange?: (limit: number) => void

View File

@@ -1,9 +1,11 @@
import React, { useEffect, useState } from 'react';
import React, { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../../utilities/Auth';
import Button from '../Button';
import { Props } from './types';
import { useLocale } from '../../utilities/Locale';
import { useDocumentInfo } from '../../utilities/DocumentInfo';
import { useConfig } from '../../utilities/Config';
import './index.scss';
@@ -12,46 +14,40 @@ const baseClass = 'preview-btn';
const PreviewButton: React.FC<Props> = (props) => {
const {
generatePreviewURL,
data,
} = props;
const [url, setUrl] = useState<string | undefined>(undefined);
const { id, collection, global } = useDocumentInfo();
const [isLoading, setIsLoading] = useState(false);
const locale = useLocale();
const { token } = useAuth();
const { serverURL, routes: { api } } = useConfig();
const { t } = useTranslation('version');
useEffect(() => {
if (generatePreviewURL && typeof generatePreviewURL === 'function') {
const makeRequest = async () => {
const handleClick = useCallback(async () => {
setIsLoading(true);
let url = `${serverURL}${api}`;
if (collection) url = `${url}/${collection.slug}/${id}`;
if (global) url = `${url}/globals/${global.slug}`;
const data = await fetch(`${url}?draft=true&locale=${locale}&fallback-locale=null`).then((res) => res.json());
const previewURL = await generatePreviewURL(data, { locale, token });
setUrl(previewURL);
};
setIsLoading(false);
makeRequest();
}
}, [
generatePreviewURL,
locale,
token,
data,
]);
window.open(previewURL, '_blank');
}, [serverURL, api, collection, global, id, generatePreviewURL, locale, token]);
if (url) {
return (
<Button
el="anchor"
className={baseClass}
buttonStyle="secondary"
url={url}
newTab
onClick={handleClick}
disabled={isLoading}
>
{t('preview')}
{isLoading ? t('general:loading') : t('preview')}
</Button>
);
}
return null;
};
export default PreviewButton;

View File

@@ -1,7 +1,5 @@
import { Data } from '../../forms/Form/types';
import { GeneratePreviewURL } from '../../../../config/types';
export type Props = {
generatePreviewURL?: GeneratePreviewURL,
data?: Data
}

View File

@@ -11,5 +11,6 @@
&__text {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
}

View File

@@ -36,7 +36,8 @@ const SortComplex: React.FC<Props> = (props) => {
}, []));
const [sortField, setSortField] = useState(sortFields[0]);
const [sortOrder, setSortOrder] = useState({ label: t('descending'), value: '-' });
const [initialSort] = useState(() => ({ label: t('descending'), value: '-' }));
const [sortOrder, setSortOrder] = useState(initialSort);
useEffect(() => {
if (sortField?.value) {
@@ -80,7 +81,9 @@ const SortComplex: React.FC<Props> = (props) => {
<ReactSelect
value={sortOrder}
options={sortOptions}
onChange={setSortOrder}
onChange={(incomingSort) => {
setSortOrder(incomingSort || initialSort);
}}
/>
</div>
</div>

View File

@@ -2,6 +2,7 @@
.step-nav {
display: flex;
overflow: auto;
* {
display: block;

View File

@@ -38,4 +38,8 @@ $caretSize: 6;
transition: opacity .2s ease-in-out;
cursor: default;
}
@include mid-break {
display: none;
}
}

View File

@@ -145,7 +145,9 @@ const WhereBuilder: React.FC<Props> = (props) => {
buttonStyle="icon-label"
iconPosition="left"
iconStyle="with-border"
onClick={() => dispatchConditions({ type: 'add', field: reducedFields[0].value })}
onClick={() => {
if (reducedFields.length > 0) dispatchConditions({ type: 'add', field: reducedFields[0].value });
}}
>
{t('or')}
</Button>
@@ -160,7 +162,9 @@ const WhereBuilder: React.FC<Props> = (props) => {
buttonStyle="icon-label"
iconPosition="left"
iconStyle="with-border"
onClick={() => dispatchConditions({ type: 'add', field: reducedFields[0].value })}
onClick={() => {
if (reducedFields.length > 0) dispatchConditions({ type: 'add', field: reducedFields[0].value });
}}
>
{t('addFilter')}
</Button>

View File

@@ -0,0 +1,17 @@
import { Field, fieldAffectsData } from '../../../../fields/config/types';
export const createNestedFieldPath = (parentPath: string, field: Field): string => {
if (parentPath) {
if (fieldAffectsData(field)) {
return `${parentPath}.${field.name}`;
}
return parentPath;
}
if (fieldAffectsData(field)) {
return field.name;
}
return '';
};

View File

@@ -8,9 +8,25 @@ const getSiblingData = (fields: Fields, path: string): Data => {
}
const siblingFields = {};
// If this field is nested
// We can provide a list of sibling fields
const parentFieldPath = path.substring(0, path.lastIndexOf('.') + 1);
// Determine if the last segment of the path is an array-based row
const pathSegments = path.split('.');
const lastSegment = pathSegments[pathSegments.length - 1];
const lastSegmentIsRowIndex = !Number.isNaN(Number(lastSegment));
let parentFieldPath: string;
if (lastSegmentIsRowIndex) {
// If the last segment is a row index,
// the sibling data is that row's contents
// so create a parent field path that will
// retrieve all contents of that row index only
parentFieldPath = `${path}.`;
} else {
// Otherwise, the last path segment is a field name
// and it should be removed
parentFieldPath = path.substring(0, path.lastIndexOf('.') + 1);
}
Object.keys(fields).forEach((fieldKey) => {
if (!fields[fieldKey].disableFormData && fieldKey.indexOf(parentFieldPath) === 0) {
siblingFields[fieldKey.replace(parentFieldPath, '')] = fields[fieldKey].value;

View File

@@ -17,16 +17,16 @@ import { useDocumentInfo } from '../../../utilities/DocumentInfo';
import { useOperation } from '../../../utilities/OperationProvider';
import { Collapsible } from '../../../elements/Collapsible';
import RenderFields from '../../RenderFields';
import { fieldAffectsData } from '../../../../../fields/config/types';
import { Props } from './types';
import { usePreferences } from '../../../utilities/Preferences';
import { ArrayAction } from '../../../elements/ArrayAction';
import { scrollToID } from '../../../../utilities/scrollToID';
import HiddenInput from '../HiddenInput';
import { RowLabel } from '../../RowLabel';
import { getTranslation } from '../../../../../utilities/getTranslation';
import { createNestedFieldPath } from '../../Form/createNestedFieldPath';
import './index.scss';
import { getTranslation } from '../../../../../utilities/getTranslation';
const baseClass = 'array-field';
@@ -312,7 +312,7 @@ const ArrayFieldType: React.FC<Props> = (props) => {
indexPath={indexPath}
fieldSchema={fields.map((field) => ({
...field,
path: `${path}.${i}${fieldAffectsData(field) ? `.${field.name}` : ''}`,
path: createNestedFieldPath(`${path}.${i}`, field),
}))}
/>
@@ -326,10 +326,7 @@ const ArrayFieldType: React.FC<Props> = (props) => {
<Banner type="error">
{t('validation:requiresAtLeast', {
count: minRows,
label: getTranslation(minRows
? labels.plural
: labels.singular,
i18n) || t(minRows > 1 ? 'general:row' : 'general:rows'),
label: getTranslation(minRows ? labels.plural : labels.singular, i18n) || t(minRows > 1 ? 'general:row' : 'general:rows'),
})}
</Banner>
)}

View File

@@ -23,12 +23,12 @@ import { useOperation } from '../../../utilities/OperationProvider';
import { Collapsible } from '../../../elements/Collapsible';
import { ArrayAction } from '../../../elements/ArrayAction';
import RenderFields from '../../RenderFields';
import { fieldAffectsData } from '../../../../../fields/config/types';
import SectionTitle from './SectionTitle';
import Pill from '../../../elements/Pill';
import { scrollToID } from '../../../../utilities/scrollToID';
import HiddenInput from '../HiddenInput';
import { getTranslation } from '../../../../../utilities/getTranslation';
import { createNestedFieldPath } from '../../Form/createNestedFieldPath';
import './index.scss';
@@ -345,7 +345,7 @@ const BlocksField: React.FC<Props> = (props) => {
permissions={permissions?.fields}
fieldSchema={blockToRender.fields.map((field) => ({
...field,
path: `${path}.${i}${fieldAffectsData(field) ? `.${field.name}` : ''}`,
path: createNestedFieldPath(`${path}.${i}`, field),
}))}
indexPath={indexPath}
/>

View File

@@ -7,8 +7,8 @@ import { usePreferences } from '../../../utilities/Preferences';
import { DocumentPreferences } from '../../../../../preferences/types';
import { useDocumentInfo } from '../../../utilities/DocumentInfo';
import FieldDescription from '../../FieldDescription';
import { getFieldPath } from '../getFieldPath';
import { RowLabel } from '../../RowLabel';
import { createNestedFieldPath } from '../../Form/createNestedFieldPath';
import './index.scss';
@@ -101,7 +101,7 @@ const CollapsibleField: React.FC<Props> = (props) => {
indexPath={indexPath}
fieldSchema={fields.map((field) => ({
...field,
path: getFieldPath(path, field),
path: createNestedFieldPath(path, field),
}))}
/>
</Collapsible>

View File

@@ -89,7 +89,7 @@ const DateTime: React.FC<Props> = (props) => {
placeholder={getTranslation(placeholder, i18n)}
readOnly={readOnly}
onChange={(incomingDate) => {
if (!readOnly) setValue(incomingDate.toISOString());
if (!readOnly) setValue(incomingDate?.toISOString() || null);
}}
value={value as Date}
/>

View File

@@ -4,11 +4,11 @@ import RenderFields from '../../RenderFields';
import withCondition from '../../withCondition';
import FieldDescription from '../../FieldDescription';
import { Props } from './types';
import { fieldAffectsData } from '../../../../../fields/config/types';
import { useCollapsible } from '../../../elements/Collapsible/provider';
import { GroupProvider, useGroup } from './provider';
import { useTabs } from '../Tabs/provider';
import { getTranslation } from '../../../../../utilities/getTranslation';
import { createNestedFieldPath } from '../../Form/createNestedFieldPath';
import './index.scss';
@@ -78,7 +78,7 @@ const Group: React.FC<Props> = (props) => {
indexPath={indexPath}
fieldSchema={fields.map((subField) => ({
...subField,
path: `${path}${fieldAffectsData(subField) ? `.${subField.name}` : ''}`,
path: createNestedFieldPath(path, subField),
}))}
/>
</div>

View File

@@ -10,7 +10,8 @@
height: 100%;
}
&__add-button {
&__add-button,
&__add-button.doc-drawer__toggler {
@include formInput;
position: relative;
height: 100%;
@@ -35,7 +36,6 @@
&__relation-button {
@extend %btn-reset;
cursor: pointer;
@extend %btn-reset;
display: block;
padding: base(.125) 0;
text-align: center;

View File

@@ -25,6 +25,7 @@ import { findOptionsByValue } from './findOptionsByValue';
import { GetFilterOptions } from './GetFilterOptions';
import { SingleValue } from './select-components/SingleValue';
import { MultiValueLabel } from './select-components/MultiValueLabel';
import { DocumentDrawerProps } from '../../../elements/DocumentDrawer/types';
import './index.scss';
@@ -300,6 +301,10 @@ const Relationship: React.FC<Props> = (props) => {
setHasLoadedFirstPage(false);
}, [relationTo, filterOptionsResult]);
const onSave = useCallback<DocumentDrawerProps['onSave']>((args) => {
dispatchOptions({ type: 'UPDATE', doc: args.doc, collection: args.collectionConfig, i18n });
}, [i18n]);
const classes = [
'field-type',
baseClass,
@@ -383,6 +388,7 @@ const Relationship: React.FC<Props> = (props) => {
disableMouseDown: drawerIsOpen,
disableKeyDown: drawerIsOpen,
setDrawerIsOpen,
onSave,
}}
onMenuOpen={() => {
if (!hasLoadedFirstPage) {

View File

@@ -29,6 +29,22 @@ const optionsReducer = (state: OptionGroup[], action: Action): OptionGroup[] =>
return [];
}
case 'UPDATE': {
const { collection, doc, i18n } = action;
const relation = collection.slug;
const newOptions = [...state];
const labelKey = collection.admin.useAsTitle || 'id';
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.relationTo = relation;
}
return newOptions;
}
case 'ADD': {
const { collection, docs, sort, ids = [], i18n } = action;
const relation = collection.slug;

View File

@@ -15,14 +15,11 @@
&__text {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
&__drawer-toggler {
position: relative;
background: none;
border: none;
cursor: pointer;
padding: 0;
display: flex;
align-items: center;
justify-content: center;

View File

@@ -20,6 +20,7 @@ export const MultiValueLabel: React.FC<MultiValueProps<Option>> = (props) => {
selectProps: {
setDrawerIsOpen,
draggableProps,
onSave,
},
} = props;
@@ -65,7 +66,7 @@ export const MultiValueLabel: React.FC<MultiValueProps<Option>> = (props) => {
</Tooltip>
<Edit />
</DocumentDrawerToggler>
<DocumentDrawer />
<DocumentDrawer onSave={onSave} />
</Fragment>
)}
</div>

View File

@@ -10,14 +10,17 @@
display: flex;
align-items: center;
overflow: visible;
width: 100%;
flex-shrink: 1;
}
&__text {
overflow: hidden;
text-overflow: ellipsis;
}
&__drawer-toggler {
position: relative;
background: none;
border: none;
cursor: pointer;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;

View File

@@ -21,6 +21,7 @@ export const SingleValue: React.FC<SingleValueProps<Option>> = (props) => {
selectProps: {
selectProps: {
setDrawerIsOpen,
onSave,
},
},
} = props;
@@ -69,7 +70,7 @@ export const SingleValue: React.FC<SingleValueProps<Option>> = (props) => {
</SelectComponents.SingleValue>
</div>
{relationTo && hasReadPermission && (
<DocumentDrawer />
<DocumentDrawer onSave={onSave} />
)}
</div>
);

View File

@@ -28,6 +28,13 @@ type CLEAR = {
type: 'CLEAR'
}
type UPDATE = {
type: 'UPDATE'
doc: any
collection: SanitizedCollectionConfig
i18n: typeof i18n
}
type ADD = {
type: 'ADD'
docs: any[]
@@ -37,7 +44,7 @@ type ADD = {
i18n: typeof i18n
}
export type Action = CLEAR | ADD
export type Action = CLEAR | ADD | UPDATE
export type ValueWithRelation = {
relationTo: string

View File

@@ -1,6 +1,6 @@
import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react';
import isHotkey from 'is-hotkey';
import { createEditor, Transforms, Node, Element as SlateElement, Text, BaseEditor } from 'slate';
import { createEditor, Transforms, Node, Element as SlateElement, Text, BaseEditor, BaseOperation } from 'slate';
import { ReactEditor, Editable, withReact, Slate } from 'slate-react';
import { HistoryEditor, withHistory } from 'slate-history';
import { useTranslation } from 'react-i18next';
@@ -17,12 +17,13 @@ import enablePlugins from './enablePlugins';
import defaultValue from '../../../../../fields/richText/defaultValue';
import FieldDescription from '../../FieldDescription';
import withHTML from './plugins/withHTML';
import { Props } from './types';
import { ElementNode, TextNode, Props } from './types';
import { RichTextElement, RichTextLeaf } from '../../../../../fields/config/types';
import listTypes from './elements/listTypes';
import mergeCustomFunctions from './mergeCustomFunctions';
import withEnterBreakOut from './plugins/withEnterBreakOut';
import { getTranslation } from '../../../../../utilities/getTranslation';
import { useEditDepth } from '../../../utilities/EditDepth';
import './index.scss';
@@ -30,15 +31,12 @@ const defaultElements: RichTextElement[] = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6',
const defaultLeaves: RichTextLeaf[] = ['bold', 'italic', 'underline', 'strikethrough', 'code'];
const baseClass = 'rich-text';
type CustomText = { text: string;[x: string]: unknown }
type CustomElement = { type?: string; children: CustomText[] }
declare module 'slate' {
interface CustomTypes {
Editor: BaseEditor & ReactEditor & HistoryEditor
Element: CustomElement
Text: CustomText
Element: ElementNode
Text: TextNode
}
}
@@ -74,6 +72,9 @@ const RichText: React.FC<Props> = (props) => {
const editorRef = useRef(null);
const toolbarRef = useRef(null);
const drawerDepth = useEditDepth();
const drawerIsOpen = drawerDepth > 1;
const renderElement = useCallback(({ attributes, children, element }) => {
const matchedElement = enabledElements[element?.type];
const Element = matchedElement?.Element;
@@ -168,6 +169,25 @@ const RichText: React.FC<Props> = (props) => {
return CreatedEditor;
}, [elements, leaves]);
// All slate changes fire the onChange event
// including selection changes
// so we will filter the set_selection operations out
// and only fire setValue when onChange is because of value
const handleChange = useCallback((val: unknown) => {
const ops = editor.operations.filter((o: BaseOperation) => {
if (o) {
return o.type !== 'set_selection';
}
return false;
});
if (ops && Array.isArray(ops) && ops.length > 0) {
if (!readOnly && val !== defaultValue && val !== value) {
setValue(val);
}
}
}, [editor.operations, readOnly, setValue, value]);
useEffect(() => {
if (!loaded) {
const mergedElements = mergeCustomFunctions(elements, elementTypes);
@@ -206,12 +226,12 @@ const RichText: React.FC<Props> = (props) => {
}, [loaded, readOnly]);
useEffect(() => {
// If there is a change to the initial value, we need to reset Slate
// to the new initial value, and clear selection
// If there is a change to the initial value, we need to reset Slate history
// and clear selection because the old selection may no longer be valid
// as returned JSON may be modified in hooks and have a different shape
if (Array.isArray(initialValue) && initialValue.length > 0) {
if (editor.selection) ReactEditor.deselect(editor);
editor.history = { redos: [], undos: [] };
editor.children = initialValue;
}
}, [initialValue, editor]);
@@ -253,15 +273,14 @@ const RichText: React.FC<Props> = (props) => {
<Slate
editor={editor}
value={valueToRender as any[]}
onChange={(val) => {
if (!readOnly && val !== defaultValue && val !== value) {
setValue(val);
}
}}
onChange={handleChange}
>
<div className={`${baseClass}__wrapper`}>
<div
className={`${baseClass}__toolbar`}
className={[
`${baseClass}__toolbar`,
drawerIsOpen && `${baseClass}__drawerIsOpen`,
].filter(Boolean).join(' ')}
ref={toolbarRef}
>
<div className={`${baseClass}__toolbar-wrap`}>
@@ -349,13 +368,15 @@ const RichText: React.FC<Props> = (props) => {
if (SlateElement.isElement(selectedElement) && selectedElement.type === 'li') {
const selectedLeaf = Node.descendant(editor, editor.selection.anchor.path);
if (Text.isText(selectedLeaf) && String(selectedLeaf.text).length === 1) {
if (Text.isText(selectedLeaf) && String(selectedLeaf.text).length === 0) {
event.preventDefault();
Transforms.unwrapNodes(editor, {
match: (n) => SlateElement.isElement(n) && listTypes.includes(n.type),
split: true,
mode: 'lowest',
});
Transforms.setNodes(editor, {});
Transforms.setNodes(editor, { type: undefined });
}
} else if (editor.isVoid(selectedElement)) {
Transforms.removeNodes(editor);

View File

@@ -1,6 +1,9 @@
@import '../../../../scss/styles.scss';
.rich-text__button {
position: relative;
cursor: pointer;
svg {
width: base(.75);
height: base(.75);

View File

@@ -1,33 +1,54 @@
import React, { useCallback } from 'react';
import React, { ElementType, useCallback, useState } from 'react';
import { useSlate } from 'slate-react';
import isElementActive from './isActive';
import toggleElement from './toggle';
import { ButtonProps } from './types';
import Tooltip from '../../../../elements/Tooltip';
import '../buttons.scss';
export const baseClass = 'rich-text__button';
const ElementButton: React.FC<ButtonProps> = ({ format, children, onClick, className }) => {
const ElementButton: React.FC<ButtonProps> = (props) => {
const {
format,
children,
onClick,
className,
tooltip,
el = 'button',
} = props;
const editor = useSlate();
const [showTooltip, setShowTooltip] = useState(false);
const defaultOnClick = useCallback((event) => {
event.preventDefault();
setShowTooltip(false);
toggleElement(editor, format);
}, [editor, format]);
const Tag: ElementType = el;
return (
<button
type="button"
<Tag
{...el === 'button' && { type: 'button' }}
className={[
baseClass,
className,
isElementActive(editor, format) && `${baseClass}__button--active`,
].filter(Boolean).join(' ')}
onClick={onClick || defaultOnClick}
onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)}
>
{tooltip && (
<Tooltip show={showTooltip}>
{tooltip}
</Tooltip>
)}
{children}
</button>
</Tag>
);
};

View File

@@ -1,8 +1,8 @@
import React, { useCallback } from 'react';
import { useSlate } from 'slate-react';
import isListActive from './isListActive';
import toggleElement from './toggle';
import toggleList from './toggleList';
import { ButtonProps } from './types';
import isListActive from './isListActive';
import '../buttons.scss';
@@ -13,7 +13,7 @@ const ListButton: React.FC<ButtonProps> = ({ format, children, onClick, classNam
const defaultOnClick = useCallback((event) => {
event.preventDefault();
toggleElement(editor, format);
toggleList(editor, format);
}, [editor, format]);
return (

View File

@@ -0,0 +1,5 @@
import { Element, Node } from 'slate';
export const areAllChildrenElements = (node: Node): boolean => {
return Array.isArray(node.children) && node.children.every((child) => Element.isElement(child));
};

View File

@@ -0,0 +1,20 @@
import { Editor, Node, NodeEntry, NodeMatch } from 'slate';
export const getCommonBlock = (editor: Editor, match?: NodeMatch<Node>): NodeEntry<Node> => {
const range = Editor.unhangRange(editor, editor.selection, { voids: true });
const [common, path] = Node.common(
editor,
range.anchor.path,
range.focus.path,
);
if (Editor.isBlock(editor, common) || Editor.isEditor(common)) {
return [common, path];
}
return Editor.above(editor, {
at: path,
match: match || ((n) => Editor.isBlock(editor, n) || Editor.isEditor(n)),
});
};

View File

@@ -1,11 +1,13 @@
import React, { useCallback } from 'react';
import { useSlate, ReactEditor } from 'slate-react';
import { Editor, Element, Transforms } from 'slate';
import { Editor, Element, Text, Transforms } from 'slate';
import IndentLeft from '../../../../../icons/IndentLeft';
import IndentRight from '../../../../../icons/IndentRight';
import { baseClass } from '../Button';
import isElementActive from '../isActive';
import listTypes from '../listTypes';
import { getCommonBlock } from '../getCommonBlock';
import { unwrapList } from '../unwrapList';
const indentType = 'indent';
@@ -25,25 +27,87 @@ const indent = {
e.preventDefault();
if (dir === 'left') {
Transforms.unwrapNodes(editor, {
match: (n) => Element.isElement(n) && [indentType, ...listTypes].includes(n.type),
split: true,
mode: 'lowest',
});
if (isElementActive(editor, 'li')) {
const [, parentLocation] = Editor.parent(editor, editor.selection);
const [, parentDepth] = parentLocation;
const [, listPath] = getCommonBlock(editor, (n) => Element.isElement(n) && listTypes.includes(n.type));
if (parentDepth > 0 || parentDepth === 0) {
const matchedParentList = Editor.above(editor, {
at: listPath,
match: (n) => !Editor.isEditor(n) && Editor.isBlock(editor, n),
});
if (matchedParentList) {
const [parentListItem, parentListItemPath] = matchedParentList;
if (parentListItem.children.length > 1) {
// Remove nested list
Transforms.unwrapNodes(editor, {
match: (n) => Element.isElement(n) && n.type === 'li',
at: parentListItemPath,
match: (node, path) => {
const matches = !Editor.isEditor(node)
&& Element.isElement(node)
&& listTypes.includes(node.type)
&& path.length === parentListItemPath.length + 1;
return matches;
},
});
// Set li type on any children that don't have a type
Transforms.setNodes(editor, { type: 'li' }, {
at: parentListItemPath,
match: (node, path) => {
const matches = !Editor.isEditor(node)
&& Element.isElement(node)
&& node.type !== 'li'
&& path.length === parentListItemPath.length + 1;
return matches;
},
});
// Parent list item path has changed at this point
// so we need to re-fetch the parent node
const [newParentNode] = Editor.node(editor, parentListItemPath);
// If the parent node is an li,
// lift all li nodes within
if (Element.isElement(newParentNode) && newParentNode.type === 'li') {
// Lift the nested lis
Transforms.liftNodes(editor, {
at: parentListItemPath,
match: (node, path) => {
const matches = !Editor.isEditor(node)
&& Element.isElement(node)
&& path.length === parentListItemPath.length + 1
&& node.type === 'li';
return matches;
},
});
}
} else {
Transforms.unwrapNodes(editor, {
at: parentListItemPath,
match: (node, path) => {
return Element.isElement(node)
&& node.type === 'li'
&& path.length === parentListItemPath.length;
},
});
Transforms.unwrapNodes(editor, {
match: (n) => Element.isElement(n) && listTypes.includes(n.type),
});
}
} else {
unwrapList(editor, listPath);
}
} else {
Transforms.unwrapNodes(editor, {
match: (n) => Element.isElement(n) && n.type === indentType,
split: true,
mode: 'lowest',
});
} else {
Transforms.unsetNodes(editor, ['type']);
}
}
}
@@ -52,12 +116,63 @@ const indent = {
const isCurrentlyUL = isElementActive(editor, 'ul');
if (isCurrentlyOL || isCurrentlyUL) {
Transforms.wrapNodes(editor, {
type: 'li',
children: [],
// Get the path of the first selected li -
// Multiple lis could be selected
// and the selection may start in the middle of the first li
const [[, firstSelectedLIPath]] = Array.from(Editor.nodes(editor, {
mode: 'lowest',
match: (node) => Element.isElement(node) && node.type === 'li',
}));
// Is the first selected li the first in its list?
const hasPrecedingLI = firstSelectedLIPath[firstSelectedLIPath.length - 1] > 0;
// If the first selected li is NOT the first in its list,
// we need to inject it into the prior li
if (hasPrecedingLI) {
const [, precedingLIPath] = Editor.previous(editor, {
at: firstSelectedLIPath,
});
Transforms.wrapNodes(editor, { type: isCurrentlyOL ? 'ol' : 'ul', children: [{ text: ' ' }] });
Transforms.setNodes(editor, { type: 'li' });
const [precedingLIChildren] = Editor.node(editor, [...precedingLIPath, 0]);
const precedingLIChildrenIsText = Text.isText(precedingLIChildren);
if (precedingLIChildrenIsText) {
// Wrap the prior li text content so that it can be nested next to a list
Transforms.wrapNodes(editor, { children: [] }, { at: [...precedingLIPath, 0] });
}
// Move the selected lis after the prior li contents
Transforms.moveNodes(editor, {
to: [...precedingLIPath, 1],
match: (node) => Element.isElement(node) && node.type === 'li',
mode: 'lowest',
});
// Wrap the selected lis in a new list
Transforms.wrapNodes(
editor,
{
type: isCurrentlyOL ? 'ol' : 'ul', children: [],
},
{
match: (node) => Element.isElement(node) && node.type === 'li',
mode: 'lowest',
},
);
} else {
// Otherwise, just wrap the node in a list / li
Transforms.wrapNodes(
editor,
{
type: isCurrentlyOL ? 'ol' : 'ul', children: [{ type: 'li', children: [] }],
},
{
match: (node) => Element.isElement(node) && node.type === 'li',
mode: 'lowest',
},
);
}
} else {
Transforms.wrapNodes(editor, { type: indentType, children: [] });
}

View File

@@ -1,6 +1,6 @@
import { Editor, Element } from 'slate';
const isElementActive = (editor, format) => {
const isElementActive = (editor: Editor, format: string): boolean => {
if (!editor.selection) return false;
const [match] = Array.from(Editor.nodes(editor, {

View File

@@ -1,6 +1,9 @@
import { Editor, Element } from 'slate';
import { nodeIsTextNode } from '../types';
export const isLastSelectedElementEmpty = (editor: Editor): boolean => {
if (!editor.selection) return false;
const currentlySelectedNodes = Array.from(Editor.nodes(editor, {
at: Editor.unhangRange(editor, editor.selection),
match: (n) => !Editor.isEditor(n) && Element.isElement(n) && (!n.type || n.type === 'p'),
@@ -10,5 +13,6 @@ export const isLastSelectedElementEmpty = (editor: Editor): boolean => {
return lastSelectedNode && Element.isElement(lastSelectedNode[0])
&& (!lastSelectedNode[0].type || lastSelectedNode[0].type === 'p')
&& nodeIsTextNode(lastSelectedNode[0].children?.[0])
&& lastSelectedNode[0].children?.[0].text === '';
};

View File

@@ -1,23 +1,24 @@
import { Ancestor, Editor, Element, NodeEntry } from 'slate';
import { Editor, Element } from 'slate';
import { getCommonBlock } from './getCommonBlock';
const isListActive = (editor: Editor, format: string): boolean => {
let parentLI: NodeEntry<Ancestor>;
if (!editor.selection) return false;
const [topmostSelectedNode, topmostSelectedNodePath] = getCommonBlock(editor);
try {
parentLI = Editor.parent(editor, editor.selection);
} catch (e) {
// swallow error, Slate
}
if (Editor.isEditor(topmostSelectedNode)) return false;
if (parentLI?.[1]?.length > 0) {
const ancestor = Editor.above(editor, {
at: parentLI[1],
});
const [match] = Array.from(Editor.nodes(editor, {
at: topmostSelectedNodePath,
mode: 'lowest',
match: (node, path) => {
return !Editor.isEditor(node)
&& Element.isElement(node)
&& node.type === format
&& path.length >= topmostSelectedNodePath.length - 2;
},
}));
return Element.isElement(ancestor[0]) && ancestor[0].type === format;
}
return false;
return !!match;
};
export default isListActive;

View File

@@ -0,0 +1,17 @@
import { Editor, Element, Ancestor, NodeEntry } from 'slate';
export const isWithinListItem = (editor: Editor): boolean => {
let parentLI: NodeEntry<Ancestor>;
try {
parentLI = Editor.parent(editor, editor.selection);
} catch (e) {
// swallow error, Slate
}
if (Element.isElement(parentLI?.[0]) && parentLI?.[0]?.type === 'li') {
return true;
}
return false;
};

View File

@@ -1,134 +0,0 @@
import React, { Fragment, useState } from 'react';
import { ReactEditor, useSlate } from 'slate-react';
import { Transforms, Editor, Range } from 'slate';
import { useModal } from '@faceless-ui/modal';
import { useTranslation } from 'react-i18next';
import ElementButton from '../Button';
import { unwrapLink } from './utilities';
import LinkIcon from '../../../../../icons/Link';
import { EditModal } from './Modal';
import { modalSlug as baseModalSlug } from './shared';
import isElementActive from '../isActive';
import { Fields } from '../../../../Form/types';
import buildStateFromSchema from '../../../../Form/buildStateFromSchema';
import { useAuth } from '../../../../../utilities/Auth';
import { useLocale } from '../../../../../utilities/Locale';
import { useConfig } from '../../../../../utilities/Config';
import { getBaseFields } from './Modal/baseFields';
import { Field } from '../../../../../../../fields/config/types';
import reduceFieldsToValues from '../../../../Form/reduceFieldsToValues';
export const LinkButton = ({ fieldProps }) => {
const customFieldSchema = fieldProps?.admin?.link?.fields;
const modalSlug = `${baseModalSlug}-${fieldProps.path}`;
const { t } = useTranslation();
const config = useConfig();
const editor = useSlate();
const { user } = useAuth();
const locale = useLocale();
const { toggleModal } = useModal();
const [renderModal, setRenderModal] = useState(false);
const [initialState, setInitialState] = useState<Fields>({});
const [fieldSchema] = useState(() => {
const fields: Field[] = [
...getBaseFields(config),
];
if (customFieldSchema) {
fields.push({
name: 'fields',
type: 'group',
admin: {
style: {
margin: 0,
padding: 0,
borderTop: 0,
borderBottom: 0,
},
},
fields: customFieldSchema,
});
}
return fields;
});
return (
<Fragment>
<ElementButton
format="link"
onClick={async () => {
if (isElementActive(editor, 'link')) {
unwrapLink(editor);
} else {
toggleModal(modalSlug);
setRenderModal(true);
const isCollapsed = editor.selection && Range.isCollapsed(editor.selection);
if (!isCollapsed) {
const data = {
text: editor.selection ? Editor.string(editor, editor.selection) : '',
};
const state = await buildStateFromSchema({ fieldSchema, data, user, operation: 'create', locale, t });
setInitialState(state);
}
}
}}
>
<LinkIcon />
</ElementButton>
{renderModal && (
<EditModal
modalSlug={modalSlug}
fieldSchema={fieldSchema}
initialState={initialState}
close={() => {
toggleModal(modalSlug);
setRenderModal(false);
}}
handleModalSubmit={(fields) => {
const isCollapsed = editor.selection && Range.isCollapsed(editor.selection);
const data = reduceFieldsToValues(fields, true);
const newLink = {
type: 'link',
linkType: data.linkType,
url: data.url,
doc: data.doc,
newTab: data.newTab,
fields: data.fields,
children: [],
};
if (isCollapsed || !editor.selection) {
// If selection anchor and focus are the same,
// Just inject a new node with children already set
Transforms.insertNodes(editor, {
...newLink,
children: [{ text: String(data.text) }],
});
} else if (editor.selection) {
// Otherwise we need to wrap the selected node in a link,
// Delete its old text,
// Move the selection one position forward into the link,
// And insert the text back into the new link
Transforms.wrapNodes(editor, newLink, { split: true });
Transforms.delete(editor, { at: editor.selection.focus.path, unit: 'word' });
Transforms.move(editor, { distance: 1, unit: 'offset' });
Transforms.insertText(editor, String(data.text), { at: editor.selection.focus.path });
}
toggleModal(modalSlug);
setRenderModal(false);
ReactEditor.focus(editor);
}}
/>
)}
</Fragment>
);
};

View File

@@ -0,0 +1,125 @@
import React, { Fragment, useId, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ReactEditor, useSlate } from 'slate-react';
import { Transforms, Range } from 'slate';
import { useModal } from '@faceless-ui/modal';
import ElementButton from '../../Button';
import LinkIcon from '../../../../../../icons/Link';
import reduceFieldsToValues from '../../../../../Form/reduceFieldsToValues';
import { useConfig } from '../../../../../../utilities/Config';
import isElementActive from '../../isActive';
import { unwrapLink } from '../utilities';
import { useEditDepth } from '../../../../../../utilities/EditDepth';
import { formatDrawerSlug } from '../../../../../../elements/Drawer';
import { getBaseFields } from '../LinkDrawer/baseFields';
import { LinkDrawer } from '../LinkDrawer';
import { Field } from '../../../../../../../../fields/config/types';
import { Props as RichTextFieldProps } from '../../../types';
const insertLink = (editor, fields) => {
const isCollapsed = editor.selection && Range.isCollapsed(editor.selection);
const data = reduceFieldsToValues(fields, true);
const newLink = {
type: 'link',
linkType: data.linkType,
url: data.url,
doc: data.doc,
newTab: data.newTab,
fields: data.fields,
children: [],
};
if (isCollapsed || !editor.selection) {
// If selection anchor and focus are the same,
// Just inject a new node with children already set
Transforms.insertNodes(editor, {
...newLink,
children: [{ text: String(data.text) }],
});
} else if (editor.selection) {
// Otherwise we need to wrap the selected node in a link,
// Delete its old text,
// Move the selection one position forward into the link,
// And insert the text back into the new link
Transforms.wrapNodes(editor, newLink, { split: true });
Transforms.delete(editor, { at: editor.selection.focus.path, unit: 'word' });
Transforms.move(editor, { distance: 1, unit: 'offset' });
Transforms.insertText(editor, String(data.text), { at: editor.selection.focus.path });
}
ReactEditor.focus(editor);
};
export const LinkButton: React.FC<{
path: string
fieldProps: RichTextFieldProps
}> = ({ fieldProps }) => {
const customFieldSchema = fieldProps?.admin?.link?.fields;
const { t } = useTranslation(['upload', 'general']);
const editor = useSlate();
const config = useConfig();
const [fieldSchema] = useState(() => {
const fields: Field[] = [
...getBaseFields(config),
];
if (customFieldSchema) {
fields.push({
name: 'fields',
type: 'group',
admin: {
style: {
margin: 0,
padding: 0,
borderTop: 0,
borderBottom: 0,
},
},
fields: customFieldSchema,
});
}
return fields;
});
const { openModal, closeModal } = useModal();
const uuid = useId();
const editDepth = useEditDepth();
const drawerSlug = formatDrawerSlug({
slug: `rich-text-link-${uuid}`,
depth: editDepth,
});
return (
<Fragment>
<ElementButton
format="link"
tooltip={t('fields:addLink')}
className="link"
onClick={() => {
if (isElementActive(editor, 'link')) {
unwrapLink(editor);
} else {
openModal(drawerSlug);
}
}}
>
<LinkIcon />
</ElementButton>
<LinkDrawer
drawerSlug={drawerSlug}
handleModalSubmit={(fields) => {
insertLink(editor, fields);
closeModal(drawerSlug);
}}
fieldSchema={fieldSchema}
handleClose={() => {
closeModal(drawerSlug);
}}
/>
</Fragment>
);
};

View File

@@ -1,4 +1,4 @@
@import '../../../../../../scss/styles.scss';
@import '../../../../../../../scss/styles.scss';
.rich-text-link {
position: relative;
@@ -11,7 +11,6 @@
bottom: 0;
left: 0;
.popup__scroll,
.popup__wrap {
overflow: visible;
@@ -52,18 +51,19 @@
}
}
.rich-text-link__button {
@extend %btn-reset;
font-family: inherit;
font-size: inherit;
font-weight: inherit;
color: inherit;
letter-spacing: inherit;
line-height: inherit;
.rich-text-link__popup-toggler {
position: relative;
border: 0;
background-color: transparent;
padding: 0;
text-decoration: underline;
cursor: text;
&:focus,
&:focus-within {
outline: none;
}
&--open {
z-index: var(--z-popup);
}

View File

@@ -1,31 +1,73 @@
import React, { useCallback, useEffect, useState } from 'react';
import React, { HTMLAttributes, useCallback, useEffect, useId, useState } from 'react';
import { ReactEditor, useSlate } from 'slate-react';
import { Transforms, Node, Editor } from 'slate';
import { useModal } from '@faceless-ui/modal';
import { Trans, useTranslation } from 'react-i18next';
import { unwrapLink } from './utilities';
import Popup from '../../../../../elements/Popup';
import { EditModal } from './Modal';
import { modalSlug as baseModalSlug } from './shared';
import { Fields } from '../../../../Form/types';
import buildStateFromSchema from '../../../../Form/buildStateFromSchema';
import { useAuth } from '../../../../../utilities/Auth';
import { useLocale } from '../../../../../utilities/Locale';
import { useConfig } from '../../../../../utilities/Config';
import { getBaseFields } from './Modal/baseFields';
import { Field } from '../../../../../../../fields/config/types';
import reduceFieldsToValues from '../../../../Form/reduceFieldsToValues';
import deepCopyObject from '../../../../../../../utilities/deepCopyObject';
import Button from '../../../../../elements/Button';
import { getTranslation } from '../../../../../../../utilities/getTranslation';
import { unwrapLink } from '../utilities';
import Popup from '../../../../../../elements/Popup';
import { LinkDrawer } from '../LinkDrawer';
import { Fields } from '../../../../../Form/types';
import buildStateFromSchema from '../../../../../Form/buildStateFromSchema';
import { useAuth } from '../../../../../../utilities/Auth';
import { useLocale } from '../../../../../../utilities/Locale';
import { useConfig } from '../../../../../../utilities/Config';
import { getBaseFields } from '../LinkDrawer/baseFields';
import { Field } from '../../../../../../../../fields/config/types';
import reduceFieldsToValues from '../../../../../Form/reduceFieldsToValues';
import deepCopyObject from '../../../../../../../../utilities/deepCopyObject';
import Button from '../../../../../../elements/Button';
import { getTranslation } from '../../../../../../../../utilities/getTranslation';
import { useEditDepth } from '../../../../../../utilities/EditDepth';
import { formatDrawerSlug } from '../../../../../../elements/Drawer';
import { Props as RichTextFieldProps } from '../../../types';
import './index.scss';
const baseClass = 'rich-text-link';
// TODO: Multiple modal windows stacked go boom (rip). Edit Upload in fields -> rich text
const insertChange = (editor, fields, customFieldSchema) => {
const data = reduceFieldsToValues(fields, true);
const [, parentPath] = Editor.above(editor);
const newNode: Record<string, unknown> = {
newTab: data.newTab,
url: data.url,
linkType: data.linkType,
doc: data.doc,
};
if (customFieldSchema) {
newNode.fields = data.fields;
}
Transforms.setNodes(
editor,
newNode,
{ at: parentPath },
);
Transforms.delete(editor, { at: editor.selection.focus.path, unit: 'block' });
Transforms.move(editor, { distance: 1, unit: 'offset' });
Transforms.insertText(editor, String(data.text), { at: editor.selection.focus.path });
ReactEditor.focus(editor);
};
export const LinkElement: React.FC<{
attributes: HTMLAttributes<HTMLDivElement>
children: React.ReactNode
element: any
fieldProps: RichTextFieldProps
editorRef: React.RefObject<HTMLDivElement>
}> = (props) => {
const {
attributes,
children,
element,
editorRef,
fieldProps,
} = props;
export const LinkElement = ({ attributes, children, element, editorRef, fieldProps }) => {
const customFieldSchema = fieldProps?.admin?.link?.fields;
const editor = useSlate();
@@ -33,7 +75,7 @@ export const LinkElement = ({ attributes, children, element, editorRef, fieldPro
const { user } = useAuth();
const locale = useLocale();
const { t, i18n } = useTranslation('fields');
const { openModal, toggleModal } = useModal();
const { openModal, toggleModal, closeModal } = useModal();
const [renderModal, setRenderModal] = useState(false);
const [renderPopup, setRenderPopup] = useState(false);
const [initialState, setInitialState] = useState<Fields>({});
@@ -61,7 +103,13 @@ export const LinkElement = ({ attributes, children, element, editorRef, fieldPro
return fields;
});
const modalSlug = `${baseModalSlug}-${fieldProps.path}`;
const uuid = useId();
const editDepth = useEditDepth();
const drawerSlug = formatDrawerSlug({
slug: `rich-text-link-${uuid}`,
depth: editDepth,
});
const handleTogglePopup = useCallback((render) => {
if (!render) {
@@ -97,43 +145,16 @@ export const LinkElement = ({ attributes, children, element, editorRef, fieldPro
contentEditable={false}
>
{renderModal && (
<EditModal
modalSlug={modalSlug}
<LinkDrawer
drawerSlug={drawerSlug}
fieldSchema={fieldSchema}
close={() => {
toggleModal(modalSlug);
handleClose={() => {
toggleModal(drawerSlug);
setRenderModal(false);
}}
handleModalSubmit={(fields) => {
toggleModal(modalSlug);
setRenderModal(false);
const data = reduceFieldsToValues(fields, true);
const [, parentPath] = Editor.above(editor);
const newNode: Record<string, unknown> = {
newTab: data.newTab,
url: data.url,
linkType: data.linkType,
doc: data.doc,
};
if (customFieldSchema) {
newNode.fields = data.fields;
}
Transforms.setNodes(
editor,
newNode,
{ at: parentPath },
);
Transforms.delete(editor, { at: editor.selection.focus.path, unit: 'block' });
Transforms.move(editor, { distance: 1, unit: 'offset' });
Transforms.insertText(editor, String(data.text), { at: editor.selection.focus.path });
ReactEditor.focus(editor);
insertChange(editor, fields, customFieldSchema);
closeModal(drawerSlug);
}}
initialState={initialState}
/>
@@ -181,7 +202,7 @@ export const LinkElement = ({ attributes, children, element, editorRef, fieldPro
onClick={(e) => {
e.preventDefault();
setRenderPopup(false);
openModal(modalSlug);
openModal(drawerSlug);
setRenderModal(true);
}}
tooltip={t('general:edit')}
@@ -205,7 +226,7 @@ export const LinkElement = ({ attributes, children, element, editorRef, fieldPro
tabIndex={0}
role="button"
className={[
`${baseClass}__button`,
`${baseClass}__popup-toggler`,
].filter(Boolean).join(' ')}
onKeyDown={(e) => { if (e.key === 'Enter') setRenderPopup(true); }}
onClick={() => setRenderPopup(true)}

View File

@@ -0,0 +1,50 @@
@import '../../../../../../../scss/styles.scss';
.rich-text-link-edit-modal {
&__template {
position: relative;
z-index: 1;
padding-top: base(1);
padding-bottom: base(2);
}
&__header {
width: 100%;
margin-bottom: $baseline;
display: flex;
justify-content: space-between;
margin-top: base(2.5);
margin-bottom: base(1);
@include mid-break {
margin-top: base(1.5);
}
}
&__header-text {
margin: 0;
}
&__header-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;
}
}
}
}

View File

@@ -1,7 +1,6 @@
import { Modal } from '@faceless-ui/modal';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { MinimalTemplate } from '../../../../../..';
import { Drawer } from '../../../../../../elements/Drawer';
import Button from '../../../../../../elements/Button';
import X from '../../../../../../icons/X';
import Form from '../../../../../Form';
@@ -11,29 +10,34 @@ import fieldTypes from '../../../..';
import RenderFields from '../../../../../RenderFields';
import './index.scss';
import { Gutter } from '../../../../../../elements/Gutter';
const baseClass = 'rich-text-link-edit-modal';
export const EditModal: React.FC<Props> = ({
close,
export const LinkDrawer: React.FC<Props> = ({
handleClose,
handleModalSubmit,
initialState,
fieldSchema,
modalSlug,
drawerSlug,
}) => {
const { t } = useTranslation('fields');
return (
<Modal
slug={modalSlug}
<Drawer
slug={drawerSlug}
formatSlug={false}
className={baseClass}
>
<MinimalTemplate className={`${baseClass}__template`}>
<Gutter className={`${baseClass}__template`}>
<header className={`${baseClass}__header`}>
<h3>{t('editLink')}</h3>
<h2 className={`${baseClass}__header-text`}>
{t('editLink')}
</h2>
<Button
className={`${baseClass}__header-close`}
buttonStyle="none"
onClick={close}
onClick={handleClose}
>
<X />
</Button>
@@ -52,7 +56,7 @@ export const EditModal: React.FC<Props> = ({
{t('general:submit')}
</FormSubmit>
</Form>
</MinimalTemplate>
</Modal>
</Gutter>
</Drawer>
);
};

View File

@@ -2,8 +2,8 @@ import { Field } from '../../../../../../../../fields/config/types';
import { Fields } from '../../../../../Form/types';
export type Props = {
modalSlug: string
close: () => void
drawerSlug: string
handleClose: () => void
handleModalSubmit: (fields: Fields, data: Record<string, unknown>) => void
initialState?: Fields
fieldSchema: Field[]

View File

@@ -1,29 +0,0 @@
@import '../../../../../../../scss/styles.scss';
.rich-text-link-edit-modal {
@include blur-bg;
display: flex;
align-items: center;
height: 100%;
&__template {
position: relative;
z-index: 1;
}
&__header {
width: 100%;
margin-bottom: $baseline;
display: flex;
justify-content: space-between;
h3 {
margin: 0;
}
svg {
width: base(1.5);
height: base(1.5);
}
}
}

View File

@@ -1,35 +1,7 @@
@import '../../../../../../../scss/styles.scss';
.relationship-rich-text-button {
.btn {
margin-right: base(1);
}
&__modal {
@include blur-bg;
display: flex;
align-items: center;
height: 100%;
}
&__modal-template {
position: relative;
z-index: 1;
}
&__header {
width: 100%;
margin-bottom: $baseline;
display: flex;
justify-content: space-between;
h3 {
margin: 0;
}
svg {
width: base(1.5);
height: base(1.5);
}
}
}

View File

@@ -1,23 +1,14 @@
import React, { Fragment, useCallback, useEffect, useState } from 'react';
import { Modal, useModal } from '@faceless-ui/modal';
import { ReactEditor, useSlate } from 'slate-react';
import { useTranslation } from 'react-i18next';
import { useConfig } from '../../../../../../utilities/Config';
import ElementButton from '../../Button';
import RelationshipIcon from '../../../../../../icons/Relationship';
import Form from '../../../../../Form';
import MinimalTemplate from '../../../../../../templates/Minimal';
import Button from '../../../../../../elements/Button';
import Submit from '../../../../../Submit';
import X from '../../../../../../icons/X';
import Fields from './Fields';
import { requests } from '../../../../../../../api';
import { injectVoidElement } from '../../injectVoid';
import { useListDrawer } from '../../../../../../elements/ListDrawer';
import './index.scss';
const initialFormData = {};
const baseClass = 'relationship-rich-text-button';
const insertRelationship = (editor, { value, relationTo }) => {
@@ -37,80 +28,58 @@ const insertRelationship = (editor, { value, relationTo }) => {
ReactEditor.focus(editor);
};
const RelationshipButton: React.FC<{ path: string }> = ({ path }) => {
const { toggleModal } = useModal();
const RelationshipButton: React.FC<{ path: string }> = () => {
const { collections } = useConfig();
const { t } = useTranslation('fields');
const editor = useSlate();
const { serverURL, routes: { api }, collections } = useConfig();
const [renderModal, setRenderModal] = useState(false);
const [loading, setLoading] = useState(false);
const { t, i18n } = useTranslation('fields');
const [hasEnabledCollections] = useState(() => collections.find(({ admin: { enableRichTextRelationship } }) => enableRichTextRelationship));
const modalSlug = `${path}-add-relationship`;
const handleAddRelationship = useCallback(async (_, { relationTo, value }) => {
setLoading(true);
const res = await requests.get(`${serverURL}${api}/${relationTo}/${value}?depth=0`, {
headers: {
'Accept-Language': i18n.language,
const [enabledCollectionSlugs] = useState(() => collections.filter(({ admin: { enableRichTextRelationship } }) => enableRichTextRelationship).map(({ slug }) => slug));
const [selectedCollectionSlug, setSelectedCollectionSlug] = useState(() => enabledCollectionSlugs[0]);
const [
ListDrawer,
ListDrawerToggler,
{
closeDrawer,
isDrawerOpen,
},
] = useListDrawer({
collectionSlugs: enabledCollectionSlugs,
selectedCollection: selectedCollectionSlug,
});
const json = await res.json();
insertRelationship(editor, { value: { id: json.id }, relationTo });
toggleModal(modalSlug);
setRenderModal(false);
setLoading(false);
}, [i18n.language, editor, toggleModal, modalSlug, api, serverURL]);
const onSelect = useCallback(({ docID, collectionConfig }) => {
insertRelationship(editor, {
value: {
id: docID,
},
relationTo: collectionConfig.slug,
});
closeDrawer();
}, [editor, closeDrawer]);
useEffect(() => {
if (renderModal) {
toggleModal(modalSlug);
}
}, [renderModal, toggleModal, modalSlug]);
// always reset back to first option
// TODO: this is not working, see the ListDrawer component
setSelectedCollectionSlug(enabledCollectionSlugs[0]);
}, [isDrawerOpen, enabledCollectionSlugs]);
if (!hasEnabledCollections) return null;
if (!enabledCollectionSlugs || enabledCollectionSlugs.length === 0) return null;
return (
<Fragment>
<ListDrawerToggler>
<ElementButton
className={baseClass}
format="relationship"
onClick={() => setRenderModal(true)}
tooltip={t('addRelationship')}
el="div"
onClick={() => {
// do nothing
}}
>
<RelationshipIcon />
</ElementButton>
{renderModal && (
<Modal
slug={modalSlug}
className={`${baseClass}__modal`}
>
<MinimalTemplate className={`${baseClass}__modal-template`}>
<header className={`${baseClass}__header`}>
<h3>{t('addRelationship')}</h3>
<Button
buttonStyle="none"
onClick={() => {
toggleModal(modalSlug);
setRenderModal(false);
}}
>
<X />
</Button>
</header>
<Form
onSubmit={handleAddRelationship}
initialData={initialFormData}
disabled={loading}
>
<Fields />
<Submit>
{t('addRelationship')}
</Submit>
</Form>
</MinimalTemplate>
</Modal>
)}
</ListDrawerToggler>
<ListDrawer onSelect={onSelect} />
</Fragment>
);
};

View File

@@ -3,9 +3,9 @@
.rich-text-relationship {
@extend %body;
@include shadow-sm;
padding: base(.5);
padding: base(.75);
display: flex;
align-items: flex-start;
align-items: center;
background: var(--theme-input-bg);
border: 1px solid var(--theme-elevation-100);
max-width: base(15);
@@ -19,20 +19,77 @@
margin: base(.625) 0;
}
svg {
width: base(1.25);
height: base(1.25);
margin-right: base(.5);
&__label {
margin-bottom: base(0.25);
}
h5 {
&__title {
margin: 0;
}
&__label,
&__title {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
line-height: 1 !important;
}
&__title {
font-weight: bold;
}
&__wrap {
flex-grow: 1;
overflow: hidden;
}
&--selected {
box-shadow: $focus-box-shadow;
outline: none;
}
&__toggler {
background: transparent;
border: none;
padding: 0;
cursor: pointer;
&:focus,
&:focus-within {
outline: none;
}
&:disabled {
pointer-events: none;
}
}
&__actions {
display: flex;
align-items: center;
flex-shrink: 0;
margin-left: base(0.5);
& > *:not(:last-child) {
margin-right: base(.25);
}
}
&__actionButton {
margin: 0;
border-radius: 0;
flex-shrink: 0;
width: base(1);
height: base(1);
line {
stroke-width: $style-stroke-width-m;
}
&:disabled {
opacity: 0.5;
pointer-events: none;
}
}
}

View File

@@ -1,9 +1,13 @@
import React, { useState } from 'react';
import { useFocused, useSelected } from 'slate-react';
import React, { HTMLAttributes, useCallback, useReducer, useState } from 'react';
import { ReactEditor, useFocused, useSelected, useSlateStatic } from 'slate-react';
import { useTranslation } from 'react-i18next';
import { Transforms } from 'slate';
import { useConfig } from '../../../../../../utilities/Config';
import RelationshipIcon from '../../../../../../icons/Relationship';
import usePayloadAPI from '../../../../../../../hooks/usePayloadAPI';
import { useDocumentDrawer } from '../../../../../../elements/DocumentDrawer';
import Button from '../../../../../../elements/Button';
import { useListDrawer } from '../../../../../../elements/ListDrawer';
import { Props as RichTextProps } from '../../../types';
import { getTranslation } from '../../../../../../../../utilities/getTranslation';
import './index.scss';
@@ -14,20 +18,115 @@ const initialParams = {
depth: 0,
};
const Element = (props) => {
const { attributes, children, element } = props;
const Element: React.FC<{
attributes: HTMLAttributes<HTMLDivElement>
children: React.ReactNode
element: any
fieldProps: RichTextProps
}> = (props) => {
const {
attributes,
children,
element,
fieldProps,
} = props;
const { relationTo, value } = element;
const { collections, serverURL, routes: { api } } = useConfig();
const [relatedCollection] = useState(() => collections.find((coll) => coll.slug === relationTo));
const [enabledCollectionSlugs] = useState(() => collections.filter(({ admin: { enableRichTextRelationship } }) => enableRichTextRelationship).map(({ slug }) => slug));
const [relatedCollection, setRelatedCollection] = useState(() => collections.find((coll) => coll.slug === relationTo));
const selected = useSelected();
const focused = useFocused();
const { t, i18n } = useTranslation('fields');
const [{ data }] = usePayloadAPI(
const { t, i18n } = useTranslation(['fields', 'general']);
const editor = useSlateStatic();
const [cacheBust, dispatchCacheBust] = useReducer((state) => state + 1, 0);
const [{ data }, { setParams }] = usePayloadAPI(
`${serverURL}${api}/${relatedCollection.slug}/${value?.id}`,
{ initialParams },
);
const [
DocumentDrawer,
DocumentDrawerToggler,
{
closeDrawer,
},
] = useDocumentDrawer({
collectionSlug: relatedCollection.slug,
id: value?.id,
});
const [
ListDrawer,
ListDrawerToggler,
{
closeDrawer: closeListDrawer,
},
] = useListDrawer({
collectionSlugs: enabledCollectionSlugs,
selectedCollection: relatedCollection.slug,
});
const removeRelationship = useCallback(() => {
const elementPath = ReactEditor.findPath(editor, element);
Transforms.removeNodes(
editor,
{ at: elementPath },
);
}, [editor, element]);
const updateRelationship = React.useCallback(({ doc }) => {
const elementPath = ReactEditor.findPath(editor, element);
Transforms.setNodes(
editor,
{
type: 'relationship',
value: { id: doc.id },
relationTo: relatedCollection.slug,
children: [
{ text: ' ' },
],
},
{ at: elementPath },
);
setParams({
...initialParams,
cacheBust, // do this to get the usePayloadAPI to re-fetch the data even though the URL string hasn't changed
});
closeDrawer();
dispatchCacheBust();
}, [editor, element, relatedCollection, cacheBust, setParams, closeDrawer]);
const swapRelationship = React.useCallback(({ docID, collectionConfig }) => {
const elementPath = ReactEditor.findPath(editor, element);
Transforms.setNodes(
editor,
{
type: 'relationship',
value: { id: docID },
relationTo: collectionConfig.slug,
children: [
{ text: ' ' },
],
},
{ at: elementPath },
);
setRelatedCollection(collections.find((coll) => coll.slug === collectionConfig.slug));
setParams({
...initialParams,
cacheBust, // do this to get the usePayloadAPI to re-fetch the data even though the URL string hasn't changed
});
closeListDrawer();
dispatchCacheBust();
}, [closeListDrawer, editor, element, cacheBust, setParams, collections]);
return (
<div
className={[
@@ -37,13 +136,65 @@ const Element = (props) => {
contentEditable={false}
{...attributes}
>
<RelationshipIcon />
<div className={`${baseClass}__wrap`}>
<div className={`${baseClass}__label`}>
<p className={`${baseClass}__label`}>
{t('labelRelationship', { label: getTranslation(relatedCollection.labels.singular, i18n) })}
</p>
<p className={`${baseClass}__title`}>
{data[relatedCollection?.admin?.useAsTitle || 'id']}
</p>
</div>
<h5>{data[relatedCollection?.admin?.useAsTitle || 'id']}</h5>
<div className={`${baseClass}__actions`}>
{value?.id && (
<DocumentDrawerToggler
className={`${baseClass}__toggler`}
disabled={fieldProps?.admin?.readOnly}
>
<Button
icon="edit"
round
buttonStyle="icon-label"
el="div"
className={`${baseClass}__actionButton`}
onClick={(e) => {
e.preventDefault();
}}
tooltip={t('general:editLabel', { label: getTranslation(relatedCollection.labels.singular, i18n) })}
disabled={fieldProps?.admin?.readOnly}
/>
</DocumentDrawerToggler>
)}
<ListDrawerToggler disabled={fieldProps?.admin?.readOnly}>
<Button
icon="swap"
round
buttonStyle="icon-label"
className={`${baseClass}__actionButton`}
onClick={() => {
// do nothing
}}
el="div"
tooltip={t('swapRelationship')}
disabled={fieldProps?.admin?.readOnly}
/>
</ListDrawerToggler>
<Button
icon="x"
round
buttonStyle="icon-label"
className={`${baseClass}__actionButton`}
onClick={(e) => {
e.preventDefault();
removeRelationship();
}}
tooltip={t('fields:removeRelationship')}
disabled={fieldProps?.admin?.readOnly}
/>
</div>
{value?.id && (
<DocumentDrawer onSave={updateRelationship} />
)}
<ListDrawer onSelect={swapRelationship} />
{children}
</div>
);

View File

@@ -1,33 +1,29 @@
import { Element, Transforms } from 'slate';
import { Editor, Transforms } from 'slate';
import { ReactEditor } from 'slate-react';
import isElementActive from './isActive';
import listTypes from './listTypes';
import { isWithinListItem } from './isWithinListItem';
const toggleElement = (editor, format) => {
const toggleElement = (editor: Editor, format: string): void => {
const isActive = isElementActive(editor, format);
const isList = listTypes.includes(format);
let type = format;
const isWithinLI = isWithinListItem(editor);
if (isActive) {
type = undefined;
} else if (isList) {
type = 'li';
}
Transforms.unwrapNodes(editor, {
match: (n) => Element.isElement(n) && listTypes.includes(n.type as string),
split: true,
mode: 'lowest',
if (!isActive && isWithinLI) {
const block = { type: 'li', children: [] };
Transforms.wrapNodes(editor, block, {
at: Editor.unhangRange(editor, editor.selection),
});
Transforms.setNodes(editor, { type });
if (!isActive && isList) {
const block = { type: format, children: [] };
Transforms.wrapNodes(editor, block);
}
Transforms.setNodes(editor, { type }, {
at: Editor.unhangRange(editor, editor.selection),
});
ReactEditor.focus(editor);
};

View File

@@ -0,0 +1,88 @@
import { Editor, Element, Node, Text, Transforms } from 'slate';
import { ReactEditor } from 'slate-react';
import { getCommonBlock } from './getCommonBlock';
import isListActive from './isListActive';
import listTypes from './listTypes';
import { unwrapList } from './unwrapList';
const toggleList = (editor: Editor, format: string): void => {
let currentListFormat: string;
if (isListActive(editor, 'ol')) currentListFormat = 'ol';
if (isListActive(editor, 'ul')) currentListFormat = 'ul';
// If the format is currently active,
// remove the list
if (currentListFormat === format) {
const selectedLeaf = Node.descendant(editor, editor.selection.anchor.path);
// If on an empty bullet, leave the above list alone
// and unwrap only the active bullet
if (Text.isText(selectedLeaf) && String(selectedLeaf.text).length === 0) {
Transforms.unwrapNodes(editor, {
match: (n) => Element.isElement(n) && listTypes.includes(n.type),
split: true,
mode: 'lowest',
});
Transforms.setNodes(editor, { type: undefined });
} else {
// Otherwise, we need to unset li on all lis in the parent list
// and unwrap the parent list itself
const [, listPath] = getCommonBlock(editor, (n) => Element.isElement(n) && n.type === format);
unwrapList(editor, listPath);
}
// Otherwise, if a list is active and we are changing it,
// change it
} else if (currentListFormat && currentListFormat !== format) {
Transforms.setNodes(
editor,
{
type: format,
},
{
match: (node) => Element.isElement(node) && listTypes.includes(node.type),
mode: 'lowest',
},
);
// Otherwise we can assume that we should just activate the list
} else {
Transforms.wrapNodes(editor, { type: format, children: [] });
const [, parentNodePath] = getCommonBlock(editor, (node) => Element.isElement(node) && node.type === format);
// Only set li on nodes that don't have type
Transforms.setNodes(editor, { type: 'li' }, {
voids: true,
match: (node, path) => {
const match = Element.isElement(node)
&& typeof node.type === 'undefined'
&& path.length === parentNodePath.length + 1;
return match;
},
});
// Wrap nodes that do have a type with an li
// so as to not lose their existing formatting
const nodesToWrap = Array.from(Editor.nodes(editor, {
match: (node, path) => {
const match = Element.isElement(node)
&& typeof node.type !== 'undefined'
&& node.type !== 'li'
&& path.length === parentNodePath.length + 1;
return match;
},
}));
nodesToWrap.forEach(([, path]) => {
Transforms.wrapNodes(editor, { type: 'li', children: [] }, { at: path });
});
}
ReactEditor.focus(editor);
};
export default toggleList;

View File

@@ -1,6 +1,10 @@
import { ElementType } from 'react';
export type ButtonProps = {
format: string
onClick?: (e: React.MouseEvent) => void
className?: string
children?: React.ReactNode
tooltip?: string
el?: ElementType
}

View File

@@ -0,0 +1,45 @@
import { Path, Transforms, Editor, Element } from 'slate';
import { areAllChildrenElements } from './areAllChildrenElements';
import listTypes from './listTypes';
export const unwrapList = (editor: Editor, atPath: Path): void => {
// Remove type for any nodes that have text children -
// this means that the node should remain
Transforms.setNodes(editor, { type: undefined }, {
at: atPath,
match: (node, path) => {
const childrenAreAllElements = areAllChildrenElements(node);
const matches = !Editor.isEditor(node)
&& Element.isElement(node)
&& !childrenAreAllElements
&& node.type === 'li'
&& path.length === atPath.length + 1;
return matches;
},
});
// For nodes have all element children, unwrap it instead
// because the li is a duplicative wrapper
Transforms.unwrapNodes(editor, {
at: atPath,
match: (node, path) => {
const childrenAreAllElements = areAllChildrenElements(node);
const matches = !Editor.isEditor(node)
&& Element.isElement(node)
&& childrenAreAllElements
&& node.type === 'li'
&& path.length === atPath.length + 1;
return matches;
},
});
// Finally, unwrap the UL itself
Transforms.unwrapNodes(editor, {
match: (n) => Element.isElement(n) && listTypes.includes(n.type),
mode: 'lowest',
});
};

View File

@@ -1,7 +1,7 @@
@import '../../../../../../../scss/styles.scss';
.upload-rich-text-button {
.btn {
margin-right: base(1);
}
display: flex;
align-items: center;
height: 100%;
}

View File

@@ -1,29 +1,14 @@
import React, { Fragment, useEffect, useState } from 'react';
import { Modal, useModal } from '@faceless-ui/modal';
import { ReactEditor, useSlate } from 'slate-react';
import React, { Fragment, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useConfig } from '../../../../../../utilities/Config';
import { ReactEditor, useSlate } from 'slate-react';
import ElementButton from '../../Button';
import UploadIcon from '../../../../../../icons/Upload';
import usePayloadAPI from '../../../../../../../hooks/usePayloadAPI';
import UploadGallery from '../../../../../../elements/UploadGallery';
import ListControls from '../../../../../../elements/ListControls';
import ReactSelect from '../../../../../../elements/ReactSelect';
import Paginator from '../../../../../../elements/Paginator';
import formatFields from '../../../../../../views/collections/List/formatFields';
import Label from '../../../../../Label';
import MinimalTemplate from '../../../../../../templates/Minimal';
import Button from '../../../../../../elements/Button';
import { SanitizedCollectionConfig } from '../../../../../../../../collections/config/types';
import PerPage from '../../../../../../elements/PerPage';
import { useListDrawer } from '../../../../../../elements/ListDrawer';
import { injectVoidElement } from '../../injectVoid';
import { getTranslation } from '../../../../../../../../utilities/getTranslation';
import './index.scss';
import '../addSwapModals.scss';
const baseClass = 'upload-rich-text-button';
const baseModalClass = 'rich-text-upload-modal';
const insertUpload = (editor, { value, relationTo }) => {
const text = { text: ' ' };
@@ -42,178 +27,48 @@ const insertUpload = (editor, { value, relationTo }) => {
ReactEditor.focus(editor);
};
const UploadButton: React.FC<{ path: string }> = ({ path }) => {
const { t, i18n } = useTranslation('upload');
const { toggleModal, isModalOpen } = useModal();
const UploadButton: React.FC<{
path: string
}> = () => {
const { t } = useTranslation(['upload', 'general']);
const editor = useSlate();
const { serverURL, routes: { api }, collections } = useConfig();
const [availableCollections] = useState(() => collections.filter(({ admin: { enableRichTextRelationship }, upload }) => (Boolean(upload) && enableRichTextRelationship)));
const [renderModal, setRenderModal] = useState(false);
const [modalCollectionOption, setModalCollectionOption] = useState<{ label: string, value: string }>(() => {
const firstAvailableCollection = collections.find(({ admin: { enableRichTextRelationship }, upload }) => (Boolean(upload) && enableRichTextRelationship));
if (firstAvailableCollection) {
return { label: getTranslation(firstAvailableCollection.labels.singular, i18n), value: firstAvailableCollection.slug };
}
return undefined;
const [
ListDrawer,
ListDrawerToggler,
{
closeDrawer,
},
] = useListDrawer({
uploads: true,
});
const [modalCollection, setModalCollection] = useState<SanitizedCollectionConfig>(() => collections.find(({ admin: { enableRichTextRelationship }, upload }) => (Boolean(upload) && enableRichTextRelationship)));
const [fields, setFields] = useState(() => (modalCollection ? formatFields(modalCollection, t) : undefined));
const [limit, setLimit] = useState<number>();
const [sort, setSort] = useState(null);
const [where, setWhere] = useState(null);
const [page, setPage] = useState(null);
const modalSlug = `${path}-add-upload`;
const moreThanOneAvailableCollection = availableCollections.length > 1;
const isOpen = isModalOpen(modalSlug);
// If modal is open, get active page of upload gallery
const apiURL = isOpen ? `${serverURL}${api}/${modalCollection.slug}` : null;
const [{ data }, { setParams }] = usePayloadAPI(apiURL, {});
useEffect(() => {
if (modalCollection) {
setFields(formatFields(modalCollection, t));
}
}, [modalCollection, t]);
useEffect(() => {
if (renderModal) {
toggleModal(modalSlug);
}
}, [renderModal, toggleModal, modalSlug]);
useEffect(() => {
const params: {
page?: number
sort?: string
where?: unknown
limit?: number
} = {};
if (page) params.page = page;
if (where) params.where = where;
if (sort) params.sort = sort;
if (limit) params.limit = limit;
setParams(params);
}, [setParams, page, sort, where, limit]);
useEffect(() => {
if (modalCollectionOption) {
setModalCollection(collections.find(({ slug }) => modalCollectionOption.value === slug));
}
}, [modalCollectionOption, collections]);
if (!modalCollection) {
return null;
}
const onSelect = useCallback(({ docID, collectionConfig }) => {
insertUpload(editor, {
value: {
id: docID,
},
relationTo: collectionConfig.slug,
});
closeDrawer();
}, [editor, closeDrawer]);
return (
<Fragment>
<ListDrawerToggler>
<ElementButton
className={baseClass}
format="upload"
onClick={() => setRenderModal(true)}
tooltip={t('fields:addUpload')}
el="div"
onClick={() => {
// do nothing
}}
>
<UploadIcon />
</ElementButton>
{renderModal && (
<Modal
className={baseModalClass}
slug={modalSlug}
>
{isOpen && (
<MinimalTemplate width="wide">
<header className={`${baseModalClass}__header`}>
<h1>
{t('fields:addLabel', { label: getTranslation(modalCollection.labels.singular, i18n) })}
</h1>
<Button
icon="x"
round
buttonStyle="icon-label"
iconStyle="with-border"
onClick={() => {
toggleModal(modalSlug);
setRenderModal(false);
}}
/>
</header>
{moreThanOneAvailableCollection && (
<div className={`${baseModalClass}__select-collection-wrap`}>
<Label label={t('selectCollectionToBrowse')} />
<ReactSelect
className={`${baseClass}__select-collection`}
value={modalCollectionOption}
onChange={setModalCollectionOption}
options={availableCollections.map((coll) => ({ label: getTranslation(coll.labels.singular, i18n), value: coll.slug }))}
/>
</div>
)}
<ListControls
collection={{
...modalCollection,
fields,
}}
enableColumns={false}
enableSort
modifySearchQuery={false}
handleSortChange={setSort}
handleWhereChange={setWhere}
/>
<UploadGallery
docs={data?.docs}
collection={modalCollection}
onCardClick={(doc) => {
insertUpload(editor, {
value: {
id: doc.id,
},
relationTo: modalCollection.slug,
});
setRenderModal(false);
toggleModal(modalSlug);
}}
/>
<div className={`${baseModalClass}__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}
onChange={setPage}
disableHistoryChange
/>
{data?.totalDocs > 0 && (
<Fragment>
<div className={`${baseModalClass}__page-info`}>
{data.page}
-
{data.totalPages > 1 ? data.limit : data.totalDocs}
{' '}
{t('general:of')}
{' '}
{data.totalDocs}
</div>
<PerPage
limits={modalCollection?.admin?.pagination?.limits}
limit={limit}
modifySearchParams={false}
handleChange={setLimit}
/>
</Fragment>
)}
</div>
</MinimalTemplate>
)}
</Modal>
)}
</ListDrawerToggler>
<ListDrawer onSelect={onSelect} />
</Fragment>
);
};

View File

@@ -1,31 +0,0 @@
@import '../../../../../../../../scss/styles.scss';
.edit-upload-modal {
@include blur-bg;
display: flex;
align-items: center;
.template-minimal {
padding-top: base(4);
align-items: flex-start;
position: relative;
z-index: 1;
}
&__header {
margin-bottom: $baseline;
display: flex;
h1 {
margin: 0 auto 0 0;
}
.btn {
margin: 0 0 0 $baseline;
}
}
.rich-text__toolbar {
top: base(1);
}
}

View File

@@ -1,99 +0,0 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Transforms, Element } from 'slate';
import { ReactEditor, useSlateStatic } from 'slate-react';
import { Modal } from '@faceless-ui/modal';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../../../../../../../utilities/Auth';
import { SanitizedCollectionConfig } from '../../../../../../../../../collections/config/types';
import buildStateFromSchema from '../../../../../../Form/buildStateFromSchema';
import MinimalTemplate from '../../../../../../../templates/Minimal';
import Button from '../../../../../../../elements/Button';
import RenderFields from '../../../../../../RenderFields';
import fieldTypes from '../../../../..';
import Form from '../../../../../../Form';
import Submit from '../../../../../../Submit';
import { Field } from '../../../../../../../../../fields/config/types';
import { useLocale } from '../../../../../../../utilities/Locale';
import { getTranslation } from '../../../../../../../../../utilities/getTranslation';
import './index.scss';
const baseClass = 'edit-upload-modal';
type Props = {
slug: string
closeModal: () => void
relatedCollectionConfig: SanitizedCollectionConfig
fieldSchema: Field[]
element: Element & {
fields: Field[]
}
}
export const EditModal: React.FC<Props> = ({ slug, closeModal, relatedCollectionConfig, fieldSchema, element }) => {
const editor = useSlateStatic();
const [initialState, setInitialState] = useState({});
const { user } = useAuth();
const locale = useLocale();
const { t, i18n } = useTranslation('fields');
const handleUpdateEditData = useCallback((_, data) => {
const newNode = {
fields: data,
};
const elementPath = ReactEditor.findPath(editor, element);
Transforms.setNodes(
editor,
newNode,
{ at: elementPath },
);
closeModal();
}, [closeModal, editor, element]);
useEffect(() => {
const awaitInitialState = async () => {
const state = await buildStateFromSchema({ fieldSchema, data: { ...element?.fields || {} }, user, operation: 'update', locale, t });
setInitialState(state);
};
awaitInitialState();
}, [fieldSchema, element.fields, user, locale, t]);
return (
<Modal
slug={slug}
className={baseClass}
>
<MinimalTemplate width="wide">
<header className={`${baseClass}__header`}>
<h1>
{ t('editLabelData', { label: getTranslation(relatedCollectionConfig.labels.singular, i18n) }) }
</h1>
<Button
icon="x"
round
buttonStyle="icon-label"
onClick={closeModal}
/>
</header>
<div>
<Form
onSubmit={handleUpdateEditData}
initialState={initialState}
>
<RenderFields
readOnly={false}
fieldTypes={fieldTypes}
fieldSchema={fieldSchema}
/>
<Submit>
{t('saveChanges')}
</Submit>
</Form>
</div>
</MinimalTemplate>
</Modal>
);
};

View File

@@ -1,185 +0,0 @@
import * as React from 'react';
import { Modal } from '@faceless-ui/modal';
import { Element, Transforms } from 'slate';
import { ReactEditor, useSlateStatic } from 'slate-react';
import { useTranslation } from 'react-i18next';
import { useConfig } from '../../../../../../../utilities/Config';
import { SanitizedCollectionConfig } from '../../../../../../../../../collections/config/types';
import usePayloadAPI from '../../../../../../../../hooks/usePayloadAPI';
import MinimalTemplate from '../../../../../../../templates/Minimal';
import Button from '../../../../../../../elements/Button';
import Label from '../../../../../../Label';
import ReactSelect from '../../../../../../../elements/ReactSelect';
import ListControls from '../../../../../../../elements/ListControls';
import UploadGallery from '../../../../../../../elements/UploadGallery';
import Paginator from '../../../../../../../elements/Paginator';
import PerPage from '../../../../../../../elements/PerPage';
import formatFields from '../../../../../../../views/collections/List/formatFields';
import { getTranslation } from '../../../../../../../../../utilities/getTranslation';
import '../../addSwapModals.scss';
const baseClass = 'rich-text-upload-modal';
type Props = {
slug: string
element: Element
closeModal: () => void
setRelatedCollectionConfig: (collectionConfig: SanitizedCollectionConfig) => void
relatedCollectionConfig: SanitizedCollectionConfig
}
export const SwapUploadModal: React.FC<Props> = ({ closeModal, element, setRelatedCollectionConfig, relatedCollectionConfig, slug }) => {
const { collections, serverURL, routes: { api } } = useConfig();
const editor = useSlateStatic();
const { t, i18n } = useTranslation('upload');
const [modalCollection, setModalCollection] = React.useState(relatedCollectionConfig);
const [modalCollectionOption, setModalCollectionOption] = React.useState<{ label: string, value: string }>({ label: getTranslation(relatedCollectionConfig.labels.singular, i18n), value: relatedCollectionConfig.slug });
const [availableCollections] = React.useState(() => collections.filter(({ admin: { enableRichTextRelationship }, upload }) => (Boolean(upload) && enableRichTextRelationship)));
const [fields, setFields] = React.useState(() => formatFields(modalCollection, t));
const [limit, setLimit] = React.useState<number>();
const [sort, setSort] = React.useState(null);
const [where, setWhere] = React.useState(null);
const [page, setPage] = React.useState(null);
const moreThanOneAvailableCollection = availableCollections.length > 1;
const apiURL = `${serverURL}${api}/${modalCollection.slug}`;
const [{ data }, { setParams }] = usePayloadAPI(apiURL, {});
const handleUpdateUpload = React.useCallback((doc) => {
const newNode = {
type: 'upload',
value: { id: doc.id },
relationTo: modalCollection.slug,
children: [
{ text: ' ' },
],
};
const elementPath = ReactEditor.findPath(editor, element);
Transforms.setNodes(
editor,
newNode,
{ at: elementPath },
);
closeModal();
}, [closeModal, editor, element, modalCollection]);
React.useEffect(() => {
const params: {
page?: number
sort?: string
where?: unknown
limit?: number
} = {};
if (page) params.page = page;
if (where) params.where = where;
if (sort) params.sort = sort;
if (limit) params.limit = limit;
setParams(params);
}, [setParams, page, sort, where, limit]);
React.useEffect(() => {
setFields(formatFields(modalCollection, t));
setLimit(modalCollection.admin.pagination.defaultLimit);
}, [modalCollection, t]);
React.useEffect(() => {
setModalCollection(collections.find(({ slug: collectionSlug }) => modalCollectionOption.value === collectionSlug));
}, [modalCollectionOption, collections]);
return (
<Modal
className={baseClass}
slug={slug}
>
<MinimalTemplate width="wide">
<header className={`${baseClass}__header`}>
<h1>
{t('chooseLabel', { label: getTranslation(modalCollection.labels.singular, i18n) })}
</h1>
<Button
icon="x"
round
buttonStyle="icon-label"
iconStyle="with-border"
onClick={closeModal}
/>
</header>
{
moreThanOneAvailableCollection && (
<div className={`${baseClass}__select-collection-wrap`}>
<Label label={t('selectCollectionToBrowse')} />
<ReactSelect
className={`${baseClass}__select-collection`}
value={modalCollectionOption}
onChange={setModalCollectionOption}
options={availableCollections.map((coll) => ({ label: getTranslation(coll.labels.singular, i18n), value: coll.slug }))}
/>
</div>
)
}
<ListControls
collection={
{
...modalCollection,
fields,
}
}
enableColumns={false}
enableSort
modifySearchQuery={false}
handleSortChange={setSort}
handleWhereChange={setWhere}
/>
<UploadGallery
docs={data?.docs}
collection={modalCollection}
onCardClick={(doc) => {
handleUpdateUpload(doc);
setRelatedCollectionConfig(modalCollection);
closeModal();
}}
/>
<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}
onChange={setPage}
disableHistoryChange
/>
{data?.totalDocs > 0 && (
<React.Fragment>
<div className={`${baseClass}__page-info`}>
{data.page}
-
{data.totalPages > 1 ? data.limit : data.totalDocs}
{' '}
{t('general:of')}
{' '}
{data.totalDocs}
</div>
<PerPage
limits={modalCollection?.admin?.pagination?.limits}
limit={limit}
modifySearchParams={false}
handleChange={setLimit}
/>
</React.Fragment>
)}
</div>
</MinimalTemplate>
</Modal>
);
};

View File

@@ -51,7 +51,7 @@
flex-grow: 1;
display: flex;
align-items: center;
padding: base(.75) base(1);
padding: base(.75);
justify-content: space-between;
max-width: calc(100% - #{base(3.25)});
}
@@ -60,19 +60,35 @@
display: flex;
align-items: center;
flex-shrink: 0;
margin-left: base(0.5);
& > *:not(:last-child) {
margin-right: base(.25);
}
}
&__actionButton {
margin: 0;
margin-right: base(.5);
border-radius: 0;
flex-shrink: 0;
line {
stroke-width: $style-stroke-width-m;
}
&:last-child {
margin-right: 0;
&:disabled {
opacity: 0.5;
pointer-events: none;
}
}
&__toggler {
background: transparent;
border: none;
padding: 0;
&:focus,
&:focus-within {
outline: none;
}
}

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