Merge branch 'master' into feat/json-field
This commit is contained in:
45
CHANGELOG.md
45
CHANGELOG.md
@@ -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
101
README.md
@@ -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>
|
||||
|
||||
<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" />
|
||||
|
||||
<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>
|
||||
|
||||
|
||||
<a href="https://discord.com/invite/r6sCXqVk3v">
|
||||
<img alt="Discord" src="https://img.shields.io/discord/967097582721572934?label=Discord&color=7289da" />
|
||||
</a>
|
||||
|
||||
<a href="https://www.npmjs.com/package/payload">
|
||||
<img alt="npm" src="https://img.shields.io/npm/v/payload" />
|
||||
</a>
|
||||
|
||||
<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.
|
||||

|
||||
|
||||
## 👏 Thanks to all our contributors
|
||||
|
||||
<img align="left" src="https://contributors-img.web.app/image?repo=payloadcms/payload"/>
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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). |
|
||||
@@ -24,33 +24,33 @@ As with Collection configs, it's often best practice to write your Globals in se
|
||||
| **`versions`** | Set to true to enable default options, or configure with object properties. [More](/docs/versions/overview#globals-config) |
|
||||
| **`endpoints`** | Add custom routes to the REST API. [More](/docs/rest-api/overview#custom-endpoints) |
|
||||
| **`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. |
|
||||
| **`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',
|
||||
fields: [
|
||||
{
|
||||
name: 'items',
|
||||
type: 'array',
|
||||
required: true,
|
||||
maxRows: 8,
|
||||
fields: [
|
||||
{
|
||||
name: 'page',
|
||||
type: 'relationship',
|
||||
relationTo: 'pages', // "pages" is the slug of an existing collection
|
||||
required: true,
|
||||
}
|
||||
]
|
||||
},
|
||||
]
|
||||
slug: "nav",
|
||||
fields: [
|
||||
{
|
||||
name: "items",
|
||||
type: "array",
|
||||
required: true,
|
||||
maxRows: 8,
|
||||
fields: [
|
||||
{
|
||||
name: "page",
|
||||
type: "relationship",
|
||||
relationTo: "pages", // "pages" is the slug of an existing collection
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default Nav;
|
||||
@@ -64,9 +64,47 @@ 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) |
|
||||
| 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`. |
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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:
|
||||
```
|
||||
|
||||
@@ -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": {
|
||||
|
||||
88
src/admin/assets/images/payload-logo-dark.svg
Normal file
88
src/admin/assets/images/payload-logo-dark.svg
Normal 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 |
88
src/admin/assets/images/payload-logo-light.svg
Normal file
88
src/admin/assets/images/payload-logo-light.svg
Normal 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 |
@@ -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,29 +101,31 @@ const Autosave: React.FC<Props> = ({ collection, global, id, publishedDocUpdated
|
||||
};
|
||||
|
||||
setTimeout(async () => {
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept-Language': i18n.language,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (modifiedRef.current) {
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept-Language': i18n.language,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (res.status === 200) {
|
||||
setLastSaved(new Date().getTime());
|
||||
getVersions();
|
||||
}
|
||||
}
|
||||
|
||||
setSaving(false);
|
||||
|
||||
if (res.status === 200) {
|
||||
setLastSaved(new Date().getTime());
|
||||
getVersions();
|
||||
}
|
||||
}, 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]) {
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
140
src/admin/components/elements/DocumentDrawer/DrawerContent.tsx
Normal file
140
src/admin/components/elements/DocumentDrawer/DrawerContent.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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-close {
|
||||
svg {
|
||||
width: base(2.5);
|
||||
height: base(2.5);
|
||||
position: relative;
|
||||
top: base(-.5);
|
||||
right: base(-.75);
|
||||
&__header-text {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.stroke {
|
||||
stroke-width: .5px;
|
||||
}
|
||||
&__toggler {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
|
||||
&:focus,
|
||||
&:focus-within {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
@include mid-break {
|
||||
&__header-close {
|
||||
svg {
|
||||
top: base(-.75);
|
||||
&__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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' } },
|
||||
return (
|
||||
<Drawer
|
||||
slug={drawerSlug}
|
||||
formatSlug={false}
|
||||
className={baseClass}
|
||||
>
|
||||
<DocumentDrawerContent {...props} />
|
||||
</Drawer>
|
||||
);
|
||||
|
||||
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>
|
||||
</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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
z-index: 2;
|
||||
width: 100%;
|
||||
transition: all 300ms ease-out;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__content-children {
|
||||
|
||||
@@ -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,44 +59,55 @@ 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]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
slug={modalSlug}
|
||||
className={[
|
||||
className,
|
||||
baseClass,
|
||||
isOpen && `${baseClass}--is-open`,
|
||||
].filter(Boolean).join(' ')}
|
||||
style={{
|
||||
zIndex: zBase + drawerDepth,
|
||||
}}
|
||||
>
|
||||
{drawerDepth === 1 && (
|
||||
<div className={`${baseClass}__blur-bg`} />
|
||||
)}
|
||||
<button
|
||||
className={`${baseClass}__close`}
|
||||
id={`close-drawer__${modalSlug}`}
|
||||
type="button"
|
||||
onClick={() => closeModal(modalSlug)}
|
||||
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,
|
||||
animateIn && `${baseClass}--is-open`,
|
||||
].filter(Boolean).join(' ')}
|
||||
style={{
|
||||
width: `calc(${midBreak ? 'var(--gutter-h)' : 'var(--nav-width)'} + ${drawerDepth - 1} * 25px)`,
|
||||
zIndex: zBase + drawerDepth,
|
||||
}}
|
||||
aria-label={t('close')}
|
||||
/>
|
||||
<div className={`${baseClass}__content`}>
|
||||
<div className={`${baseClass}__content-children`}>
|
||||
<EditDepthContext.Provider value={drawerDepth + 1}>
|
||||
{children}
|
||||
</EditDepthContext.Provider>
|
||||
>
|
||||
{drawerDepth === 1 && (
|
||||
<div className={`${baseClass}__blur-bg`} />
|
||||
)}
|
||||
<button
|
||||
className={`${baseClass}__close`}
|
||||
id={`close-drawer__${modalSlug}`}
|
||||
type="button"
|
||||
onClick={() => closeModal(modalSlug)}
|
||||
style={{
|
||||
width: `calc(${midBreak ? 'var(--gutter-h)' : 'var(--nav-width)'} + ${drawerDepth - 1} * 25px)`,
|
||||
}}
|
||||
aria-label={t('close')}
|
||||
/>
|
||||
<div className={`${baseClass}__content`}>
|
||||
<div className={`${baseClass}__content-children`}>
|
||||
<EditDepthContext.Provider value={drawerDepth + 1}>
|
||||
{children}
|
||||
</EditDepthContext.Provider>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -12,4 +12,5 @@ export type TogglerProps = HTMLAttributes<HTMLButtonElement> & {
|
||||
formatSlug?: boolean
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
310
src/admin/components/elements/ListDrawer/DrawerContent.tsx
Normal file
310
src/admin/components/elements/ListDrawer/DrawerContent.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
97
src/admin/components/elements/ListDrawer/index.scss
Normal file
97
src/admin/components/elements/ListDrawer/index.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
120
src/admin/components/elements/ListDrawer/index.tsx
Normal file
120
src/admin/components/elements/ListDrawer/index.tsx
Normal 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,
|
||||
];
|
||||
};
|
||||
38
src/admin/components/elements/ListDrawer/types.ts
Normal file
38
src/admin/components/elements/ListDrawer/types.ts
Normal 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
|
||||
}
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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 previewURL = await generatePreviewURL(data, { locale, token });
|
||||
setUrl(previewURL);
|
||||
};
|
||||
const handleClick = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
makeRequest();
|
||||
}
|
||||
}, [
|
||||
generatePreviewURL,
|
||||
locale,
|
||||
token,
|
||||
data,
|
||||
]);
|
||||
let url = `${serverURL}${api}`;
|
||||
if (collection) url = `${url}/${collection.slug}/${id}`;
|
||||
if (global) url = `${url}/globals/${global.slug}`;
|
||||
|
||||
if (url) {
|
||||
return (
|
||||
<Button
|
||||
el="anchor"
|
||||
className={baseClass}
|
||||
buttonStyle="secondary"
|
||||
url={url}
|
||||
newTab
|
||||
>
|
||||
{t('preview')}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
const data = await fetch(`${url}?draft=true&locale=${locale}&fallback-locale=null`).then((res) => res.json());
|
||||
const previewURL = await generatePreviewURL(data, { locale, token });
|
||||
setIsLoading(false);
|
||||
|
||||
return null;
|
||||
window.open(previewURL, '_blank');
|
||||
}, [serverURL, api, collection, global, id, generatePreviewURL, locale, token]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={baseClass}
|
||||
buttonStyle="secondary"
|
||||
onClick={handleClick}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? t('general:loading') : t('preview')}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default PreviewButton;
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { Data } from '../../forms/Form/types';
|
||||
import { GeneratePreviewURL } from '../../../../config/types';
|
||||
|
||||
export type Props = {
|
||||
generatePreviewURL?: GeneratePreviewURL,
|
||||
data?: Data
|
||||
}
|
||||
|
||||
@@ -11,5 +11,6 @@
|
||||
&__text {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
.step-nav {
|
||||
display: flex;
|
||||
overflow: auto;
|
||||
|
||||
* {
|
||||
display: block;
|
||||
|
||||
@@ -38,4 +38,8 @@ $caretSize: 6;
|
||||
transition: opacity .2s ease-in-out;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@include mid-break {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
17
src/admin/components/forms/Form/createNestedFieldPath.ts
Normal file
17
src/admin/components/forms/Form/createNestedFieldPath.ts
Normal 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 '';
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -60,16 +60,16 @@ const Group: React.FC<Props> = (props) => {
|
||||
<GroupProvider>
|
||||
<div className={`${baseClass}__wrap`}>
|
||||
{(label || description) && (
|
||||
<header className={`${baseClass}__header`}>
|
||||
{label && (
|
||||
<h3 className={`${baseClass}__title`}>{getTranslation(label, i18n)}</h3>
|
||||
)}
|
||||
<FieldDescription
|
||||
className={`field-description-${path.replace(/\./gi, '__')}`}
|
||||
value={null}
|
||||
description={description}
|
||||
/>
|
||||
</header>
|
||||
<header className={`${baseClass}__header`}>
|
||||
{label && (
|
||||
<h3 className={`${baseClass}__title`}>{getTranslation(label, i18n)}</h3>
|
||||
)}
|
||||
<FieldDescription
|
||||
className={`field-description-${path.replace(/\./gi, '__')}`}
|
||||
value={null}
|
||||
description={description}
|
||||
/>
|
||||
</header>
|
||||
)}
|
||||
<RenderFields
|
||||
permissions={permissions?.fields}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
@import '../../../../scss/styles.scss';
|
||||
|
||||
.rich-text__button {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
|
||||
svg {
|
||||
width: base(.75);
|
||||
height: base(.75);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
@@ -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)),
|
||||
});
|
||||
};
|
||||
@@ -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) {
|
||||
Transforms.unwrapNodes(editor, {
|
||||
match: (n) => Element.isElement(n) && n.type === 'li',
|
||||
split: true,
|
||||
mode: 'lowest',
|
||||
});
|
||||
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, {
|
||||
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 {
|
||||
Transforms.unsetNodes(editor, ['type']);
|
||||
unwrapList(editor, listPath);
|
||||
}
|
||||
} else {
|
||||
Transforms.unwrapNodes(editor, {
|
||||
match: (n) => Element.isElement(n) && n.type === indentType,
|
||||
split: true,
|
||||
mode: 'lowest',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,12 +116,63 @@ const indent = {
|
||||
const isCurrentlyUL = isElementActive(editor, 'ul');
|
||||
|
||||
if (isCurrentlyOL || isCurrentlyUL) {
|
||||
Transforms.wrapNodes(editor, {
|
||||
type: 'li',
|
||||
children: [],
|
||||
});
|
||||
Transforms.wrapNodes(editor, { type: isCurrentlyOL ? 'ol' : 'ul', children: [{ text: ' ' }] });
|
||||
Transforms.setNodes(editor, { type: 'li' });
|
||||
// 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,
|
||||
});
|
||||
|
||||
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: [] });
|
||||
}
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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 === '';
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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[]
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@@ -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 [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 handleAddRelationship = useCallback(async (_, { relationTo, value }) => {
|
||||
setLoading(true);
|
||||
|
||||
const res = await requests.get(`${serverURL}${api}/${relationTo}/${value}?depth=0`, {
|
||||
headers: {
|
||||
'Accept-Language': i18n.language,
|
||||
const onSelect = useCallback(({ docID, collectionConfig }) => {
|
||||
insertRelationship(editor, {
|
||||
value: {
|
||||
id: docID,
|
||||
},
|
||||
relationTo: collectionConfig.slug,
|
||||
});
|
||||
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]);
|
||||
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>
|
||||
<ElementButton
|
||||
className={baseClass}
|
||||
format="relationship"
|
||||
onClick={() => setRenderModal(true)}
|
||||
>
|
||||
<RelationshipIcon />
|
||||
</ElementButton>
|
||||
{renderModal && (
|
||||
<Modal
|
||||
slug={modalSlug}
|
||||
className={`${baseClass}__modal`}
|
||||
<ListDrawerToggler>
|
||||
<ElementButton
|
||||
className={baseClass}
|
||||
format="relationship"
|
||||
tooltip={t('addRelationship')}
|
||||
el="div"
|
||||
onClick={() => {
|
||||
// do nothing
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
<RelationshipIcon />
|
||||
</ElementButton>
|
||||
</ListDrawerToggler>
|
||||
<ListDrawer onSelect={onSelect} />
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) })}
|
||||
</div>
|
||||
<h5>{data[relatedCollection?.admin?.useAsTitle || 'id']}</h5>
|
||||
</p>
|
||||
<p className={`${baseClass}__title`}>
|
||||
{data[relatedCollection?.admin?.useAsTitle || 'id']}
|
||||
</p>
|
||||
</div>
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -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 }, {
|
||||
at: Editor.unhangRange(editor, editor.selection),
|
||||
});
|
||||
|
||||
Transforms.setNodes(editor, { type });
|
||||
|
||||
if (!isActive && isList) {
|
||||
const block = { type: format, children: [] };
|
||||
Transforms.wrapNodes(editor, block);
|
||||
}
|
||||
|
||||
ReactEditor.focus(editor);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
@import '../../../../../../../scss/styles.scss';
|
||||
|
||||
.upload-rich-text-button {
|
||||
.btn {
|
||||
margin-right: base(1);
|
||||
}
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
<ElementButton
|
||||
className={baseClass}
|
||||
format="upload"
|
||||
onClick={() => setRenderModal(true)}
|
||||
>
|
||||
<UploadIcon />
|
||||
</ElementButton>
|
||||
{renderModal && (
|
||||
<Modal
|
||||
className={baseModalClass}
|
||||
slug={modalSlug}
|
||||
<ListDrawerToggler>
|
||||
<ElementButton
|
||||
className={baseClass}
|
||||
format="upload"
|
||||
tooltip={t('fields:addUpload')}
|
||||
el="div"
|
||||
onClick={() => {
|
||||
// do nothing
|
||||
}}
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
<UploadIcon />
|
||||
</ElementButton>
|
||||
</ListDrawerToggler>
|
||||
<ListDrawer onSelect={onSelect} />
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
Reference in New Issue
Block a user