Merge remote-tracking branch 'origin' into feat/1695-nullable-localized-array-and-blocks

This commit is contained in:
Jarrod Flesch
2023-01-09 16:19:11 -05:00
250 changed files with 7253 additions and 5608 deletions

View File

@@ -1,5 +1,96 @@
## [1.5.2](https://github.com/payloadcms/payload/compare/v1.5.1...v1.5.2) (2023-01-04)
### Bug Fixes
* ignores admin and components from swc ([7d27431](https://github.com/payloadcms/payload/commit/7d274313129c44618ebd8d1fd7a176694ee40476))
## [1.5.1](https://github.com/payloadcms/payload/compare/v1.5.0...v1.5.1) (2023-01-04)
### Bug Fixes
* reverts components directory back to ts ([1bbf099](https://github.com/payloadcms/payload/commit/1bbf099fe052e767512e111f8f2b778c1b9c59d9))
# [1.5.0](https://github.com/payloadcms/payload/compare/v1.4.2...v1.5.0) (2023-01-04)
### Bug Fixes
* json field type ([73b8ba3](https://github.com/payloadcms/payload/commit/73b8ba3d4a86385cd0a80efcdc19e4972d16b0b7))
### Features
* adds initial json field ([28d9f90](https://github.com/payloadcms/payload/commit/28d9f9009cc479b0e5da5c5b4fb85eb29b055309))
* fixes json editor errors and misc styling ([efe4f6d](https://github.com/payloadcms/payload/commit/efe4f6d861a99337cfd35592557d3e8f16ff924a))
* swc register ([#1779](https://github.com/payloadcms/payload/issues/1779)) ([c11bcd1](https://github.com/payloadcms/payload/commit/c11bcd1416b19e48569218d9011d013ad77306ce))
* updates code field editor ([4d6eba8](https://github.com/payloadcms/payload/commit/4d6eba8d21d19eac63df02d56d27b0a17006d042))
* wires up i18n with monaco editor ([07b2cca](https://github.com/payloadcms/payload/commit/07b2ccad61a619478f6613fa65f4f630222639d4))
## [1.4.2](https://github.com/payloadcms/payload/compare/v1.4.1...v1.4.2) (2023-01-03)
### Bug Fixes
* [#1775](https://github.com/payloadcms/payload/issues/1775) - siblingData for unnamed fields within array rows improperly formatted ([d6fcd19](https://github.com/payloadcms/payload/commit/d6fcd19bd1eaf2942c2eaa31f0de4770ca10ff06))
* [#1786](https://github.com/payloadcms/payload/issues/1786), relationship with hasMany no longer sets empty array as default value ([ecfb363](https://github.com/payloadcms/payload/commit/ecfb36316961ef0eb9dd1ba1dc95ba98f95223f8))
* error clearing date field ([883daf7](https://github.com/payloadcms/payload/commit/883daf7b469c03fae67c16292af6aded662c0bd0))
* select field crash on missing value option ([ec9196e](https://github.com/payloadcms/payload/commit/ec9196e33ca01e6a15097943b4be6dee6ea5202f))
### Features
* add Ukrainian translation ([#1767](https://github.com/payloadcms/payload/issues/1767)) ([49fa5cb](https://github.com/payloadcms/payload/commit/49fa5cb23a0bb57348d8cd7ec0b7805d651fda2d))
* preview now exposes most recent draft data ([54dadbe](https://github.com/payloadcms/payload/commit/54dadbeae5b195405a7cfb480fd38b2eeb684938))
## [1.4.1](https://github.com/payloadcms/payload/compare/v1.4.0...v1.4.1) (2022-12-24)
### Bug Fixes
* [#1761](https://github.com/payloadcms/payload/issues/1761), avoids rich text modifying form due to selection change ([9f4ce8d](https://github.com/payloadcms/payload/commit/9f4ce8d756742a6e1b2644ea49d0778774aae457))
# [1.4.0](https://github.com/payloadcms/payload/compare/v1.3.4...v1.4.0) (2022-12-23)
### Bug Fixes
* [#1611](https://github.com/payloadcms/payload/issues/1611), unable to query draft versions with draft=true ([44b31a9](https://github.com/payloadcms/payload/commit/44b31a9e585aad515557b749bf05253139a17bd9))
* [#1656](https://github.com/payloadcms/payload/issues/1656) remove size data ([389ee26](https://github.com/payloadcms/payload/commit/389ee261d4ebae0b773bca375ed8a74685506aa0))
* [#1698](https://github.com/payloadcms/payload/issues/1698) - globals and autosave not working ([915f1e2](https://github.com/payloadcms/payload/commit/915f1e2b3a0c9618d5699a0ee6f5e74c6f4038ee))
* [#1738](https://github.com/payloadcms/payload/issues/1738) save image dimensions to svg uploads ([2de435f](https://github.com/payloadcms/payload/commit/2de435f43a2e75391a655e91a0cda251da776bcb))
* [#1747](https://github.com/payloadcms/payload/issues/1747), rich text in arrays improperly updating initialValue when moving rows ([d417e50](https://github.com/payloadcms/payload/commit/d417e50d52fc0824fb5aaedd3e1208c3e1468bdd))
* [#1748](https://github.com/payloadcms/payload/issues/1748), bails out of autosave if doc is published while autosaving ([95e9300](https://github.com/payloadcms/payload/commit/95e9300d109c9bfd377d5b5efbb68ddca306bbec))
* [#1752](https://github.com/payloadcms/payload/issues/1752), removes label from row field type ([ff3ab18](https://github.com/payloadcms/payload/commit/ff3ab18d1690e50473be2d77897fb9de48361413))
* [#551](https://github.com/payloadcms/payload/issues/551) - rich text nested list structure ([542ea8e](https://github.com/payloadcms/payload/commit/542ea8eb81a6e608c7368882da9692d656f1d36b))
* allows cleared file to be reselected ([35abe81](https://github.com/payloadcms/payload/commit/35abe811c1534ba4f7e926edd3a2978ee67b181e))
* get relationships in locale of i18n language setting ([#1648](https://github.com/payloadcms/payload/issues/1648)) ([60bb265](https://github.com/payloadcms/payload/commit/60bb2652f0aa63747513e771173362985123519c))
* missing file after reselect in upload component ([6bc1758](https://github.com/payloadcms/payload/commit/6bc1758dc0cad3f52ce332e71134ee527e17fff0))
* prevents special characters breaking relationship field search ([#1710](https://github.com/payloadcms/payload/issues/1710)) ([9af4c1d](https://github.com/payloadcms/payload/commit/9af4c1dde7f4a68dc629738dff4fc314626cabb8))
* refreshes document drawer on save ([9567328](https://github.com/payloadcms/payload/commit/9567328d28709c5721b33e5bd61c9535568ffffd))
* removes update and created at fields when duplicating, ensures updatedAt data is reactive ([bd4ed5b](https://github.com/payloadcms/payload/commit/bd4ed5b99b5026544c910592c3bff6040e2058bc))
* safely clears sort [#1736](https://github.com/payloadcms/payload/issues/1736) ([341c163](https://github.com/payloadcms/payload/commit/341c163b36c330df76a6eb5146fccc80059eb9d7))
* simplifies radio validation ([0dfed3b](https://github.com/payloadcms/payload/commit/0dfed3b30a15829f9454332a4cbd7d9ce1fddea3))
* translated tab classnames ([238bada](https://github.com/payloadcms/payload/commit/238badabb4f38e691608219c54a541993d9f3010))
* updates relationship label on drawer save and prevents stepnav update ([59de4f7](https://github.com/payloadcms/payload/commit/59de4f7e82dc4f08240b13d48054589b561688fa))
* updates richtext toolbar position if inside a drawer ([468b0d2](https://github.com/payloadcms/payload/commit/468b0d2a55616993f10eac7d1709620d114ad7d6))
* use the slug for authentication header API Key ([5b70ebd](https://github.com/payloadcms/payload/commit/5b70ebd119b557cff66e97e3554af730657b4071))
### Features
* add Czech translation ([#1705](https://github.com/payloadcms/payload/issues/1705)) ([0be4285](https://github.com/payloadcms/payload/commit/0be428530512c3babdfe39be259dd165bb66b5f4))
* adds doc permissions to account view ([8d643fb](https://github.com/payloadcms/payload/commit/8d643fb29d3604b78f6cb46582720dde2a46affb))
* **graphql:** upgrade to graphql 16 ([57f5f5e](https://github.com/payloadcms/payload/commit/57f5f5ec439b5aee1d46bff0bf31aac6148f16b2))
### BREAKING CHANGES
* replaced the useAPIKey authentication header format to use the collection slug instead of the collection label. Previous: `${collection.labels.singular} API-Key ${apiKey}`, updated: `${collection.slug} API-Key ${apiKey}`
## [1.3.4](https://github.com/payloadcms/payload/compare/v1.3.3...v1.3.4) (2022-12-16)

101
README.md
View File

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

View File

@@ -1,3 +0,0 @@
const config = require('./src/babel.config');
module.exports = config;

View File

@@ -1,3 +0,0 @@
const babelConfig = require('./dist/babel.config');
exports.config = babelConfig;

View File

@@ -0,0 +1 @@
export type { Props } from '../../dist/admin/components/forms/field-types/JSON/types';

View File

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

View File

@@ -6,16 +6,16 @@ desc: Set up your Global config for your needs by defining fields, adding slugs
keywords: globals, config, configuration, documentation, Content Management System, cms, headless, javascript, node, react, express
---
Global configs are in many ways similar to [Collections](/docs/configuration/collections). The big difference is that Collections will potentially contain *many* documents, while a Global is a "one-off". Globals are perfect for things like header nav, site-wide banner alerts, app-wide localized strings, and other "global" data that your site or app might rely on.
Global configs are in many ways similar to [Collections](/docs/configuration/collections). The big difference is that Collections will potentially contain _many_ documents, while a Global is a "one-off". Globals are perfect for things like header nav, site-wide banner alerts, app-wide localized strings, and other "global" data that your site or app might rely on.
As with Collection configs, it's often best practice to write your Globals in separate files and then import them into the main Payload config.
## Options
| Option | Description |
|--------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **`slug`** * | Unique, URL-friendly string that will act as an identifier for this Global. |
| **`fields`** * | Array of field types that will determine the structure and functionality of the data stored within this Global. [Click here](/docs/fields/overview) for a full list of field types as well as how to configure them. |
| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`slug`** \* | Unique, URL-friendly string that will act as an identifier for this Global. |
| **`fields`** \* | Array of field types that will determine the structure and functionality of the data stored within this Global. [Click here](/docs/fields/overview) for a full list of field types as well as how to configure them. |
| **`label`** | Text for the name in the Admin panel or an object with keys for each language. Auto-generated from slug if not defined. |
| **`description`** | Text or React component to display below the Global header to give editors more information. |
| **`admin`** | Admin-specific configuration. See below for [more detail](/docs/configuration/globals#admin-options). |
@@ -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.

View File

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

View File

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

View File

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

View File

@@ -11,13 +11,13 @@ keywords: code, fields, config, configuration, documentation, Content Management
The Code field type saves a string in the database, but provides the Admin panel with a code editor styled interface.
</Banner>
This field uses `prismjs` for syntax highlighting and `react-simple-code-editor` for the editor itself.
This field uses the `monaco-react` editor syntax highlighting.
### Config
| 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. |
@@ -33,25 +33,16 @@ This field uses `prismjs` for syntax highlighting and `react-simple-code-editor`
| **`required`** | Require this field to have a value. |
| **`admin`** | Admin-specific configuration. See below for [more detail](#admin-config). |
*\* An asterisk denotes that a property is required.*
_\* An asterisk denotes that a property is required._
### Admin config
### Admin Config
In addition to the default [field admin config](/docs/fields/overview#admin-config), the Code field type also allows for the customization of a `language` property.
In addition to the default [field admin config](/docs/fields/overview#admin-config), you can adjust the following properties:
The following `prismjs` plugins are imported, enabling the `language` property to accept the following values:
| Plugin | Language |
| ---------------------------- | ----------- |
| **`prism-css`** | `css` |
| **`prism-clike`** | `clike` |
| **`prism-markup`** | `markup`, `html`, `xml`, `svg`, `mathml`, `ssml`, `atom`, `rss` |
| **`prism-javascript`** | `javascript`, `js` |
| **`prism-json`** | `json` |
| **`prism-jsx`** | `jsx` |
| **`prism-typescript`** | `typescript`, `ts` |
| **`prism-tsx`** | `tsx` |
| **`prism-yaml`** | `yaml`, `yml` |
| Option | Description |
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`language`** | This property can be set to any language listed [here](https://github.com/microsoft/monaco-editor/tree/main/src/basic-languages). |
| **`editorOptions`** | Options that can be passed to the monaco editor, [view the full list](https://microsoft.github.io/monaco-editor/api/interfaces/monaco.editor.IDiffEditorConstructionOptions.html). |
### Example
@@ -67,7 +58,7 @@ const ExampleCollection: CollectionConfig = {
type: 'code', // required
required: true,
admin: {
language: 'js'
language: 'javascript'
}
}
]

View File

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

View File

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

View File

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

60
docs/fields/json.mdx Normal file
View File

@@ -0,0 +1,60 @@
---
title: JSON Field
label: JSON
order: 50
desc: The JSON field type will store any string in the Database. Learn how to use JSON fields, see examples and options.
keywords: json, fields, config, configuration, documentation, Content Management System, cms, headless, javascript, node, react, express
---
<Banner >
The JSON field type saves actual JSON in the database, which differs from the Code field that saves the value as a string in the database.
</Banner>
This field uses the `monaco-react` editor syntax highlighting.
### Config
| Option | Description |
| ---------------- | ----------- |
| **`name`** * | To be used as the property name when stored and retrieved from the database. [More](/docs/fields/overview#field-names) |
| **`label`** | Text used as a field label in the Admin panel or an object with keys for each language. |
| **`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. |
| **`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. |
| **`hooks`** | Provide field-based hooks to control logic for this field. [More](/docs/fields/overview#field-level-hooks) |
| **`access`** | Provide field-based access control to denote what users can see and do with this field's data. [More](/docs/fields/overview#field-level-access-control) |
| **`hidden`** | Restrict this field's visibility from all APIs entirely. Will still be saved to the database, but will not appear in any API or the Admin panel. |
| **`defaultValue`** | Provide data to be used for this field's default value. [More](/docs/fields/overview#default-values) |
| **`localized`** | Enable localization for this field. Requires [localization to be enabled](/docs/configuration/localization) in the Base config. |
| **`required`** | Require this field to have a value. |
| **`admin`** | Admin-specific configuration. See below for [more detail](#admin-config). |
_\* An asterisk denotes that a property is required._
### Admin Config
In addition to the default [field admin config](/docs/fields/overview#admin-config), you can adjust the following properties:
| Option | Description |
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`editorOptions`** | Options that can be passed to the monaco editor, [view the full list](https://microsoft.github.io/monaco-editor/api/interfaces/monaco.editor.IDiffEditorConstructionOptions.html). |
### Example
`collections/ExampleCollection.ts
```ts
import { CollectionConfig } from 'payload/types';
const ExampleCollection: CollectionConfig = {
slug: 'example-collection',
fields: [
{
name: 'customerJSON', // required
type: 'json', // required
required: true,
}
]
};
```

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,12 @@
module.exports = {
verbose: true,
testTimeout: 15000,
testEnvironment: 'jsdom',
testRegex: '(/src/admin/.*\\.(test|spec))\\.[jt]sx?$',
setupFilesAfterEnv: ['<rootDir>/test/componentsSetup.js'],
transform: {
'^.+\\.(t|j)sx?$': ['@swc/jest'],
},
testPathIgnorePatterns: [
'node_modules',
'dist',

View File

@@ -5,6 +5,9 @@ module.exports = {
'**/src/**/*.spec.ts',
'**/test/**/*int.spec.ts',
],
transform: {
'^.+\\.(t|j)sx?$': ['@swc/jest'],
},
globalSetup: './test/jest.setup.ts',
testTimeout: 90000,
moduleNameMapper: {

View File

@@ -1,6 +1,6 @@
{
"name": "payload",
"version": "1.3.4",
"version": "1.5.2",
"description": "Node, React and MongoDB Headless CMS and Application Framework",
"license": "MIT",
"engines": {
@@ -32,7 +32,7 @@
},
"scripts": {
"copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,ttf,woff,woff2,eot,svg,jpg,png}\" dist/",
"build:tsc": "tsc --p tsconfig.admin.json && tsc --p tsconfig.server.json",
"build:tsc": "tsc",
"build:components": "webpack --config dist/webpack/components.config.js",
"build": "yarn copyfiles && yarn build:tsc && yarn build:components",
"build:watch": "nodemon --watch 'src/**' --ext 'ts,tsx' --exec \"yarn build:tsc\"",
@@ -80,26 +80,15 @@
"auth"
],
"dependencies": {
"@babel/cli": "^7.12.8",
"@babel/core": "^7.11.6",
"@babel/node": "^7.12.6",
"@babel/plugin-proposal-class-properties": "^7.8.3",
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.14.2",
"@babel/plugin-transform-runtime": "^7.11.5",
"@babel/preset-env": "^7.8.3",
"@babel/preset-react": "^7.8.3",
"@babel/preset-typescript": "^7.12.1",
"@babel/register": "^7.11.5",
"@date-io/date-fns": "^2.10.6",
"@dnd-kit/core": "^6.0.5",
"@dnd-kit/sortable": "^7.0.1",
"@faceless-ui/modal": "^2.0.1",
"@faceless-ui/scroll-info": "^1.2.3",
"@faceless-ui/window-info": "^2.0.2",
"@types/is-plain-object": "^2.0.4",
"@types/sharp": "^0.26.1",
"babel-jest": "^26.3.0",
"babel-loader": "^8.1.0",
"@monaco-editor/react": "^4.4.6",
"@swc/core": "^1.3.24",
"@swc/register": "^0.1.10",
"body-parser": "^1.19.0",
"bson-objectid": "^2.0.1",
"compression": "^1.7.4",
@@ -121,7 +110,7 @@
"find-up": "4.1.0",
"flatley": "^5.2.0",
"fs-extra": "^10.0.0",
"graphql": "15.4.0",
"graphql": "^16.6.0",
"graphql-playground-middleware-express": "^1.7.14",
"graphql-query-complexity": "^0.7.0",
"graphql-scalars": "^1.4.0",
@@ -134,10 +123,9 @@
"is-hotkey": "^0.2.0",
"is-plain-object": "^5.0.0",
"isomorphic-fetch": "^3.0.0",
"jest": "^26.6.3",
"joi": "^17.3.0",
"json-schema-to-typescript": "^11.0.2",
"jsonwebtoken": "^8.5.1",
"jsonwebtoken": "^9.0.0",
"jwt-decode": "^3.1.2",
"md5": "^2.3.0",
"method-override": "^3.0.0",
@@ -146,6 +134,7 @@
"minimist": "^1.2.0",
"mkdirp": "^1.0.4",
"mongoose": "6.5.0",
"mongoose-aggregate-paginate-v2": "^1.0.6",
"mongoose-paginate-v2": "^1.6.1",
"nodemailer": "^6.4.2",
"object-to-formdata": "^4.1.0",
@@ -189,6 +178,8 @@
"slate-hyperscript": "^0.66.0",
"slate-react": "^0.72.1",
"style-loader": "^2.0.0",
"swc-loader": "^0.2.3",
"swc-minify-webpack-plugin": "^1.0.1",
"terser-webpack-plugin": "^5.0.3",
"ts-essentials": "^7.0.1",
"url-loader": "^4.1.1",
@@ -203,13 +194,11 @@
"devDependencies": {
"@playwright/test": "^1.23.1",
"@release-it/conventional-changelog": "^5.1.1",
"@swc/jest": "^0.2.24",
"@testing-library/jest-dom": "^5.11.4",
"@testing-library/react": "^13.0.1",
"@trbl/eslint-config": "^1.2.4",
"@types/asap": "^2.0.0",
"@types/babel__core": "^7.1.12",
"@types/babel__plugin-transform-runtime": "^7.9.1",
"@types/babel__preset-env": "^7.9.1",
"@types/body-parser": "^1.19.0",
"@types/compression": "^1.7.0",
"@types/conf": "^3.0.0",
@@ -234,6 +223,7 @@
"@types/mini-css-extract-plugin": "^1.2.1",
"@types/minimist": "^1.2.1",
"@types/mkdirp": "^1.0.1",
"@types/mongoose-aggregate-paginate-v2": "^1.0.5",
"@types/mongoose-paginate-v2": "^1.3.8",
"@types/node-fetch": "^2.5.7",
"@types/nodemailer": "^6.4.0",
@@ -258,6 +248,7 @@
"@types/react-router-dom": "^5.1.6",
"@types/react-select": "^3.0.26",
"@types/sass": "^1.16.0",
"@types/sharp": "^0.26.1",
"@types/shelljs": "^0.8.11",
"@types/testing-library__jest-dom": "^5.9.5",
"@types/uuid": "^8.3.0",
@@ -268,8 +259,6 @@
"@types/webpack-hot-middleware": "2.25.3",
"@typescript-eslint/eslint-plugin": "^4.8.1",
"@typescript-eslint/parser": "4.0.1",
"babel-eslint": "^10.0.1",
"babel-plugin-ignore-html-and-css-imports": "^0.1.0",
"copyfiles": "^2.4.0",
"cross-env": "^7.0.2",
"eslint": "^6.8.0",
@@ -284,7 +273,10 @@
"get-port": "5.1.1",
"glob": "^8.0.3",
"graphql-request": "^3.4.0",
"jest": "^29.3.1",
"jest-environment-jsdom": "^29.3.1",
"mongodb-memory-server": "^7.2.0",
"node-fetch": "2",
"nodemon": "^2.0.6",
"passport-strategy": "^1.0.0",
"release-it": "^15.5.0",

View File

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

After

Width:  |  Height:  |  Size: 8.9 KiB

View File

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

After

Width:  |  Height:  |  Size: 8.9 KiB

View File

@@ -31,12 +31,18 @@ const Autosave: React.FC<Props> = ({ collection, global, id, publishedDocUpdated
const [lastSaved, setLastSaved] = useState<number>();
const debouncedFields = useDebounce(fields, interval);
const fieldRef = useRef(fields);
const modifiedRef = useRef(modified);
// Store fields in ref so the autosave func
// can always retrieve the most to date copies
// after the timeout has executed
fieldRef.current = fields;
// Store modified in ref so the autosave func
// can bail out if modified becomes false while
// timing out during autosave
modifiedRef.current = modified;
const createCollectionDoc = useCallback(async () => {
const res = await fetch(`${serverURL}${api}/${collection.slug}?locale=${locale}&fallback-locale=null&depth=0&draft=true`, {
method: 'POST',
@@ -95,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]) {

View File

@@ -0,0 +1,21 @@
@import '../../../scss/styles';
.code-editor {
@include formInput;
height: auto;
padding: 0;
.monaco-editor {
.view-overlays .current-line {
max-width: calc(100% - 14px);
border-width: 0px;
}
&:focus-within {
.view-overlays .current-line {
border-right: 0;
border-width: 1px;
}
}
}
}

View File

@@ -0,0 +1,44 @@
import React from 'react';
import Editor from '@monaco-editor/react';
import type { Props } from './types';
import { useTheme } from '../../utilities/Theme';
import './index.scss';
const baseClass = 'code-editor';
const CodeEditor: React.FC<Props> = (props) => {
const { readOnly, className, options, ...rest } = props;
const { theme } = useTheme();
const classes = [
baseClass,
className,
rest?.defaultLanguage ? `language--${rest.defaultLanguage}` : '',
].filter(Boolean).join(' ');
return (
<Editor
height="35vh"
className={classes}
theme={theme === 'dark' ? 'vs-dark' : 'vs'}
options={
{
detectIndentation: true,
minimap: {
enabled: false,
},
readOnly: Boolean(readOnly),
scrollBeyondLastLine: false,
tabSize: 2,
wordWrap: 'on',
...options,
}
}
{...rest}
/>
);
};
export default CodeEditor;

View File

@@ -0,0 +1,5 @@
import type { EditorProps } from '@monaco-editor/react';
export type Props = EditorProps & {
readOnly?: boolean
}

View File

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

View File

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

View File

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

View File

@@ -2,34 +2,60 @@
.doc-drawer {
&__header {
margin-top: base(2.5);
margin-bottom: base(1);
@include mid-break {
margin-top: base(1.5);
}
}
&__header-content {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-top: base(2.5);
margin-bottom: base(1);
}
&__header-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;
}
}
}

View File

@@ -1,28 +1,16 @@
import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
import React, { useCallback, useEffect, useId, useMemo, useState } from 'react';
import { useModal } from '@faceless-ui/modal';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import { DocumentDrawerProps, DocumentTogglerProps, UseDocumentDrawer } from './types';
import DefaultEdit from '../../views/collections/Edit/Default';
import X from '../../icons/X';
import { Fields } from '../../forms/Form/types';
import buildStateFromSchema from '../../forms/Form/buildStateFromSchema';
import { getTranslation } from '../../../../utilities/getTranslation';
import { Drawer, DrawerToggler } from '../Drawer';
import Button from '../Button';
import { useConfig } from '../../utilities/Config';
import { useLocale } from '../../utilities/Locale';
import { useAuth } from '../../utilities/Auth';
import { DocumentInfoProvider } from '../../utilities/DocumentInfo';
import RenderCustomComponent from '../../utilities/RenderCustomComponent';
import usePayloadAPI from '../../../hooks/usePayloadAPI';
import formatFields from '../../views/collections/Edit/formatFields';
import { useRelatedCollections } from '../../forms/field-types/Relationship/AddNew/useRelatedCollections';
import IDLabel from '../IDLabel';
import { useEditDepth } from '../../utilities/EditDepth';
import { DocumentDrawerContent } from './DrawerContent';
import './index.scss';
const baseClass = 'doc-drawer';
export const baseClass = 'doc-drawer';
const formatDocumentDrawerSlug = ({
collectionSlug,
@@ -42,6 +30,7 @@ export const DocumentDrawerToggler: React.FC<DocumentTogglerProps> = ({
drawerSlug,
id,
collectionSlug,
disabled,
...rest
}) => {
const { t, i18n } = useTranslation(['fields', 'general']);
@@ -51,7 +40,11 @@ export const DocumentDrawerToggler: React.FC<DocumentTogglerProps> = ({
<DrawerToggler
slug={drawerSlug}
formatSlug={false}
className={className}
className={[
className,
`${baseClass}__toggler`,
].filter(Boolean).join(' ')}
disabled={disabled}
aria-label={t(!id ? 'fields:addNewLabel' : 'general:editLabel', { label: getTranslation(collectionConfig.labels.singular, i18n) })}
{...rest}
>
@@ -60,131 +53,24 @@ export const DocumentDrawerToggler: React.FC<DocumentTogglerProps> = ({
);
};
export const DocumentDrawer: React.FC<DocumentDrawerProps> = ({
collectionSlug,
id,
drawerSlug,
onSave,
customHeader,
}) => {
const { serverURL, routes: { api } } = useConfig();
const { toggleModal, modalState, closeModal } = useModal();
const locale = useLocale();
const { permissions, user } = useAuth();
const [initialState, setInitialState] = useState<Fields>();
const { t, i18n } = useTranslation(['fields', 'general']);
const hasInitializedState = useRef(false);
const [isOpen, setIsOpen] = useState(false);
const [collectionConfig] = useRelatedCollections(collectionSlug);
export const DocumentDrawer: React.FC<DocumentDrawerProps> = (props) => {
const { drawerSlug } = props;
const [fields, setFields] = useState(() => formatFields(collectionConfig, true));
useEffect(() => {
setFields(formatFields(collectionConfig, true));
}, [collectionSlug, collectionConfig]);
const [{ data, isLoading: isLoadingDocument, isError }] = usePayloadAPI(
(id ? `${serverURL}${api}/${collectionSlug}/${id}` : null),
{ initialParams: { 'fallback-locale': 'null', depth: 0, draft: 'true' } },
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,

View File

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

View File

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

View File

@@ -10,13 +10,13 @@ const baseClass = 'drawer';
const zBase = 100;
const formatDrawerSlug = ({
export const formatDrawerSlug = ({
slug,
depth,
}: {
slug: string,
depth: number,
}) => `drawer_${depth}_${slug}`;
}): string => `drawer_${depth}_${slug}`;
export const DrawerToggler: React.FC<TogglerProps> = ({
slug,
@@ -24,6 +24,7 @@ export const DrawerToggler: React.FC<TogglerProps> = ({
children,
className,
onClick,
disabled,
...rest
}) => {
const { openModal } = useModal();
@@ -39,6 +40,7 @@ export const DrawerToggler: React.FC<TogglerProps> = ({
onClick={handleClick}
type="button"
className={className}
disabled={disabled}
{...rest}
>
{children}
@@ -57,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;
};

View File

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

View File

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

View File

@@ -23,7 +23,11 @@
display: flex;
margin-left: - base(.5);
margin-right: - base(.5);
width: calc(100% + #{$baseline});
width: calc(100% + #{base(1)});
.btn {
margin: 0 base(.5);
}
}
&__toggle-columns,
@@ -31,10 +35,6 @@
&__toggle-sort {
min-width: 140px;
&.btn {
margin: 0 base(.5);
}
&.btn--style-primary {
svg {
transform: rotate(180deg);
@@ -52,6 +52,16 @@
&__buttons {
margin-left: base(.5);
}
&__buttons-wrap {
margin-left: - base(.25);
margin-right: - base(.25);
width: calc(100% + #{base(0.5)});
.btn {
margin: 0 base(.25);
}
}
}
@include small-break {

View File

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

View File

@@ -0,0 +1,97 @@
@import '../../../scss/styles.scss';
.list-drawer {
&__header {
margin-top: base(2.5);
width: 100%;
@include mid-break {
margin-top: base(1.5);
}
}
&__header-wrap {
display: flex;
}
&__header-content {
display: flex;
flex-wrap: wrap;
flex-grow: 1;
align-items: flex-start;
.pill {
pointer-events: none;
margin: 0;
margin-top: base(0.25);
margin-left: base(0.5);
@include mid-break {
margin-top: 0;
}
}
}
&__header-text {
margin: 0;
}
&__toggler {
background: transparent;
border: 0;
padding: 0;
cursor: pointer;
&:focus,
&:focus-within {
outline: none;
}
&:disabled {
pointer-events: none;
}
}
&__header-close {
border: 0;
background-color: transparent;
padding: 0;
cursor: pointer;
overflow: hidden;
width: base(1);
height: base(1);
svg {
width: base(2.75);
height: base(2.75);
position: relative;
left: base(-.825);
top: base(-.825);
.stroke {
stroke-width: 2px;
vector-effect: non-scaling-stroke;
}
}
}
&__select-collection-wrap {
margin-top: base(1);
}
&__first-cell {
border: 0;
background-color: transparent;
padding: 0;
cursor: pointer;
text-decoration: underline;
text-align: left;
white-space: nowrap;
}
@include mid-break {
.collection-list__header {
margin-bottom: base(0.5);
}
}
}

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,11 @@
import React, { useEffect, useState } from 'react';
import React, { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../../utilities/Auth';
import Button from '../Button';
import { Props } from './types';
import { useLocale } from '../../utilities/Locale';
import { useDocumentInfo } from '../../utilities/DocumentInfo';
import { useConfig } from '../../utilities/Config';
import './index.scss';
@@ -12,46 +14,40 @@ const baseClass = 'preview-btn';
const PreviewButton: React.FC<Props> = (props) => {
const {
generatePreviewURL,
data,
} = props;
const [url, setUrl] = useState<string | undefined>(undefined);
const { id, collection, global } = useDocumentInfo();
const [isLoading, setIsLoading] = useState(false);
const locale = useLocale();
const { token } = useAuth();
const { serverURL, routes: { api } } = useConfig();
const { t } = useTranslation('version');
useEffect(() => {
if (generatePreviewURL && typeof generatePreviewURL === 'function') {
const makeRequest = async () => {
const 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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -84,6 +84,10 @@ const fieldTypeConditions = {
component: 'Text',
operators: [...base, like, contains],
},
json: {
component: 'Text',
operators: [...base, like, contains],
},
richText: {
component: 'Text',
operators: [...base, like, contains],

View File

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

View File

@@ -9,7 +9,7 @@
transform: none;
background-color: var(--theme-error-500);
&:after {
&::after {
border-top-color: var(--theme-error-500);
}
}

View File

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

View File

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

View File

@@ -17,7 +17,6 @@ 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';
@@ -25,12 +24,12 @@ import { scrollToID } from '../../../../utilities/scrollToID';
import HiddenInput from '../HiddenInput';
import { RowLabel } from '../../RowLabel';
import { getTranslation } from '../../../../../utilities/getTranslation';
import { createNestedFieldPath } from '../../Form/createNestedFieldPath';
import { useConfig } from '../../../utilities/Config';
import { NullifyField } from '../../NullifyField';
import './index.scss';
const baseClass = 'array-field';
const ArrayFieldType: React.FC<Props> = (props) => {
@@ -331,7 +330,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),
}))}
/>
@@ -341,26 +340,25 @@ const ArrayFieldType: React.FC<Props> = (props) => {
</Draggable>
);
})}
{checkSkipValidation(value) && (
<React.Fragment>
{(rows.length < minRows || (required && rows.length === 0)) && (
<Banner type="error">
{t('validation:requiresAtLeast', {
count: minRows,
label: getTranslation(minRows
? labels.plural
: labels.singular,
i18n) || t(minRows > 1 ? 'general:row' : 'general:rows'),
})}
</Banner>
)}
{(rows.length === 0 && readOnly) && (
<Banner>
{t('validation:fieldHasNo', { label: getTranslation(labels.plural, i18n) })}
</Banner>
)}
</React.Fragment>
)}
{
checkSkipValidation(value) && (
<React.Fragment>
{(rows.length < minRows || (required && rows.length === 0)) && (
<Banner type="error">
{t('validation:requiresAtLeast', {
count: minRows,
label: getTranslation(minRows ? labels.plural : labels.singular, i18n) || t(minRows > 1 ? 'general:row' : 'general:rows'),
})}
</Banner>
)}
{(rows.length === 0 && readOnly) && (
<Banner>
{t('validation:fieldHasNo', { label: getTranslation(labels.plural, i18n) })}
</Banner>
)}
</React.Fragment>
)
}
{provided.placeholder}
</div>
)}

View File

@@ -23,7 +23,6 @@ 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';
@@ -31,6 +30,7 @@ import HiddenInput from '../HiddenInput';
import { getTranslation } from '../../../../../utilities/getTranslation';
import { NullifyField } from '../../NullifyField';
import { useConfig } from '../../../utilities/Config';
import { createNestedFieldPath } from '../../Form/createNestedFieldPath';
import './index.scss';
@@ -361,7 +361,7 @@ const BlocksField: React.FC<Props> = (props) => {
permissions={permissions?.fields}
fieldSchema={blockToRender.fields.map((field) => ({
...field,
path: `${path}.${i}${fieldAffectsData(field) ? `.${field.name}` : ''}`,
path: createNestedFieldPath(`${path}.${i}`, field),
}))}
indexPath={indexPath}
/>

View File

@@ -1,25 +1,21 @@
import React, { useCallback, useState } from 'react';
import Editor from 'react-simple-code-editor';
import { highlight, languages } from 'prismjs/components/prism-core';
import 'prismjs/components/prism-clike';
import 'prismjs/components/prism-css';
import 'prismjs/components/prism-markup';
import 'prismjs/components/prism-javascript';
import 'prismjs/components/prism-json';
import 'prismjs/components/prism-jsx';
import 'prismjs/components/prism-typescript';
import 'prismjs/components/prism-tsx';
import 'prismjs/components/prism-yaml';
import useField from '../../useField';
import withCondition from '../../withCondition';
import Label from '../../Label';
import React, { useCallback } from 'react';
import { code } from '../../../../../fields/validations';
import Error from '../../Error';
import FieldDescription from '../../FieldDescription';
import { code } from '../../../../../fields/validations';
import Label from '../../Label';
import { Props } from './types';
import useField from '../../useField';
import withCondition from '../../withCondition';
import CodeEditor from '../../../elements/CodeEditor';
import './index.scss';
const prismToMonacoLanguageMap = {
js: 'javascript',
ts: 'typescript',
};
const baseClass = 'code-field';
const Code: React.FC<Props> = (props) => {
@@ -36,18 +32,11 @@ const Code: React.FC<Props> = (props) => {
language,
description,
condition,
editorOptions,
} = {},
label,
} = props;
const [highlighter] = useState(() => {
if (languages[language]) {
return (content) => highlight(content, languages[language]);
}
return (content) => content;
});
const path = pathFromProps || name;
const memoizedValidate = useCallback((value, options) => {
@@ -90,18 +79,12 @@ const Code: React.FC<Props> = (props) => {
label={label}
required={required}
/>
<Editor
className={`${baseClass}__input`}
id={`field-${path.replace(/\./gi, '__')}`}
<CodeEditor
options={editorOptions}
defaultLanguage={prismToMonacoLanguageMap[language] || language}
value={value as string || ''}
onValueChange={readOnly ? () => null : setValue}
highlight={highlighter}
padding={25}
style={{
fontFamily: '"Consolas", "Monaco", monospace',
fontSize: 12,
pointerEvents: readOnly ? 'none' : 'auto',
}}
onChange={readOnly ? () => null : (val) => setValue(val)}
readOnly={readOnly}
/>
<FieldDescription
value={value}

View File

@@ -4,252 +4,9 @@
position: relative;
margin-bottom: $baseline;
&__input {
@include formInput();
height: unset;
}
&.error {
textarea {
border: 1px solid var(--theme-error-500) !important;
}
}
}
/**
* atom-dark theme for `prism.js`
* Based on Atom's `atom-dark` theme: https://github.com/atom/atom-dark-syntax
* @author Joe Gibson (@gibsjose)
*/
code[class*="language-"],
pre[class*="language-"] {
color: #c5c8c6;
text-shadow: 0 1px rgba(0, 0, 0, 0.3);
font-family: Inconsolata, Monaco, Consolas, "Courier New", Courier, monospace;
direction: ltr;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
line-height: 1.5;
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4;
-webkit-hyphens: none;
-moz-hyphens: none;
-ms-hyphens: none;
hyphens: none;
}
/* Code blocks */
pre[class*="language-"] {
padding: 1em;
margin: 0.5em 0;
overflow: auto;
border-radius: 0.3em;
}
/* Inline code */
:not(pre)>code[class*="language-"] {
padding: 0.1em;
border-radius: 0.3em;
}
.namespace {
opacity: 0.7;
}
.token.important,
.token.bold {
font-weight: bold;
}
.token.italic {
font-style: italic;
}
// DARK
html[data-theme=dark] {
:not(pre)>code[class*="language-"],
pre[class*="language-"] {
background: var(--theme-base-100);
}
.token.comment,
.token.prolog,
.token.doctype,
.token.cdata {
color: #7c7c7c;
}
.token.punctuation {
color: #c5c8c6;
}
.token.property,
.token.keyword,
.token.tag {
color: #96cbfe;
}
.token.class-name {
color: #ffffb6;
text-decoration: underline;
}
.token.boolean,
.token.constant {
color: #99cc99;
}
.token.symbol,
.token.deleted {
color: #f92672;
}
.token.number {
color: #ff73fd;
}
.token.selector,
.token.attr-name,
.token.string,
.token.char,
.token.builtin,
.token.inserted {
color: #a8ff60;
}
.token.variable {
color: #c6c5fe;
}
.token.operator {
color: #ededed;
}
.token.entity {
color: #ffffb6;
cursor: help;
}
.token.url {
color: #96cbfe;
}
.language-css .token.string,
.style .token.string {
color: #87c38a;
}
.token.atrule,
.token.attr-value {
color: #f9ee98;
}
.token.function {
color: #dad085;
}
.token.regex {
color: #e9c062;
}
.token.important {
color: #fd971f;
}
}
// LIGHT
html[data-theme=light] {
.token.comment,
.token.prolog,
.token.doctype,
.token.cdata {
color: #666666;
}
.token.punctuation {
color: #797979;
}
.token.property,
.token.keyword,
.token.tag {
color: #0167c5;
}
.token.class-name {
color: #b2ac00;
text-decoration: underline;
}
.token.boolean,
.token.constant {
color: #008600;
}
.token.symbol,
.token.deleted {
color: #ab003f;
}
.token.number {
color: #970195;
}
.token.selector,
.token.attr-name,
.token.string,
.token.char,
.token.builtin,
.token.inserted {
color: #418c03;
}
.token.variable {
color: #4643e3;
}
.token.operator {
color: #3c3c3c;
}
.token.entity {
color: #b2ac00;
cursor: help;
}
.token.url {
color: #0084ff;
}
.language-css .token.string,
.style .token.string {
color: #386b3a;
}
.token.atrule,
.token.attr-value {
color: #b5a108;
}
.token.function {
color: #8c1aea;
}
.token.regex {
color: #e5a205;
}
.token.important {
color: #ac0900;
}
}

View File

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

View File

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

View File

@@ -4,11 +4,11 @@ import RenderFields from '../../RenderFields';
import withCondition from '../../withCondition';
import FieldDescription from '../../FieldDescription';
import { Props } from './types';
import { fieldAffectsData } from '../../../../../fields/config/types';
import { useCollapsible } from '../../../elements/Collapsible/provider';
import { GroupProvider, useGroup } from './provider';
import { useTabs } from '../Tabs/provider';
import { getTranslation } from '../../../../../utilities/getTranslation';
import { createNestedFieldPath } from '../../Form/createNestedFieldPath';
import './index.scss';
@@ -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>

View File

@@ -0,0 +1,110 @@
import React, { useCallback, useEffect, useState } from 'react';
import Error from '../../Error';
import FieldDescription from '../../FieldDescription';
import { json } from '../../../../../fields/validations';
import Label from '../../Label';
import { Props } from './types';
import useField from '../../useField';
import withCondition from '../../withCondition';
import CodeEditor from '../../../elements/CodeEditor';
import './index.scss';
const baseClass = 'json-field';
const JSONField: React.FC<Props> = (props) => {
const {
path: pathFromProps,
name,
required,
validate = json,
admin: {
readOnly,
style,
className,
width,
description,
condition,
editorOptions,
} = {},
label,
} = props;
const path = pathFromProps || name;
const [stringValue, setStringValue] = useState<string>();
const [jsonError, setJsonError] = useState<string>();
const memoizedValidate = useCallback((value, options) => {
return validate(value, { ...options, required, jsonError });
}, [validate, required, jsonError]);
const {
value,
initialValue,
showError,
setValue,
errorMessage,
} = useField<string>({
path,
validate: memoizedValidate,
condition,
});
const handleChange = useCallback((val) => {
if (readOnly) return;
setStringValue(val);
try {
setValue(JSON.parse(val.trim() || '{}'));
setJsonError(undefined);
} catch (e) {
setJsonError(e);
}
}, [readOnly, setValue, setStringValue]);
useEffect(() => {
setStringValue(JSON.stringify(initialValue, null, 2));
}, [initialValue]);
const classes = [
baseClass,
'field-type',
className,
showError && 'error',
readOnly && 'read-only',
].filter(Boolean).join(' ');
return (
<div
className={classes}
style={{
...style,
width,
}}
>
<Error
showError={showError}
message={errorMessage}
/>
<Label
htmlFor={`field-${path}`}
label={label}
required={required}
/>
<CodeEditor
options={editorOptions}
defaultLanguage="json"
value={stringValue}
onChange={handleChange}
readOnly={readOnly}
/>
<FieldDescription
value={value}
description={description}
/>
</div>
);
};
export default withCondition(JSONField);

View File

@@ -0,0 +1,12 @@
@import '../../../../scss/styles.scss';
.json-field {
position: relative;
margin-bottom: $baseline;
&.error {
textarea {
border: 1px solid var(--theme-error-500) !important;
}
}
}

View File

@@ -0,0 +1,13 @@
import React, { Suspense, lazy } from 'react';
import Loading from '../../../elements/Loading';
import { Props } from './types';
const JSON = lazy(() => import('./JSON'));
const JSONField: React.FC<Props> = (props) => (
<Suspense fallback={<Loading />}>
<JSON {...props} />
</Suspense>
);
export default JSONField;

View File

@@ -0,0 +1,5 @@
import { JSONField } from '../../../../../fields/config/types';
export type Props = Omit<JSONField, 'type'> & {
path?: string
}

View File

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

View File

@@ -25,6 +25,7 @@ import { findOptionsByValue } from './findOptionsByValue';
import { GetFilterOptions } from './GetFilterOptions';
import { SingleValue } from './select-components/SingleValue';
import { MultiValueLabel } from './select-components/MultiValueLabel';
import { DocumentDrawerProps } from '../../../elements/DocumentDrawer/types';
import './index.scss';
@@ -139,6 +140,7 @@ const Relationship: React.FC<Props> = (props) => {
limit: maxResultsPerRequest,
page: lastLoadedPageToUse,
sort: fieldToSearch,
locale: i18n.language,
depth: 0,
};
@@ -240,6 +242,7 @@ const Relationship: React.FC<Props> = (props) => {
},
},
depth: 0,
locale: i18n.language,
limit: idsToLoad.length,
};
@@ -298,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,
@@ -381,6 +388,7 @@ const Relationship: React.FC<Props> = (props) => {
disableMouseDown: drawerIsOpen,
disableKeyDown: drawerIsOpen,
setDrawerIsOpen,
onSave,
}}
onMenuOpen={() => {
if (!hasLoadedFirstPage) {

View File

@@ -29,6 +29,22 @@ const optionsReducer = (state: OptionGroup[], action: Action): OptionGroup[] =>
return [];
}
case 'UPDATE': {
const { collection, doc, i18n } = action;
const relation = collection.slug;
const newOptions = [...state];
const labelKey = collection.admin.useAsTitle || 'id';
const foundOptionGroup = newOptions.find((optionGroup) => optionGroup.label === collection.labels.plural);
const foundOption = foundOptionGroup?.options?.find((option) => option.value === doc.id);
if (foundOption) {
foundOption.label = doc[labelKey] || `${i18n.t('general:untitled')} - ID: ${doc.id}`;
foundOption.relationTo = relation;
}
return newOptions;
}
case 'ADD': {
const { collection, docs, sort, ids = [], i18n } = action;
const relation = collection.slug;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,13 @@
import React, { useCallback } from 'react';
import { useSlate, ReactEditor } from 'slate-react';
import { Editor, Element, Transforms } from 'slate';
import { Editor, Element, Text, Transforms } from 'slate';
import IndentLeft from '../../../../../icons/IndentLeft';
import IndentRight from '../../../../../icons/IndentRight';
import { baseClass } from '../Button';
import isElementActive from '../isActive';
import listTypes from '../listTypes';
import { getCommonBlock } from '../getCommonBlock';
import { unwrapList } from '../unwrapList';
const indentType = 'indent';
@@ -25,25 +27,87 @@ const indent = {
e.preventDefault();
if (dir === 'left') {
Transforms.unwrapNodes(editor, {
match: (n) => Element.isElement(n) && [indentType, ...listTypes].includes(n.type),
split: true,
mode: 'lowest',
});
if (isElementActive(editor, 'li')) {
const [, parentLocation] = Editor.parent(editor, editor.selection);
const [, parentDepth] = parentLocation;
const [, listPath] = getCommonBlock(editor, (n) => Element.isElement(n) && listTypes.includes(n.type));
if (parentDepth > 0 || parentDepth === 0) {
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: [] });
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,50 @@
@import '../../../../../../../scss/styles.scss';
.rich-text-link-edit-modal {
&__template {
position: relative;
z-index: 1;
padding-top: base(1);
padding-bottom: base(2);
}
&__header {
width: 100%;
margin-bottom: $baseline;
display: flex;
justify-content: space-between;
margin-top: base(2.5);
margin-bottom: base(1);
@include mid-break {
margin-top: base(1.5);
}
}
&__header-text {
margin: 0;
}
&__header-close {
border: 0;
background-color: transparent;
padding: 0;
cursor: pointer;
overflow: hidden;
width: base(1);
height: base(1);
svg {
width: base(2.75);
height: base(2.75);
position: relative;
left: base(-.825);
top: base(-.825);
.stroke {
stroke-width: 2px;
vector-effect: non-scaling-stroke;
}
}
}
}

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