Merge remote-tracking branch 'origin/master' into pr/bigmistqke/1223

This commit is contained in:
Jarrod Flesch
2022-11-16 09:00:53 -05:00
146 changed files with 5511 additions and 3839 deletions

1
.node-version Normal file
View File

@@ -0,0 +1 @@
v16.14.2

View File

@@ -1,3 +1,123 @@
## [1.1.24](https://github.com/payloadcms/payload/compare/v1.1.23...v1.1.24) (2022-11-14)
### Bug Fixes
* cursor jumping while typing in inputs ([216b9f8](https://github.com/payloadcms/payload/commit/216b9f88d988c692d6acdf920ee4dbb9903020ae)), closes [#1393](https://github.com/payloadcms/payload/issues/1393)
## [1.1.23](https://github.com/payloadcms/payload/compare/v1.1.22...v1.1.23) (2022-11-12)
### Bug Fixes
* [#1361](https://github.com/payloadcms/payload/issues/1361), ensures collection auth depth works while retrieving static assets ([2f68404](https://github.com/payloadcms/payload/commit/2f684040fc9ca717d48b0d95cbd3468c35973993))
### Features
* optimizes field performance by storing internal values in useField hook ([66210b8](https://github.com/payloadcms/payload/commit/66210b856b97139f9959fac47154bca44f0a4de0))
## [1.1.22](https://github.com/payloadcms/payload/compare/v1.1.21...v1.1.22) (2022-11-12)
### Bug Fixes
* [#1353](https://github.com/payloadcms/payload/issues/1353), ensures errors returned from server make their way to UI ([3f28a69](https://github.com/payloadcms/payload/commit/3f28a69959be9c98869f81bcd379b8c7cd505a12))
* [#1357](https://github.com/payloadcms/payload/issues/1357), nested arrays and blocks sometimes not allowing save ([86855d6](https://github.com/payloadcms/payload/commit/86855d68f65dfadbf51050bdaf6a28c3220add6f))
* [#1358](https://github.com/payloadcms/payload/issues/1358), allows listSearchableFields to work when indicated fields are nested ([eb0023e](https://github.com/payloadcms/payload/commit/eb0023e9617894873fe75748de187d85279498c8))
* [#1360](https://github.com/payloadcms/payload/issues/1360), relationship field onMenuScrollToBottom not working in some browsers ([7136db4](https://github.com/payloadcms/payload/commit/7136db4c718b70833fa75f5c8e9ae596298b3aa9))
* [#1367](https://github.com/payloadcms/payload/issues/1367), allows custom global components within schema validation ([1d76e97](https://github.com/payloadcms/payload/commit/1d76e973bb8e6e33e40b469bd410042ae4b90e2e))
* 1309, duplicative logout in admin UI ([35f91b0](https://github.com/payloadcms/payload/commit/35f91b038b66d74468dad250dbe7cbf1ea88b444))
* fixed GraphQL Access query resolver to return the correct data ([#1339](https://github.com/payloadcms/payload/issues/1339)) ([cfef68f](https://github.com/payloadcms/payload/commit/cfef68f36477e34b9943d9334c65fa46ee3eb339))
## [1.1.21](https://github.com/payloadcms/payload/compare/v1.1.20...v1.1.21) (2022-11-05)
## [1.1.20](https://github.com/payloadcms/payload/compare/v1.1.19...v1.1.20) (2022-11-05)
### Features
* optimizes blocks and arrays by removing some additional rerenders ([483adf0](https://github.com/payloadcms/payload/commit/483adf08c4131d0401e47ec45d72200b9dc60de2))
## [1.1.19](https://github.com/payloadcms/payload/compare/v1.1.18...v1.1.19) (2022-10-31)
### Bug Fixes
* [#1307](https://github.com/payloadcms/payload/issues/1307), [#1321](https://github.com/payloadcms/payload/issues/1321) - bug with disableFormData and blocks field ([2a09f15](https://github.com/payloadcms/payload/commit/2a09f15a158ff30e89c5454f81aa140448f15d30))
* [#1311](https://github.com/payloadcms/payload/issues/1311), select existing upload modal always updates state ([e2ec2f7](https://github.com/payloadcms/payload/commit/e2ec2f7b97ed308c4ff7deefbc58cf0df6ff0602))
* [#1318](https://github.com/payloadcms/payload/issues/1318), improves popup positioning and logic ([c651835](https://github.com/payloadcms/payload/commit/c6518350617d14818dfc537b5b0a147274c1119b))
* custom pino logger options ([#1299](https://github.com/payloadcms/payload/issues/1299)) ([2500026](https://github.com/payloadcms/payload/commit/25000261bd6ecb0f05ae79de9a0693078a0e3e0d))
## [1.1.18](https://github.com/payloadcms/payload/compare/v1.1.17...v1.1.18) (2022-10-25)
## [1.1.17](https://github.com/payloadcms/payload/compare/v1.1.16...v1.1.17) (2022-10-25)
### Bug Fixes
* [#1286](https://github.com/payloadcms/payload/issues/1286), uses defaultDepth in graphql rich text depth ([66bf8c3](https://github.com/payloadcms/payload/commit/66bf8c3cbd080ee5a28b7af521d427d3aae59ba2))
* [#1290](https://github.com/payloadcms/payload/issues/1290), renders more than one rich text leaf where applicable ([a9f2f0e](https://github.com/payloadcms/payload/commit/a9f2f0ec03383ef4c3ef3ba98274b0abaaf962ed))
* [#1291](https://github.com/payloadcms/payload/issues/1291), add inline relationship drafts ([3967c12](https://github.com/payloadcms/payload/commit/3967c1233fda00b48e9df15276502a6b14b737ff))
* enforces depth: 0 in graphql resolvers ([3301f59](https://github.com/payloadcms/payload/commit/3301f598223d517ac310909bb74e455891c27693))
* ensures field updates when disableFormData changes ([c929725](https://github.com/payloadcms/payload/commit/c929725dd565de08871dad655442ee9ac4f29dd5))
* group + group styles within collapsible ([17dbbc7](https://github.com/payloadcms/payload/commit/17dbbc77757a7cd6e517bac443859561fee86e32))
### Features
* added beforeLogin hook ([#1289](https://github.com/payloadcms/payload/issues/1289)) ([09d7939](https://github.com/payloadcms/payload/commit/09d793926dbb642bbcb6ab975735d069df355a8a))
* adds default max length for text-based fields ([6a1b25a](https://github.com/payloadcms/payload/commit/6a1b25ab302cbdf7f312012b29b78288815810af))
* specify node 14+ and yarn classic LTS ([#1240](https://github.com/payloadcms/payload/issues/1240)) ([9181477](https://github.com/payloadcms/payload/commit/91814777b0bf3830c4a468b76783ff6f42ad824a))
## [1.1.16](https://github.com/payloadcms/payload/compare/v1.1.15...v1.1.16) (2022-10-21)
### Bug Fixes
* indexSortableFields not respected ([785b992](https://github.com/payloadcms/payload/commit/785b992c3ea31f7818f1c87c816b8b8de644851d))
* obscure bug where upload collection has upload field relating to itself ([36ef378](https://github.com/payloadcms/payload/commit/36ef3789fbe00cafe8b3587d6c370e28efd5a187))
## [1.1.15](https://github.com/payloadcms/payload/compare/v1.1.14...v1.1.15) (2022-10-14)
### Bug Fixes
* ensures svg mime type is always image/svg+xml ([0b0d971](https://github.com/payloadcms/payload/commit/0b0d9714917b1a56fb899a053e2e35c878a00992))
## [1.1.14](https://github.com/payloadcms/payload/compare/v1.1.13...v1.1.14) (2022-10-14)
## [1.1.11](https://github.com/payloadcms/payload/compare/v1.1.10...v1.1.11) (2022-10-12)
### Bug Fixes
* ensures arrays and blocks mount as disableFormData: true, fixes [#1242](https://github.com/payloadcms/payload/issues/1242) ([5ca5aba](https://github.com/payloadcms/payload/commit/5ca5abab422ad1cdb1b449a8298f439c57dda464))
### Features
* builds beforeDuplicate admin hook, closes [#1243](https://github.com/payloadcms/payload/issues/1243) ([6f6f2f8](https://github.com/payloadcms/payload/commit/6f6f2f8e7b83821ae2f2d30d08460439746cc0c6))
## [1.1.10](https://github.com/payloadcms/payload/compare/v1.1.9...v1.1.10) (2022-10-11)
## [1.1.9](https://github.com/payloadcms/payload/compare/v1.1.8...v1.1.9) (2022-10-11)
### Features
* improves access control typing ([5322ada](https://github.com/payloadcms/payload/commit/5322ada9e690544c4864abba202a14ec1f2f5e9d))
## [1.1.8](https://github.com/payloadcms/payload/compare/v1.1.7...v1.1.8) (2022-10-11)
### Features
* adds ability to create related docs while editing another ([1e048fe](https://github.com/payloadcms/payload/commit/1e048fe03787577fe4d584cec9c2d7c78bc90a17))
* implements use-context-selector for form field access ([5c1a3fa](https://github.com/payloadcms/payload/commit/5c1a3fabeef48b78f173af084f9117515e1297ba))
## [1.1.7](https://github.com/payloadcms/payload/compare/v1.1.6...v1.1.7) (2022-10-06)
## [1.1.6](https://github.com/payloadcms/payload/compare/v1.1.5...v1.1.6) (2022-10-06)
@@ -1980,4 +2100,4 @@ If none of your collections or globals should be publicly exposed, you don't nee
* add blind index for encrypting API Keys ([9a1c1f6](https://github.com/payloadcms/payload/commit/9a1c1f64c0ea0066b679195f50e6cb1ac4bf3552))
* add license key to access routej ([2565005](https://github.com/payloadcms/payload/commit/2565005cc099797a6e3b8995e0984c28b7837e82))
## [0.0.137](https://github.com/payloadcms/payload/commit/5c1e2846a2694a80cc8707703406c2ac1bb6af8a) (2020-11-12)
## [0.0.137](https://github.com/payloadcms/payload/commit/5c1e2846a2694a80cc8707703406c2ac1bb6af8a) (2020-11-12)

View File

@@ -16,7 +16,7 @@
</a>
<a href="https://discord.com/invite/r6sCXqVk3v">
<img alt="Discord" src="https://img.shields.io/discord/967097582721572934?label=Discord" />
<img alt="Discord" src="https://img.shields.io/discord/967097582721572934?label=Discord&color=7289da" />
</a>
</p>
@@ -68,6 +68,7 @@ Alternatively, it only takes about five minutes to [create an app from scratch](
### 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
If you want to add contributions to this repository, please follow the instructions in [contributing.md](./contributing.md).

View File

@@ -1,12 +1,21 @@
export {
useForm,
/**
* @deprecated useWatchForm is no longer preferred. If you need all form fields, prefer `useAllFormFields`.
*/
useWatchForm,
useFormFields,
useAllFormFields,
useFormSubmitted,
useFormProcessing,
useFormModified,
} from '../dist/admin/components/forms/Form/context';
export { default as useField } from '../dist/admin/components/forms/useField';
/**
* @deprecated This method is now called useField. The useFieldType alias will be removed in an upcoming version.
*/
export { default as useFieldType } from '../dist/admin/components/forms/useField';
export { default as Form } from '../dist/admin/components/forms/Form';
@@ -24,5 +33,6 @@ export { default as Submit } from '../dist/admin/components/forms/Submit';
export { default as Label } from '../dist/admin/components/forms/Label';
export { default as reduceFieldsToValues } from '../dist/admin/components/forms/Form/reduceFieldsToValues';
export { default as getSiblingData } from '../dist/admin/components/forms/Form/getSiblingData';
export { default as withCondition } from '../dist/admin/components/forms/withCondition';

View File

@@ -3,3 +3,4 @@ export { useLocale } from '../dist/admin/components/utilities/Locale';
export { useDocumentInfo } from '../dist/admin/components/utilities/DocumentInfo';
export { useConfig } from '../dist/admin/components/utilities/Config';
export { useAuth } from '../dist/admin/components/utilities/Auth';
export { useEditDepth } from '../dist/admin/components/utilities/EditDepth';

View File

@@ -8,6 +8,11 @@ keywords: overview, access control, permissions, documentation, Content Manageme
Access control within Payload is extremely powerful while remaining easy and intuitive to manage. Declaring who should have access to what documents is no more complex than writing a simple JavaScript function that either returns a `boolean` or a [`query`](/docs/queries/overview) constraint to restrict which documents users can interact with.
<YouTube
id="DoPLyXG26Dg"
title="Overview of Payload Access Control"
/>
**Example use cases:**
- Allowing anyone `read` access to all `Post`s

View File

@@ -11,35 +11,39 @@ While designing the Payload Admin panel, we determined it should be as minimal a
To swap in your own React component, first, consult the list of available component overrides below. Determine the scope that corresponds to what you are trying to accomplish, and then author your React component accordingly.
<Banner type="success">
<strong>Tip:</strong><br/>
Custom components will automatically be provided with all props that the default component would accept.
<strong>Tip:</strong>
<br />
Custom components will automatically be provided with all props that the
default component would accept.
</Banner>
### Base Component Overrides
You can override a set of admin panel-wide components by providing a component to your base Payload config's `admin.components` property. The following options are available:
| Path | Description |
| --------------------- | -------------|
| **`Nav`** | Contains the sidebar and mobile Nav in its entirety. |
| **`BeforeDashboard`** | Array of components to inject into the built-in Dashboard, _before_ the default dashboard contents. |
| **`AfterDashboard`** | Array of components to inject into the built-in Dashboard, _after_ the default dashboard contents. [Demo](https://github.com/payloadcms/payload/tree/master/test/admin/components/AfterDashboard/index.tsx)|
| **`BeforeLogin`** | Array of components to inject into the built-in Login, _before_ the default login form. |
| **`AfterLogin`** | Array of components to inject into the built-in Login, _after_ the default login form. |
| **`BeforeNavLinks`** | Array of components to inject into the built-in Nav, _before_ the links themselves. |
| **`AfterNavLinks`** | Array of components to inject into the built-in Nav, _after_ the links. |
| **`views.Account`** | The Account view is used to show the currently logged in user's Account page. |
| **`views.Dashboard`** | The main landing page of the Admin panel. |
| **`graphics.Icon`** | Used as a graphic within the `Nav` component. Often represents a condensed version of a full logo. |
| **`graphics.Logo`** | The full logo to be used in contexts like the `Login` view. |
| **`routes`** | Define your own routes to add to the Payload Admin UI. [More](#custom-routes) |
| **`providers`** | Define your own provider components that will wrap the Payload Admin UI. [More](#custom-providers) |
| Path | Description |
| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`Nav`** | Contains the sidebar and mobile Nav in its entirety. |
| **`logout.Button`** | A custom React component.
| **`BeforeDashboard`** | Array of components to inject into the built-in Dashboard, _before_ the default dashboard contents. |
| **`AfterDashboard`** | Array of components to inject into the built-in Dashboard, _after_ the default dashboard contents. [Demo](https://github.com/payloadcms/payload/tree/master/test/admin/components/AfterDashboard/index.tsx) |
| **`BeforeLogin`** | Array of components to inject into the built-in Login, _before_ the default login form. |
| **`AfterLogin`** | Array of components to inject into the built-in Login, _after_ the default login form. |
| **`BeforeNavLinks`** | Array of components to inject into the built-in Nav, _before_ the links themselves. |
| **`AfterNavLinks`** | Array of components to inject into the built-in Nav, _after_ the links. |
| **`views.Account`** | The Account view is used to show the currently logged in user's Account page. |
| **`views.Dashboard`** | The main landing page of the Admin panel. |
| **`graphics.Icon`** | Used as a graphic within the `Nav` component. Often represents a condensed version of a full logo. |
| **`graphics.Logo`** | The full logo to be used in contexts like the `Login` view. |
| **`routes`** | Define your own routes to add to the Payload Admin UI. [More](#custom-routes) |
| **`providers`** | Define your own provider components that will wrap the Payload Admin UI. [More](#custom-providers) |
#### Full example:
`payload.config.js`
```ts
import { buildConfig } from 'payload/config'
import { buildConfig } from "payload/config";
import {
MyCustomNav,
MyCustomLogo,
@@ -47,7 +51,7 @@ import {
MyCustomAccount,
MyCustomDashboard,
MyProvider,
} from './customComponents';
} from "./customComponents";
export default buildConfig({
admin: {
@@ -67,75 +71,69 @@ export default buildConfig({
});
```
*For more examples regarding how to customize components, look at the following [examples](https://github.com/payloadcms/payload/tree/master/test/admin/components).*
_For more examples regarding how to customize components, look at the following [examples](https://github.com/payloadcms/payload/tree/master/test/admin/components)._
### Collections
You can override components on a Collection-by-Collection basis via each Collection's `admin` property.
| Path | Description |
| ---------------- | -------------|
| **`views.Edit`** | Used while a document within this Collection is being edited. |
| **`views.List`** | The `List` view is used to render a paginated, filterable table of Documents in this Collection. |
| Path | Description |
| ---------------- | ------------------------------------------------------------------------------------------------ |
| **`views.Edit`** | Used while a document within this Collection is being edited. |
| **`views.List`** | The `List` view is used to render a paginated, filterable table of Documents in this Collection. |
### Globals
As with Collections, You can override components on a global-by-global basis via their `admin` property.
| Path | Description |
| ---------------- | -------------|
| **`views.Edit`** | Used while this Global is being edited. |
| Path | Description |
| ---------------- | --------------------------------------- |
| **`views.Edit`** | Used while this Global is being edited. |
### Fields
All Payload fields support the ability to swap in your own React components. So, for example, instead of rendering a default Text input, you might need to render a color picker that provides the editor with a custom color picker interface to restrict the data entered to colors only.
<Banner type="success">
<strong>Tip:</strong><br/>
Don't see a built-in field type that you need? Build it! Using a combination of custom validation and custom components, you can override the entirety of how a component functions within the admin panel and effectively create your own field type.
<strong>Tip:</strong>
<br />
Don't see a built-in field type that you need? Build it! Using a combination
of custom validation and custom components, you can override the entirety of
how a component functions within the admin panel and effectively create your
own field type.
</Banner>
**Fields support the following custom components:**
| Component | Description |
| --------------- |------------------------------------------------------------------------------------------------------------------------------|
| **`Filter`** | Override the text input that is presented in the `List` view when a user is filtering documents by the customized field. |
| **`Cell`** | Used in the `List` view's table to represent a table-based preview of the data stored in the field. [More](#cell-component) |
| **`Field`** | Swap out the field itself within all `Edit` views. [More](#field-component) |
| Component | Description |
| ------------ | --------------------------------------------------------------------------------------------------------------------------- |
| **`Filter`** | Override the text input that is presented in the `List` view when a user is filtering documents by the customized field. |
| **`Cell`** | Used in the `List` view's table to represent a table-based preview of the data stored in the field. [More](#cell-component) |
| **`Field`** | Swap out the field itself within all `Edit` views. [More](#field-component) |
## Cell Component
These are the props that will be passed to your custom Cell to use in your own components.
| Property | Description |
|--------------|-------------------------------------------------------------------|
| **`field`** | An object that includes the field configuration. |
| **`colIndex`** | A unique number for the column in the list. |
| Property | Description |
| ---------------- | ----------------------------------------------------------------- |
| **`field`** | An object that includes the field configuration. |
| **`colIndex`** | A unique number for the column in the list. |
| **`collection`** | An object with the config of the collection that the field is in. |
| **`cellData`** | The data for the field that the cell represents. |
| **`rowData`** | An object with all the field values for the row. |
| **`rowData`** | An object with all the field values for the row. |
#### Example
```tsx
import React from 'react';
import './index.scss';
const baseClass = 'custom-cell';
import React from "react";
import "./index.scss";
const baseClass = "custom-cell";
const CustomCell: React.FC<Props> = (props) => {
const {
field,
colIndex,
collection,
cellData,
rowData,
} = props;
const { field, colIndex, collection, cellData, rowData } = props;
return (
<span className={baseClass}>
{ cellData }
</span>
);
return <span className={baseClass}>{cellData}</span>;
};
```
@@ -148,93 +146,61 @@ When writing your own custom components you can make use of a number of hooks to
When swapping out the `Field` component, you'll be responsible for sending and receiving the field's `value` from the form itself. To do so, import the `useField` hook as follows:
```tsx
import { useField } from 'payload/components/forms'
import { useField } from "payload/components/forms";
type Props = { path: string }
type Props = { path: string };
const CustomTextField: React.FC<Props> = ({ path }) => {
// highlight-start
const { value, setValue } = useField<Props>({ path })
const { value, setValue } = useField<Props>({ path });
// highlight-end
return <input onChange={e => setValue(e.target.value)} value={value.path} />
}
```
### Getting other field values from the form
There are times when a custom field component needs to have access to data from other fields. This can be done using `getDataByPath` from `useWatchForm` as follows:
```tsx
import { useWatchForm } from 'payload/components/forms';
const DisplayFee: React.FC = () => {
const { getDataByPath } = useWatchForm();
const amount = getDataByPath('amount');
const feePercentage = getDataByPath('feePercentage');
if (amount && feePercentage) {
return (
<span>The fee is ${(amount * feePercentage) / 100}</span>
);
}
};
```
### Getting the document ID
The document ID can be very useful for certain custom components. You can get the `id` from the `useDocumentInfo` hook. Here is an example of a `UI` field using `id` to link to related collections:
```tsx
import { useDocumentInfo } from 'payload/components/utilities';
const LinkFromCategoryToPosts: React.FC = () => {
// highlight-start
const { id } = useDocumentInfo();
// highlight-end
// id will be undefined on the create form
if (!id) {
return null;
}
return (
<a href={`/admin/collections/posts?where[or][0][and][0][category][in][0]=[${id}]`} >
View posts
</a>
)
<input onChange={(e) => setValue(e.target.value)} value={value.path} />
);
};
```
<Banner type="success">
For more information regarding the hooks that are available to you while you
build custom components, including the <strong>useField</strong> hook,{" "}
<a href="/docs/admin/hooks" style={{ color: "black" }}>
click here
</a>
.
</Banner>
## Custom routes
You can easily add your own custom routes to the Payload Admin panel using the `admin.components.routes` property. Payload currently uses the extremely powerful React Router v5.x and custom routes support all the properties of the React Router `<Route />` component.
**Custom routes support the following properties:**
| Property | Description |
| ----------------- | -------------|
| **`Component`** * | Pass in the component that should be rendered when a user navigates to this route. |
| **`path`** * | React Router `path`. [See the React Router docs](https://v5.reactrouter.com/web/api/Route/path-string-string) for more info. |
| **`exact`** | React Router `exact` property. [More](https://v5.reactrouter.com/web/api/Route/exact-bool) |
| **`strict`** | React Router `strict` property. [More](https://v5.reactrouter.com/web/api/Route/strict-bool) |
| **`sensitive`** | React Router `sensitive` property. [More](https://v5.reactrouter.com/web/api/Route/sensitive-bool) |
| Property | Description |
| ------------------ | ---------------------------------------------------------------------------------------------------------------------------- |
| **`Component`** \* | Pass in the component that should be rendered when a user navigates to this route. |
| **`path`** \* | React Router `path`. [See the React Router docs](https://v5.reactrouter.com/web/api/Route/path-string-string) for more info. |
| **`exact`** | React Router `exact` property. [More](https://v5.reactrouter.com/web/api/Route/exact-bool) |
| **`strict`** | React Router `strict` property. [More](https://v5.reactrouter.com/web/api/Route/strict-bool) |
| **`sensitive`** | React Router `sensitive` property. [More](https://v5.reactrouter.com/web/api/Route/sensitive-bool) |
*\* An asterisk denotes that a property is required.*
_\* An asterisk denotes that a property is required._
#### Custom route components
Your custom route components will be given all the props that a React Router `<Route />` typically would receive, as well as two props from Payload:
| Prop | Description |
| ---------------------- | -------------|
| **`user`** | The currently logged in user. Will be `null` if no user is logged in. |
| **`canAccessAdmin`** * | If the currently logged in user is allowed to access the admin panel or not. |
| Prop | Description |
| ----------------------- | ---------------------------------------------------------------------------- |
| **`user`** | The currently logged in user. Will be `null` if no user is logged in. |
| **`canAccessAdmin`** \* | If the currently logged in user is allowed to access the admin panel or not. |
<Banner type="warning">
<strong>Note:</strong><br/>
It's up to you to secure your custom routes. If your route requires a user to be logged in or to have certain access rights, you should handle that within your route component yourself.
<strong>Note:</strong>
<br />
It's up to you to secure your custom routes. If your route requires a user to
be logged in or to have certain access rights, you should handle that within
your route component yourself.
</Banner>
#### Example
@@ -250,7 +216,10 @@ To see how to pass in your custom views to create custom routes of your own, tak
As your admin customizations gets more complex you may want to share state between fields or other components. You can add custom providers to do add your own context to any Payload app for use in other custom components within the admin panel. Within your config add `admin.components.providers`, these can be used to share context or provide other custom functionality. Read the [React context](https://reactjs.org/docs/context.html) docs to learn more.
<Banner type="warning"><strong>Reminder:</strong> Don't forget to pass the **children** prop through the provider component for the admin UI to show</Banner>
<Banner type="warning">
<strong>Reminder:</strong> Don't forget to pass the **children** prop through
the provider component for the admin UI to show
</Banner>
### Styling Custom Components
@@ -267,7 +236,7 @@ To make use of Payload SCSS variables / mixins to use directly in your own compo
In any custom component you can get the selected locale with the `useLocale` hook. Here is a simple example:
```tsx
import { useLocale } from 'payload/components/utilities';
import { useLocale } from "payload/components/utilities";
const Greeting: React.FC = () => {
// highlight-start
@@ -275,12 +244,10 @@ const Greeting: React.FC = () => {
// highlight-end
const trans = {
en: 'Hello',
es: 'Hola',
en: "Hello",
es: "Hola",
};
return (
<span> { trans[locale] } </span>
);
return <span> {trans[locale]} </span>;
};
```

View File

@@ -1,7 +1,7 @@
---
title: Customizing CSS & SCSS
label: Customizing CSS
order: 30
order: 40
desc: Customize your Payload admin panel further by adding your own CSS or SCSS style sheet to the configuration, powerful theme and design options are waiting for you.
keywords: admin, css, scss, documentation, Content Management System, cms, headless, javascript, node, react, express
---

289
docs/admin/hooks.mdx Normal file
View File

@@ -0,0 +1,289 @@
---
title: React Hooks
label: React Hooks
order: 30
desc: Make use of all of the powerful React hooks that Payload provides.
keywords: admin, components, custom, documentation, Content Management System, cms, headless, javascript, node, react, express
---
Payload provides a variety of powerful hooks that can be used within your own React components. With them, you can interface with Payload itself and build just about any type of complex customization you can think of—directly in familiar React code.
### useField
The `useField` hook is used internally within every applicable Payload field component, and it manages sending and receiving a field's state from its parent form.
Outside of internal use, its most common use-case is in custom `Field` components. When you build a custom React `Field` component, you'll be responsible for sending and receiving the field's `value` from the form itself. To do so, import the `useField` hook as follows:
```tsx
import { useField } from 'payload/components/forms'
type Props = { path: string }
const CustomTextField: React.FC<Props> = ({ path }) => {
// highlight-start
const { value, setValue } = useField<string>({ path })
// highlight-end
return <input onChange={e => setValue(e.target.value)} value={value.path} />
}
```
The `useField` hook accepts an `args` object and sends back information and helpers for you to make use of:
```ts
const field = useField<string>({
path: 'fieldPathHere', // required
validate: myValidateFunc, // optional
disableFormData?: false, // if true, the field's data will be ignored
condition?: myConditionHere, // optional, used to skip validation if condition fails
})
// Here is what `useField` sends back
const {
showError, // whether or not the field should show as errored
errorMessage, // the error message to show, if showError
value, // the current value of the field from the form
formSubmitted, // if the form has been submitted
formProcessing, // if the form is currently processing
setValue, // method to set the field's value in form state
initialValue, // the initial value that the field mounted with
} = field;
// The rest of your component goes here
```
### useFormFields
There are times when a custom field component needs to have access to data from other fields, and you have a few options to do so. The `useFormFields` hook is a powerful and highly performant way to retrieve a form's field state, as well as to retrieve the `dispatchFields` method, which can be helpful for setting other fields' form states from anywhere within a form.
<Banner type="success">
<strong>This hook is great for retrieving only certain fields from form state</strong> because it ensures that it will only cause a rerender when the items that you ask for change.
</Banner>
Thanks to the awesome package [`use-context-selector`](https://github.com/dai-shi/use-context-selector), you can retrieve a specific field's state easily. This is ideal because you can ensure you have an up-to-date field state, and your component will only re-render when _that field's state_ changes.
You can pass a Redux-like selector into the hook, which will ensure that you retrieve only the field that you want. The selector takes an argument with type of `[fields: Fields, dispatch: React.Dispatch<Action>]]`.
```tsx
import { useFormFields } from 'payload/components/forms';
const MyComponent: React.FC = () => {
// Get only the `amount` field state, and only cause a rerender when that field changes
const amount = useFormFields(([fields, dispatch]) => fields.amount);
// Do the same thing as above, but to the `feePercentage` field
const feePercentage = useFormFields(([fields, dispatch]) => fields.feePercentage);
if (typeof amount?.value !== 'undefined' && typeof feePercentage?.value !== 'undefined') {
return (
<span>The fee is ${(amount.value * feePercentage.value) / 100}</span>
);
}
};
```
### useAllFormFields
**To retrieve more than one field**, you can use the `useAllFormFields` hook. Your component will re-render when _any_ field changes, so use this hook only if you absolutely need to. Unlike the `useFormFields` hook, this hook does not accept a "selector", and it always returns an array with type of `[fields: Fields, dispatch: React.Dispatch<Action>]]`.
You can do lots of powerful stuff by retrieving the full form state, like using built-in helper functions to reduce field state to values only, or to retrieve sibling data by path.
```tsx
import { useAllFormFields, reduceFieldsToValues, getSiblingData } from 'payload/components/forms';
const ExampleComponent: React.FC = () => {
// the `fields` const will be equal to all fields' state,
// and the `dispatchFields` method is usable to send field state up to the form
const [fields, dispatchFields] = useAllFormFields();
// Pass in fields, and indicate if you'd like to "unflatten" field data.
// The result below will reflect the data stored in the form at the given time
const formData = reduceFieldsToValues(fields, true);
// Pass in field state and a path,
// and you will be sent all sibling data of the path that you've specified
const siblingData = getSiblingData(fields, 'someFieldName');
return (
// return some JSX here if necessary
)
};
```
##### Updating other fields' values
If you are building a custom component, then you should use `setValue` which is returned from the `useField` hook to programmatically set your field's value. But if you're looking to update _another_ field's value, you can use `dispatchFields` returned from `useFormFields`.
You can send the following actions to the `dispatchFields` function.
| Action | Description |
|------------------------|----------------------------------------------------------------------------|
| **`ADD_ROW`** | Adds a row of data (useful in array / block field data) |
| **`DUPLICATE_ROW`** | Duplicates a row of data (useful in array / block field data) |
| **`MODIFY_CONDITION`** | Updates a field's conditional logic result (true / false) |
| **`MOVE_ROW`** | Moves a row of data (useful in array / block field data) |
| **`REMOVE`** | Removes a field from form state |
| **`REMOVE_ROW`** | Removes a row of data from form state (useful in array / block field data) |
| **`REPLACE_STATE`** | Completely replaces form state |
| **`UPDATE`** | Update any property of a specific field's state |
To see types for each action supported within the `dispatchFields` hook, check out the Form types [here](https://github.com/payloadcms/payload/blob/master/src/admin/components/forms/Form/types.ts).
### useForm
The `useForm` hook can be used to interact with the form itself, and sends back many methods that can be used to reactively fetch form state without causing rerenders within your components each time a field is changed. This is useful if you have action-based callbacks that your components fire, and need to interact with form state _based on a user action_.
<Banner type="warning">
<strong>Warning:</strong><br/>
This hook is optimized to avoid causing rerenders when fields change, and as such, its `fields` property will be out of date. You should only leverage this hook if you need to perform actions against the form in response to your users' actions. Do not rely on its returned "fields" as being up-to-date. They will be removed from this hook's response in an upcoming version.
</Banner>
The `useForm` hook returns an object with the following properties:
| Action | Description |
|----------------------|---------------------------------------------------------------------|
| **`fields`** | Deprecated. This property cannot be relied on as up-to-date. |
| **`submit`** | Method to trigger the form to submit |
| **`dispatchFields`** | Dispatch actions to the form field state |
| **`validateForm`** | Trigger a validation of the form state |
| **`createFormData`** | Create a `multipart/form-data` object from the current form's state |
| **`disabled`** | Boolean denoting whether or not the form is disabled |
| **`getFields`** | Gets all fields from state |
| **`getField`** | Gets a single field from state by path |
| **`getData`** | Returns the data stored in the form |
| **`getSiblingData`** | Returns form sibling data for the given field path |
| **`setModified`** | Set the form's `modified` state |
| **`setProcessing`** | Set the form's `processing` state |
| **`setSubmitted`** | Set the form's `submitted` state |
| **`formRef`** | The ref from the form HTML element |
| **`reset`** | Method to reset the form to its initial state |
### useDocumentInfo
The `useDocumentInfo` hook provides lots of information about the document currently being edited, including the following:
| Property | Description |
|---------------------------|------------------------------------------------------------------------------------|
| **`collection`** | If the doc is a collection, its collection config will be returned |
| **`global`** | If the doc is a global, its global config will be returned |
| **`type`** | The type of document being edited (collection or global) |
| **`id`** | If the doc is a collection, its ID will be returned |
| **`preferencesKey`** | The `preferences` key to use when interacting with document-level user preferences |
| **`versions`** | Versions of the current doc |
| **`unpublishedVersions`** | Unpublished versions of the current doc |
| **`publishedDoc`** | The currently published version of the doc being edited |
| **`getVersions`** | Method to trigger the retrieval of document versions |
**Example:**
```tsx
import { useDocumentInfo } from 'payload/components/utilities';
const LinkFromCategoryToPosts: React.FC = () => {
// highlight-start
const { id } = useDocumentInfo();
// highlight-end
// id will be undefined on the create form
if (!id) {
return null;
}
return (
<a href={`/admin/collections/posts?where[or][0][and][0][category][in][0]=[${id}]`} >
View posts
</a>
)
};
```
### useLocale
In any custom component you can get the selected locale with the `useLocale` hook. Here is a simple example:
```tsx
import { useLocale } from 'payload/components/utilities';
const Greeting: React.FC = () => {
// highlight-start
const locale = useLocale();
// highlight-end
const trans = {
en: 'Hello',
es: 'Hola',
};
return (
<span> { trans[locale] } </span>
);
};
```
### useAuth
Useful to retrieve info about the currently logged in user as well as methods for interacting with it. It sends back an object with the following properties:
| Property | Description |
|---------------------|-----------------------------------------------------------------------------------------|
| **`user`** | The currently logged in user |
| **`logOut`** | A method to log out the currently logged in user |
| **`refreshCookie`** | A method to trigger the silent refreshing of a user's auth token |
| **`setToken`** | Set the token of the user, to be decoded and used to reset the user and token in memory |
| **`token`** | The logged in user's token (useful for creating preview links, etc.) |
| **`permissions`** | The permissions of the current user |
```tsx
import { useAuth } from 'payload/components/utilities';
import { User } from '../payload-types.ts';
const Greeting: React.FC = () => {
// highlight-start
const { user } = useConfig<User>();
// highlight-end
return (
<span>Hi, {user.email}!</span>
);
};
```
### useConfig
Used to easily fetch the full Payload config.
```tsx
import { useConfig } from 'payload/components/utilities';
const MyComponent: React.FC = () => {
// highlight-start
const config = useConfig();
// highlight-end
return (
<span>{config.serverURL}</span>
);
};
```
### useEditDepth
Sends back how many editing levels "deep" the current component is. Edit depth is relevant while adding new documents / editing documents in modal windows and other cases.
```tsx
import { useEditDepth } from 'payload/components/utilities';
const MyComponent: React.FC = () => {
// highlight-start
const editDepth = useEditDepth();
// highlight-end
return (
<span>My component is {editDepth} levels deep</span>
)
}
```
### usePreferences
Returns methods to set and get user preferences. More info can be found [here](https://payloadcms.com/docs/admin/preferences).

View File

@@ -14,7 +14,7 @@ The Payload Admin panel is built with Webpack, code-split, highly performant (ev
The Admin panel is meant to be simple enough to give you a starting point but not bring too much complexity, so that you can easily customize it to suit the needs of your application and your editors.
</Banner>
![Payload's Admin panel built in React](https://payloadcms.com/images/admin.jpg)
![Payload's Admin panel built in React](https://payloadcms.com/images/docs/admin.jpg)
*Screenshot of the Admin panel while editing a document from an example `AllFields` collection*
@@ -22,18 +22,20 @@ The Payload Admin panel is built with Webpack, code-split, highly performant (ev
All options for the Admin panel are defined in your base Payload config file.
| Option | Description |
| -------------------- | -------------|
| `user` | The `slug` of a Collection that you want be used to log in to the Admin dashboard. [More](/docs/admin/overview#the-admin-user-collection) |
| `meta` | Base meta data to use for the Admin panel. Included properties are `titleSuffix`, `ogImage`, and `favicon`. |
| `disable` | If set to `true`, the entire Admin panel will be disabled. |
| `indexHTML` | Optionally replace the entirety of the `index.html` file used by the Admin panel. Reference the [base index.html file](https://github.com/payloadcms/payload/blob/master/src/admin/index.html) to ensure your replacement has the appropriate HTML elements. |
| `css` | Absolute path to a stylesheet that you can use to override / customize the Admin panel styling. [More](/docs/admin/customizing-css). |
| `scss` | Absolute path to a Sass variables / mixins stylesheet meant to override Payload styles to make for an easy re-skinning of the Admin panel. [More](/docs/admin/customizing-css#overriding-scss-variables). |
| `dateFormat` | Global date format that will be used for all dates in the Admin panel. Any valid [date-fns](https://date-fns.org/) format pattern can be used. |
| `avatar` | Set account profile picture. Options: `gravatar`, `default` or a custom React component. |
| `components` | Component overrides that affect the entirety of the Admin panel. [More](/docs/admin/components) |
| `webpack` | Customize the Webpack config that's used to generate the Admin panel. [More](/docs/admin/webpack) |
| Option | Description |
| --------------------- | -------------|
| `user` | The `slug` of a Collection that you want be used to log in to the Admin dashboard. [More](/docs/admin/overview#the-admin-user-collection) |
| `meta` | Base meta data to use for the Admin panel. Included properties are `titleSuffix`, `ogImage`, and `favicon`. |
| `disable` | If set to `true`, the entire Admin panel will be disabled. |
| `indexHTML` | Optionally replace the entirety of the `index.html` file used by the Admin panel. Reference the [base index.html file](https://github.com/payloadcms/payload/blob/master/src/admin/index.html) to ensure your replacement has the appropriate HTML elements. |
| `css` | Absolute path to a stylesheet that you can use to override / customize the Admin panel styling. [More](/docs/admin/customizing-css). |
| `scss` | Absolute path to a Sass variables / mixins stylesheet meant to override Payload styles to make for an easy re-skinning of the Admin panel. [More](/docs/admin/customizing-css#overriding-scss-variables). |
| `dateFormat` | Global date format that will be used for all dates in the Admin panel. Any valid [date-fns](https://date-fns.org/) format pattern can be used. |
| `avatar` | Set account profile picture. Options: `gravatar`, `default` or a custom React component. |
| `components` | Component overrides that affect the entirety of the Admin panel. [More](/docs/admin/components) |
| `webpack` | Customize the Webpack config that's used to generate the Admin panel. [More](/docs/admin/webpack) | |
| **`logoutRoute`** | The route for the `logout` page. |
| **`inactivityRoute`** | The route for the `logout` inactivity page. |
### The Admin User Collection

View File

@@ -1,7 +1,7 @@
---
title: Managing User Preferences
label: Preferences
order: 40
order: 50
desc: Store the preferences of your users as they interact with the Admin panel.
keywords: admin, preferences, custom, customize, documentation, Content Management System, cms, headless, javascript, node, react, express
---

View File

@@ -1,7 +1,7 @@
---
title: Webpack
label: Webpack
order: 50
order: 60
desc: The Payload admin panel uses Webpack 5 and supports many common functionalities such as SCSS and Typescript out of the box to give you more freedom.
keywords: admin, webpack, documentation, Content Management System, cms, headless, javascript, node, react, express
---

View File

@@ -12,7 +12,7 @@ keywords: authentication, config, configuration, overview, documentation, Conten
Authentication is used within the Payload Admin panel itself as well as throughout your app(s) themselves however you determine necessary.
![Authentication admin panel functionality](https://payloadcms.com/images/auth-admin.jpg)
![Authentication admin panel functionality](https://payloadcms.com/images/docs/auth-admin.jpg)
*Admin panel screenshot depicting an Admins Collection with Auth enabled*
**Here are some common use cases of Authentication outside of Payload's dashboard itself:**

View File

@@ -59,17 +59,18 @@ You can find an assortment of [example collection configs](https://github.com/pa
You can customize the way that the Admin panel behaves on a collection-by-collection basis by defining the `admin` property on a collection's config.
| Option | Description |
| ---------------------------- | -------------|
| `group` | Text used as a label for grouping collection links together in the navigation. |
| `useAsTitle` | Specify a top-level field to use for a document title throughout the Admin panel. If no field is defined, the ID of the document is used as the title. |
| `description` | Text or React component to display below the Collection label in the List view to give editors more information. |
| `defaultColumns` | Array of field names that correspond to which columns to show by default in this collection's List view. |
| `disableDuplicate ` | Disables the "Duplicate" button while editing documents within this collection. |
| Option | Description |
| --------------------------- | -------------|
| `group` | Text used as a label for grouping collection links together in the navigation. |
| `hooks` | Admin-specific hooks for this collection. [More](#admin-hooks) |
| `useAsTitle` | Specify a top-level field to use for a document title throughout the Admin panel. If no field is defined, the ID of the document is used as the title. |
| `description` | Text or React component to display below the Collection label in the List view to give editors more information. |
| `defaultColumns` | Array of field names that correspond to which columns to show by default in this collection's List view. |
| `disableDuplicate ` | Disables the "Duplicate" button while editing documents within this collection. |
| `enableRichTextRelationship` | The [Rich Text](/docs/fields/rich-text) field features a `Relationship` element which allows for users to automatically reference related documents within their rich text. Set to `true` by default. |
| `preview` | Function to generate preview URLS within the Admin panel that can point to your app. [More](#preview). |
| `components` | Swap in your own React components to be used within this collection. [More](/docs/admin/components#collections) |
| `listSearchableFields ` | Specify which fields should be searched in the List search view. [More](/docs/configuration/collections#list-searchable-fields) |
| `preview` | Function to generate preview URLS within the Admin panel that can point to your app. [More](#preview). |
| `components` | Swap in your own React components to be used within this collection. [More](/docs/admin/components#collections) |
| `listSearchableFields` | Specify which fields should be searched in the List search view. [More](#list-searchable-fields) |
### Preview
@@ -120,7 +121,7 @@ Hooks are a powerful way to extend collection functionality and execute your own
Collections support all field types that Payload has to offer—including simple fields like text and checkboxes all the way to more complicated layout-building field groups like Blocks. [Click here](/docs/fields/overview) to learn more about field types.
#### List Searchable Fields
### List Searchable Fields
In the List view, there is a "search" box that allows you to quickly find a document with a search. By default, it searches on the ID field. If you have `admin.useAsTitle` defined, the list search will use that field. However, you can define more than one field to search to make it easier on your admin editors to find the data they need.
@@ -128,9 +129,53 @@ For example, let's say you have a Posts collection with `title`, `metaDescriptio
<Banner type="warning">
<strong>Note:</strong><br/>
If you are adding <strong>listSearchableFields</strong>, make sure you index each of these fields so your admin queries can remain performant.
If you are adding <strong>listSearchableFields</strong>, make sure you index each of these fields so your admin queries can remain performant.
</Banner>
### Admin Hooks
In addition to collection hooks themselves, Payload provides for admin UI-specific hooks that you can leverage.
**`beforeDuplicate`**
The `beforeDuplicate` hook is an async function that accepts an object containing the data to duplicate, as well as the locale of the doc to duplicate. Within this hook, you can modify the data to be duplicated, which is useful in cases where you have unique fields that need to be incremented or similar, as well as if you want to automatically modify a document's `title`.
Example:
```ts
import { BeforeDuplicate, CollectionConfig } from 'payload/types';
// Your auto-generated Page type
import { Page } from '../payload-types.ts';
const beforeDuplicate: BeforeDuplicate<Page> = ({ data }) => {
return {
...data,
title: `${data.title} Copy`,
uniqueField: data.uniqueField ? `${data.uniqueField}-copy` : '',
};
};
export const Page: CollectionConfig = {
slug: 'pages',
admin: {
hooks: {
beforeDuplicate,
}
},
fields: [
{
name: 'title',
type: 'text',
},
{
name: 'uniqueField',
type: 'text',
unique: true,
}
]
}
```
### TypeScript
You can import collection types as follows:

View File

@@ -8,7 +8,7 @@ keywords: overview, config, configuration, documentation, Content Management Sys
Payload is a *config-based*, code-first CMS and application framework. The Payload config is central to everything that Payload does. It scaffolds the data that Payload stores as well as maintains custom React components, hook logic, custom validations, and much more. The config itself and all of its dependencies are run through Babel, so you can take full advantage of newer JavaScript features and even directly import React components containing JSX.
<strong>Also, because the Payload source code is fully written in TypeScript, its configs are strongly typed—meaning that even if you aren't using TypeScript to build your project, your IDE (such as VSCode) may still provide helpful information like type-ahead suggestions while you write your config.</strong>
**Also, because the Payload source code is fully written in TypeScript, its configs are strongly typed—meaning that even if you aren't using TypeScript to build your project, your IDE (such as VSCode) may still provide helpful information like type-ahead suggestions while you write your config.**
<Banner type="warning">
<strong>Important:</strong><br />This file is included in the Payload admin bundle, so make sure you do not embed any sensitive information.

View File

@@ -16,7 +16,7 @@ keywords: array, fields, config, configuration, documentation, Content Managemen
- Navigational structures where editors can specify nav items containing pages ([relationship field](/docs/fields/relationship)), an "open in new tab" [checkbox field](/docs/fields/checkbox)
- Event agenda "timeslots" where you need to specify start & end time ([date field](/docs/fields/date)), label ([text field](/docs/fields/text)), and Learn More page [relationship](/docs/fields/relationship)
![Array field in Payload admin panel](https://payloadcms.com/images/fields/array.jpg)
![Array field in Payload admin panel](https://payloadcms.com/images/docs/fields/array.jpg)
*Admin panel screenshot of an Array field with a Row containing two text fields, a read-only text field and a checkbox*
### Config

View File

@@ -16,7 +16,7 @@ keywords: blocks, fields, config, configuration, documentation, Content Manageme
- A form builder tool where available block configs might be `Text`, `Select`, or `Checkbox`.
- Virtual event agenda "timeslots" where a timeslot could either be a `Break`, a `Presentation`, or a `BreakoutSession`.
![Blocks field in Payload admin panel](https://payloadcms.com/images/fields/blocks.jpg)
![Blocks field in Payload admin panel](https://payloadcms.com/images/docs/fields/blocks.jpg)
*Admin panel screenshot of a Blocks field type with Call to Action and Number block examples*

View File

@@ -23,7 +23,7 @@ keywords: radio, fields, config, configuration, documentation, Content Managemen
| **`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) |
| **`defaultValue`** | Provide data to be used for this field's default value. The default value must exist within provided values in `options`. [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). |
@@ -41,7 +41,7 @@ In addition to the default [field admin config](/docs/fields/overview#admin-conf
**`layout`**
The `layout` property allows for the radio group to be styled as a horizonally or vertically distributed list.
The `layout` property allows for the radio group to be styled as a horizonally or vertically distributed list. The default value is `horizontal`.
### Example
@@ -65,7 +65,7 @@ const ExampleCollection: CollectionConfig = {
value: 'dark_gray',
},
],
defaultValue: 'option_1',
defaultValue: 'mint', // The first value in options.
admin: {
layout: 'horizontal',
}

View File

@@ -81,17 +81,17 @@ Set this property to `true` to hide this field's gutter within the admin panel.
This allows [fields](/docs/fields/overview) to be saved as extra fields on a link inside the Rich Text Editor. When this is present, the fields will render inside a modal that can be opened by clicking the "edit" button on the link element.
![RichText link fields](https://payloadcms.com/images/fields/richText/rte-link-fields-modal.jpg)
![RichText link fields](https://payloadcms.com/images/docs/fields/richText/rte-link-fields-modal.jpg)
*RichText link with custom fields*
**`upload.collections[collection-name].fields`**
This allows [fields](/docs/fields/overview) to be saved as meta data on an upload field inside the Rich Text Editor. When this is present, the fields will render inside a modal that can be opened by clicking the "edit" button on the upload element.
![RichText upload element](https://payloadcms.com/images/fields/richText/rte-upload-element.jpg)
![RichText upload element](https://payloadcms.com/images/docs/fields/richText/rte-upload-element.jpg)
*RichText field using the upload element*
![RichText upload element modal](https://payloadcms.com/images/fields/richText/rte-upload-fields-modal.jpg)
![RichText upload element modal](https://payloadcms.com/images/docs/fields/richText/rte-upload-fields-modal.jpg)
*RichText upload element modal displaying fields from the config*
### Relationship element

View File

@@ -10,7 +10,7 @@ keywords: tabs, fields, config, configuration, documentation, Content Management
The Tabs field is presentational-only and only affects the Admin panel (unless a tab is named). By using it, you can place fields within a nice layout component that separates certain sub-fields by a tabbed interface.
</Banner>
![Tabs field type used to separate Hero fields from Page Layout](https://payloadcms.com/images/fields/tabs/tabs.jpg)
![Tabs field type used to separate Hero fields from Page Layout](https://payloadcms.com/images/docs/fields/tabs/tabs.jpg)
*Tabs field type used to separate Hero fields from Page Layout*
### Config

View File

@@ -27,8 +27,8 @@ With this field, you can also inject custom `Cell` components that appear as add
| ---------------------------- |-------------------------------------------------------------------------------------------------------------------|
| **`name`** * | A unique identifier for this field. |
| **`label`** | Human-readable label for this UI field. |
| **`admin.components.Field`** | React component to be rendered for this field within the Edit view. [More](/admin/components/#field-component) |
| **`admin.components.Cell`** | React component to be rendered as a Cell within collection List views. [More](/admin/components/#field-component) |
| **`admin.components.Field`** | React component to be rendered for this field within the Edit view. [More](/docs/admin/components/#field-component) |
| **`admin.components.Cell`** | React component to be rendered as a Cell within collection List views. [More](/docs/admin/components/#field-component) |
*\* An asterisk denotes that a property is required.*

View File

@@ -48,7 +48,7 @@ The team behind Payload has been building websites and apps with existing conten
- Secure
- Fully flexible and extensible
Payload is our silver bullet solution. It represents over two years of passionate development and brings everything we need when we build new apps and websites:
Payload is our silver bullet solution. We've blended the best parts of our experience with other CMS and app frameworks into Payload, and we finally have everything we need when we build new apps and websites:
- A beautiful, dynamic, customizable admin UI
- Extensible and reusable authentication

View File

@@ -14,7 +14,7 @@ The labels you provide for your Collections and Globals are used to name the Gra
## GraphQL Options
At the top of your Payload config you can define all the options to manage GraphQL. The
At the top of your Payload config you can define all the options to manage GraphQL.
| Option | Description |
| -------------------- | -------------|

View File

@@ -190,7 +190,7 @@ const afterDeleteHook: CollectionAfterDeleteHook = async ({
### beforeLogin
For auth-enabled Collections, this hook runs after successful `login` operations. You can optionally modify the user that is returned.
For auth-enabled Collections, this hook runs during `login` operations where a user with the provided credentials exist, but before a token is generated and added to the response. You can optionally modify the user that is returned, or throw an error in order to deny the login operation.
```ts
import { CollectionBeforeLoginHook } from 'payload/types';
@@ -198,7 +198,6 @@ import { CollectionBeforeLoginHook } from 'payload/types';
const beforeLoginHook: CollectionBeforeLoginHook = async ({
req, // full express request
user, // user being logged in
token, // user token
}) => {
return user;
}
@@ -213,6 +212,8 @@ import { CollectionAfterLoginHook } from 'payload/types';
const afterLoginHook: CollectionAfterLoginHook = async ({
req, // full express request
user, // user that was logged in
token, // user token
}) => {...}
```

View File

@@ -27,9 +27,9 @@ Field-level hooks offer incredible potential for encapsulating your logic. They
Example field configuration:
```ts
import { CollectionConfig } from 'payload/types';
import { Field } from 'payload/types';
const ExampleCollection: CollectionConfig = {
const ExampleField: Field = {
name: 'name',
type: 'text',
// highlight-start

View File

@@ -63,8 +63,8 @@ You can specify more options within the Local API vs. REST or GraphQL due to the
| `depth` | [Control auto-population](/docs/getting-started/concepts#depth) of nested relationship and upload fields. |
| `locale` | Specify [locale](/docs/configuration/localization) for any returned documents. |
| `fallbackLocale` | Specify a [fallback locale](/docs/configuration/localization) to use for any returned documents. |
| `overrideAccess` | Skip access control. By default, this property is set to false. |
| `user` | If you re-enable access control, you can specify a user to use against the access control checks. |
| `overrideAccess` | Skip access control. By default, this property is set to true within all Local API operations. |
| `user` | If you set `overrideAccess` to `false`, you can pass a user to use against the access control checks. |
| `showHiddenFields` | Opt-in to receiving hidden fields. By default, they are hidden from returned documents in accordance to your config. |
| `pagination` | Set to false to return all documents and avoid querying for document counts. |
@@ -323,7 +323,7 @@ const result = await payload.updateGlobal({
## TypeScript
Local API calls also support passing in a generic. This is especially useful if you generate your TS types using a [generate types script](/docs/typescript/generate-types).
Local API calls also support passing in a generic. This is especially useful if you generate your TS types using a [generate types script](/docs/typescript/generating-types).
Here is an example of usage:

View File

@@ -12,7 +12,7 @@ keywords: uploads, images, media, overview, documentation, Content Management Sy
control.
</Banner>
![Upload admin panel functionality](https://payloadcms.com/images/upload-admin.jpg)
![Upload admin panel functionality](https://payloadcms.com/images/docs/upload-admin.jpg)
_Admin panel screenshot depicting a Media Collection with Upload enabled_
**Here are some common use cases of Uploads:**

View File

@@ -1,8 +1,12 @@
{
"name": "payload",
"version": "1.1.7",
"version": "1.1.24",
"description": "Node, React and MongoDB Headless CMS and Application Framework",
"license": "MIT",
"engines": {
"node": ">=14",
"yarn": ">=1.22 <2"
},
"author": {
"email": "info@payloadcms.com",
"name": "Payload CMS",
@@ -86,7 +90,7 @@
"@babel/preset-typescript": "^7.12.1",
"@babel/register": "^7.11.5",
"@date-io/date-fns": "^2.10.6",
"@faceless-ui/modal": "^2.0.0-alpha.4",
"@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",
@@ -172,7 +176,7 @@
"sanitize-filename": "^1.6.3",
"sass": "^1.55.0",
"sass-loader": "^12.6.0",
"sharp": "^0.29.3",
"sharp": "^0.31.1",
"slate": "^0.72.8",
"slate-history": "^0.66.0",
"slate-hyperscript": "^0.66.0",
@@ -181,6 +185,7 @@
"terser-webpack-plugin": "^5.0.3",
"ts-essentials": "^7.0.1",
"url-loader": "^4.1.1",
"use-context-selector": "^1.4.1",
"uuid": "^8.1.0",
"webpack": "^5.6.0",
"webpack-bundle-analyzer": "^4.4.1",
@@ -190,7 +195,7 @@
},
"devDependencies": {
"@playwright/test": "^1.23.1",
"@release-it/conventional-changelog": "^2.0.0",
"@release-it/conventional-changelog": "^5.1.1",
"@testing-library/jest-dom": "^5.11.4",
"@testing-library/react": "^13.0.1",
"@trbl/eslint-config": "^1.2.4",
@@ -275,7 +280,7 @@
"mongodb-memory-server": "^7.2.0",
"nodemon": "^2.0.6",
"passport-strategy": "^1.0.0",
"release-it": "^14.2.2",
"release-it": "^15.5.0",
"rimraf": "^3.0.2",
"serve-static": "^1.14.2",
"shelljs": "^0.8.5",

View File

@@ -32,9 +32,12 @@ const Routes = () => {
const canAccessAdmin = permissions?.canAccessAdmin;
const config = useConfig();
const {
admin: {
user: userSlug,
logoutRoute,
inactivityRoute: logoutInactivityRoute,
components: {
routes: customRoutes,
} = {},
@@ -42,7 +45,8 @@ const Routes = () => {
routes,
collections,
globals,
} = useConfig();
} = config;
const userCollection = collections.find(({ slug }) => slug === userSlug);
@@ -103,10 +107,10 @@ const Routes = () => {
<Route path={`${match.url}/login`}>
<Login />
</Route>
<Route path={`${match.url}/logout`}>
<Route path={`${match.url}${logoutRoute}`}>
<Logout />
</Route>
<Route path={`${match.url}/logout-inactivity`}>
<Route path={`${match.url}${logoutInactivityRoute}`}>
<Logout inactivity />
</Route>

View File

@@ -3,7 +3,7 @@ import { useHistory } from 'react-router-dom';
import { toast } from 'react-toastify';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useConfig } from '../../utilities/Config';
import { useWatchForm, useFormModified } from '../../forms/Form/context';
import { useFormModified, useAllFormFields } from '../../forms/Form/context';
import { useLocale } from '../../utilities/Locale';
import { Props } from './types';
import reduceFieldsToValues from '../../forms/Form/reduceFieldsToValues';
@@ -17,7 +17,7 @@ const baseClass = 'autosave';
const Autosave: React.FC<Props> = ({ collection, global, id, publishedDocUpdatedAt }) => {
const { serverURL, routes: { api, admin } } = useConfig();
const { versions, getVersions } = useDocumentInfo();
const { fields, dispatchFields } = useWatchForm();
const [fields] = useAllFormFields();
const modified = useFormModified();
const locale = useLocale();
const { replace } = useHistory();
@@ -114,7 +114,7 @@ const Autosave: React.FC<Props> = ({ collection, global, id, publishedDocUpdated
};
autosave();
}, [debouncedFields, modified, serverURL, api, collection, global, id, dispatchFields, getVersions, locale]);
}, [debouncedFields, modified, serverURL, api, collection, global, id, getVersions, locale]);
useEffect(() => {
if (versions?.docs?.[0]) {

View File

@@ -37,7 +37,15 @@ const Duplicate: React.FC<Props> = ({ slug, collection, id }) => {
locale,
depth: 0,
});
const data = await response.json();
let data = await response.json();
if (typeof collection.admin.hooks?.beforeDuplicate === 'function') {
data = await collection.admin.hooks.beforeDuplicate({
data,
locale,
});
}
const result = await requests.post(`${serverURL}${api}/${slug}`, {
headers: {
'Content-Type': 'application/json',
@@ -65,7 +73,15 @@ const Duplicate: React.FC<Props> = ({ slug, collection, id }) => {
locale,
depth: 0,
});
const localizedDoc = await res.json();
let localizedDoc = await res.json();
if (typeof collection.admin.hooks?.beforeDuplicate === 'function') {
localizedDoc = await collection.admin.hooks.beforeDuplicate({
data: localizedDoc,
locale,
});
}
const patchResult = await requests.patch(`${serverURL}${api}/${slug}/${duplicateID}?locale=${locale}`, {
headers: {
'Content-Type': 'application/json',
@@ -97,7 +113,7 @@ const Duplicate: React.FC<Props> = ({ slug, collection, id }) => {
pathname: `${admin}/collections/${slug}/${duplicateID}`,
});
}, 10);
}, [modified, localization, collection.labels.singular, setModified, toggleModal, modalSlug, serverURL, api, slug, id, push, admin]);
}, [modified, localization, collection, setModified, toggleModal, modalSlug, serverURL, api, slug, id, push, admin]);
const confirm = useCallback(async () => {
setHasClicked(false);

View File

@@ -0,0 +1,11 @@
import { Field, FieldAffectingData, fieldAffectsData } from '../../../../fields/config/types';
import flattenFields from '../../../../utilities/flattenTopLevelFields';
export const getTextFieldsToBeSearched = (listSearchableFields: string[], fields: Field[]) => (): FieldAffectingData[] => {
if (listSearchableFields) {
const flattenedFields = flattenFields(fields);
return flattenedFields.filter((field) => fieldAffectsData(field) && listSearchableFields.includes(field.name)) as FieldAffectingData[];
}
return null;
};

View File

@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import AnimateHeight from 'react-animate-height';
import { FieldAffectingData, fieldAffectsData } from '../../../../fields/config/types';
import { fieldAffectsData } from '../../../../fields/config/types';
import SearchFilter from '../SearchFilter';
import ColumnSelector from '../ColumnSelector';
import WhereBuilder from '../WhereBuilder';
@@ -8,8 +8,8 @@ import SortComplex from '../SortComplex';
import Button from '../Button';
import { Props } from './types';
import { useSearchParams } from '../../utilities/SearchParams';
import validateWhereQuery from '../WhereBuilder/validateWhereQuery';
import { getTextFieldsToBeSearched } from './getTextFieldsToBeSearched';
import './index.scss';
@@ -38,7 +38,7 @@ const ListControls: React.FC<Props> = (props) => {
const shouldInitializeWhereOpened = validateWhereQuery(params?.where);
const [titleField] = useState(() => fields.find((field) => fieldAffectsData(field) && field.name === useAsTitle));
const [textFieldsToBeSearched] = useState(listSearchableFields ? () => fields.filter((field) => fieldAffectsData(field) && listSearchableFields.includes(field.name)) as FieldAffectingData[] : null);
const [textFieldsToBeSearched] = useState(getTextFieldsToBeSearched(listSearchableFields, fields));
const [visibleDrawer, setVisibleDrawer] = useState<'where' | 'sort' | 'columns'>(shouldInitializeWhereOpened ? 'where' : undefined);
return (

View File

@@ -0,0 +1,44 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { useConfig } from '../../utilities/Config';
import RenderCustomComponent from '../../utilities/RenderCustomComponent';
import LogOut from '../../icons/LogOut';
const baseClass = 'nav';
const DefaultLogout = () => {
const config = useConfig();
const {
routes: { admin },
admin: {
logoutRoute,
components: { logout }
}
} = config;
return (
<Link to={`${admin}${logoutRoute}`} className={`${baseClass}__log-out`}>
<LogOut />
</Link>
);
};
const Logout: React.FC = () => {
const {
admin: {
components: {
logout: { Button: CustomLogout } = {
Button: undefined,
},
} = {},
} = {},
} = useConfig();
return (
<RenderCustomComponent
CustomComponent={CustomLogout}
DefaultComponent={DefaultLogout}
/>
);
};
export default Logout;

View File

@@ -6,7 +6,7 @@
top: 0;
left: 0;
height: 100vh;
width: base(9);
width: var(--nav-width);
overflow: hidden;
border-right: 1px solid var(--theme-elevation-100);
@@ -120,10 +120,6 @@
}
}
@include large-break {
width: base(8);
}
@include mid-break {
@include blur-bg;
position: fixed;

View File

@@ -4,7 +4,6 @@ import { useConfig } from '../../utilities/Config';
import { useAuth } from '../../utilities/Auth';
import RenderCustomComponent from '../../utilities/RenderCustomComponent';
import Chevron from '../../icons/Chevron';
import LogOut from '../../icons/LogOut';
import Menu from '../../icons/Menu';
import CloseMenu from '../../icons/CloseMenu';
import Icon from '../../graphics/Icon';
@@ -14,6 +13,7 @@ import NavGroup from '../NavGroup';
import { groupNavItems, Group, EntityToGroup, EntityType } from '../../../utilities/groupNavItems';
import './index.scss';
import Logout from '../Logout';
const baseClass = 'nav';
@@ -31,7 +31,7 @@ const DefaultNav = () => {
admin: {
components: {
beforeNavLinks,
afterNavLinks,
afterNavLinks
},
},
} = useConfig();
@@ -137,12 +137,7 @@ const DefaultNav = () => {
>
<Account />
</Link>
<Link
to={`${admin}/logout`}
className={`${baseClass}__log-out`}
>
<LogOut />
</Link>
<Logout/>
</div>
</nav>
</div>

View File

@@ -2,9 +2,10 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useWindowInfo } from '@faceless-ui/window-info';
import { Props } from './types';
import PopupButton from './PopupButton';
import useIntersect from '../../../hooks/useIntersect';
import './index.scss';
import useIntersect from '../../../hooks/useIntersect';
import useThrottledEffect from '../../../hooks/useThrottledEffect';
const baseClass = 'popup';
@@ -41,26 +42,20 @@ const Popup: React.FC<Props> = (props) => {
const [verticalAlign, setVerticalAlign] = useState(verticalAlignFromProps);
const [horizontalAlign, setHorizontalAlign] = useState(horizontalAlignFromProps);
const handleClickOutside = useCallback((e) => {
if (contentRef.current.contains(e.target)) {
return;
}
setActive(false);
}, [contentRef]);
useEffect(() => {
const setPosition = useCallback(({ horizontal = false, vertical = false }) => {
if (contentRef.current) {
const bounds = contentRef.current.getBoundingClientRect();
const {
left: contentLeftPos,
right: contentRightPos,
top: contentTopPos,
bottom: contentBottomPos,
} = contentRef.current.getBoundingClientRect();
} = bounds;
let boundingTopPos = 100;
let boundingRightPos = windowWidth;
let boundingBottomPos = windowHeight;
let boundingRightPos = window.innerWidth;
let boundingBottomPos = window.innerHeight;
let boundingLeftPos = 0;
if (boundingRef?.current) {
@@ -72,19 +67,39 @@ const Popup: React.FC<Props> = (props) => {
} = boundingRef.current.getBoundingClientRect());
}
if (contentRightPos > boundingRightPos && contentLeftPos > boundingLeftPos) {
setHorizontalAlign('right');
} else if (contentLeftPos < boundingLeftPos && contentRightPos < boundingRightPos) {
setHorizontalAlign('left');
if (horizontal) {
if (contentRightPos > boundingRightPos && contentLeftPos > boundingLeftPos) {
setHorizontalAlign('right');
} else if (contentLeftPos < boundingLeftPos && contentRightPos < boundingRightPos) {
setHorizontalAlign('left');
}
}
if (contentTopPos < boundingTopPos && contentBottomPos < boundingBottomPos) {
setVerticalAlign('bottom');
} else if (contentBottomPos > boundingBottomPos && contentTopPos > boundingTopPos) {
setVerticalAlign('top');
if (vertical) {
if (contentTopPos < boundingTopPos && contentBottomPos < boundingBottomPos) {
setVerticalAlign('bottom');
} else if (contentBottomPos > boundingBottomPos && contentTopPos > boundingTopPos) {
setVerticalAlign('top');
}
}
}
}, [boundingRef, intersectionEntry, windowHeight, windowWidth]);
}, [boundingRef]);
const handleClickOutside = useCallback((e) => {
if (contentRef.current.contains(e.target)) {
return;
}
setActive(false);
}, [contentRef]);
useEffect(() => {
setPosition({ horizontal: true });
}, [intersectionEntry, setPosition, windowWidth]);
useEffect(() => {
setPosition({ vertical: true });
}, [intersectionEntry, setPosition, windowHeight]);
useEffect(() => {
if (typeof onToggleOpen === 'function') onToggleOpen(active);

View File

@@ -116,4 +116,8 @@ div.react-select {
background-color: var(--theme-error-200);
}
}
}
&.rs--is-disabled .rs__control {
background: var(--theme-elevation-200);
}
}

View File

@@ -99,6 +99,7 @@ const ReactSelect: React.FC<Props> = (props) => {
disabled={disabled ? 'disabled' : undefined}
className={classes}
classNamePrefix="rs"
captureMenuScroll
options={options}
isSearchable={isSearchable}
isClearable={isClearable}
@@ -117,6 +118,7 @@ const ReactSelect: React.FC<Props> = (props) => {
return (
<Select
placeholder={placeholder}
captureMenuScroll
{...props}
value={value}
onChange={onChange}

View File

@@ -68,8 +68,9 @@ const SearchFilter: React.FC<Props> = (props) => {
where: newWhere,
}),
});
setPreviousSearch(debouncedSearch);
}
setPreviousSearch(debouncedSearch);
}
}, [debouncedSearch, previousSearch, history, fieldName, params, handleChange, modifySearchQuery, listSearchableFields]);

View File

@@ -12,8 +12,8 @@ const UploadGallery: React.FC<Props> = (props) => {
if (docs && docs.length > 0) {
return (
<ul className={baseClass}>
{docs.map((doc, i) => (
<li key={i}>
{docs.map((doc) => (
<li key={String(doc.id)}>
<UploadCard
doc={doc}
{...{ collection }}

View File

@@ -1,11 +1,13 @@
import { createContext, useContext } from 'react';
import { Context } from './types';
import { useContextSelector, createContext as createSelectorContext, useContext as useFullContext } from 'use-context-selector';
import { Context, FormFieldsContext as FormFieldsContextType } from './types';
const FormContext = createContext({} as Context);
const FormWatchContext = createContext({} as Context);
const SubmittedContext = createContext(false);
const ProcessingContext = createContext(false);
const ModifiedContext = createContext(false);
const FormFieldsContext = createSelectorContext<FormFieldsContextType>([{}, () => null]);
const useForm = (): Context => useContext(FormContext);
const useWatchForm = (): Context => useContext(FormWatchContext);
@@ -13,15 +15,21 @@ const useFormSubmitted = (): boolean => useContext(SubmittedContext);
const useFormProcessing = (): boolean => useContext(ProcessingContext);
const useFormModified = (): boolean => useContext(ModifiedContext);
const useFormFields = <Value = unknown>(selector: (context: FormFieldsContextType) => Value): Value => useContextSelector(FormFieldsContext, selector);
const useAllFormFields = (): FormFieldsContextType => useFullContext(FormFieldsContext);
export {
FormContext,
FormWatchContext,
SubmittedContext,
ProcessingContext,
ModifiedContext,
useForm,
useWatchForm,
useFormSubmitted,
useFormProcessing,
useFormModified,
useForm,
FormContext,
FormFieldsContext,
useFormFields,
useAllFormFields,
FormWatchContext,
useWatchForm,
};

View File

@@ -1,43 +1,11 @@
import equal from 'deep-equal';
import { unflatten, flatten } from 'flatley';
import flattenFilters from './flattenFilters';
import getSiblingData from './getSiblingData';
import reduceFieldsToValues from './reduceFieldsToValues';
import { Fields } from './types';
import { Field, FieldAction, Fields } from './types';
import deepCopyObject from '../../../../utilities/deepCopyObject';
import { flattenRows, separateRows } from './rows';
const unflattenRowsFromState = (state: Fields, path) => {
// Take a copy of state
const remainingFlattenedState = { ...state };
const rowsFromStateObject = {};
const pathPrefixToRemove = path.substring(0, path.lastIndexOf('.') + 1);
// Loop over all keys from state
// If the key begins with the name of the parent field,
// Add value to rowsFromStateObject and delete it from remaining state
Object.keys(state).forEach((key) => {
if (key.indexOf(`${path}.`) === 0) {
if (!state[key].disableFormData) {
const name = key.replace(pathPrefixToRemove, '');
rowsFromStateObject[name] = state[key];
rowsFromStateObject[name].initialValue = rowsFromStateObject[name].value;
}
delete remainingFlattenedState[key];
}
});
const unflattenedRows = unflatten(rowsFromStateObject);
return {
unflattenedRows: unflattenedRows[path.replace(pathPrefixToRemove, '')] || [],
remainingFlattenedState,
};
};
function fieldReducer(state: Fields, action): Fields {
function fieldReducer(state: Fields, action: FieldAction): Fields {
switch (action.type) {
case 'REPLACE_STATE': {
const newState = {};
@@ -70,23 +38,27 @@ function fieldReducer(state: Fields, action): Fields {
case 'REMOVE_ROW': {
const { rowIndex, path } = action;
const { unflattenedRows, remainingFlattenedState } = unflattenRowsFromState(state, path);
const { remainingFields, rows } = separateRows(path, state);
unflattenedRows.splice(rowIndex, 1);
rows.splice(rowIndex, 1);
const flattenedRowState = unflattenedRows.length > 0 ? flatten({ [path]: unflattenedRows }, { filters: flattenFilters }) : {};
return {
...remainingFlattenedState,
...flattenedRowState,
const newState: Fields = {
...remainingFields,
[path]: {
...state[path],
value: rows.length,
disableFormData: rows.length > 0,
},
...flattenRows(path, rows),
};
return newState;
}
case 'ADD_ROW': {
const {
rowIndex, path, subFieldState, blockType,
} = action;
const { unflattenedRows, remainingFlattenedState } = unflattenRowsFromState(state, path);
if (blockType) {
subFieldState.blockType = {
@@ -96,15 +68,18 @@ function fieldReducer(state: Fields, action): Fields {
};
}
// If there are subfields
if (Object.keys(subFieldState).length > 0) {
// Add new object containing subfield names to unflattenedRows array
unflattenedRows.splice(rowIndex + 1, 0, subFieldState);
}
const { remainingFields, rows } = separateRows(path, state);
rows.splice(rowIndex + 1, 0, subFieldState);
const newState = {
...remainingFlattenedState,
...(flatten({ [path]: unflattenedRows }, { filters: flattenFilters })),
...remainingFields,
[path]: {
...state[path],
value: rows.length,
disableFormData: true,
},
...flattenRows(path, rows),
};
return newState;
@@ -115,20 +90,25 @@ function fieldReducer(state: Fields, action): Fields {
rowIndex, path,
} = action;
const { unflattenedRows, remainingFlattenedState } = unflattenRowsFromState(state, path);
const { remainingFields, rows } = separateRows(path, state);
const duplicate = deepCopyObject(unflattenedRows[rowIndex]);
const duplicate = deepCopyObject(rows[rowIndex]);
if (duplicate.id) delete duplicate.id;
// If there are subfields
if (Object.keys(duplicate).length > 0) {
// Add new object containing subfield names to unflattenedRows array
unflattenedRows.splice(rowIndex + 1, 0, duplicate);
rows.splice(rowIndex + 1, 0, duplicate);
}
const newState = {
...remainingFlattenedState,
...(flatten({ [path]: unflattenedRows }, { filters: flattenFilters })),
...remainingFields,
[path]: {
...state[path],
value: rows.length,
disableFormData: true,
},
...flattenRows(path, rows),
};
return newState;
@@ -136,18 +116,18 @@ function fieldReducer(state: Fields, action): Fields {
case 'MOVE_ROW': {
const { moveFromIndex, moveToIndex, path } = action;
const { unflattenedRows, remainingFlattenedState } = unflattenRowsFromState(state, path);
const { remainingFields, rows } = separateRows(path, state);
// copy the row to move
const copyOfMovingRow = unflattenedRows[moveFromIndex];
const copyOfMovingRow = rows[moveFromIndex];
// delete the row by index
unflattenedRows.splice(moveFromIndex, 1);
rows.splice(moveFromIndex, 1);
// insert row copyOfMovingRow back in
unflattenedRows.splice(moveToIndex, 0, copyOfMovingRow);
rows.splice(moveToIndex, 0, copyOfMovingRow);
const newState = {
...remainingFlattenedState,
...(flatten({ [path]: unflattenedRows }, { filters: flattenFilters })),
...remainingFields,
...flattenRows(path, rows),
};
return newState;
@@ -186,23 +166,27 @@ function fieldReducer(state: Fields, action): Fields {
}, {});
}
default: {
const newField = {
value: action.value,
valid: action.valid,
errorMessage: action.errorMessage,
disableFormData: action.disableFormData,
initialValue: action.initialValue,
validate: action.validate,
condition: action.condition,
passesCondition: action.passesCondition,
};
case 'UPDATE': {
const newField = Object.entries(action).reduce((field, [key, value]) => {
if (['value', 'valid', 'errorMessage', 'disableFormData', 'initialValue', 'validate', 'condition', 'passesCondition'].includes(key)) {
return {
...field,
[key]: value,
};
}
return field;
}, state[action.path] || {} as Field);
return {
...state,
[action.path]: newField,
};
}
default: {
return state;
}
}
}

View File

@@ -1,10 +0,0 @@
const flattenFilters = [{
test: (_: string, value: unknown): boolean => {
const hasValidProperty = Object.prototype.hasOwnProperty.call(value, 'valid');
const hasValueProperty = Object.prototype.hasOwnProperty.call(value, 'value');
return (hasValidProperty && hasValueProperty);
},
}];
export default flattenFilters;

View File

@@ -21,7 +21,7 @@ import { Field } from '../../../../fields/config/types';
import buildInitialState from './buildInitialState';
import errorMessages from './errorMessages';
import { Context as FormContextType, GetDataByPath, Props, SubmitOptions } from './types';
import { SubmittedContext, ProcessingContext, ModifiedContext, FormContext, FormWatchContext } from './context';
import { SubmittedContext, ProcessingContext, ModifiedContext, FormContext, FormFieldsContext, FormWatchContext } from './context';
import buildStateFromSchema from './buildStateFromSchema';
import { useOperation } from '../../utilities/OperationProvider';
@@ -63,12 +63,11 @@ const Form: React.FC<Props> = (props) => {
if (formattedInitialData) initialFieldState = formattedInitialData;
if (initialState) initialFieldState = initialState;
// Allow access to initialState for field types such as Blocks and Array
contextRef.current.initialState = initialState;
const [fields, dispatchFields] = useReducer(fieldReducer, {}, () => initialFieldState);
const fieldsReducer = useReducer(fieldReducer, {}, () => initialFieldState);
const [fields, dispatchFields] = fieldsReducer;
contextRef.current.fields = fields;
contextRef.current.dispatchFields = dispatchFields;
const validateForm = useCallback(async () => {
const validatedFieldState = {};
@@ -111,7 +110,7 @@ const Form: React.FC<Props> = (props) => {
}
return isValid;
}, [contextRef, id, user, operation]);
}, [contextRef, id, user, operation, dispatchFields]);
const submit = useCallback(async (options: SubmitOptions = {}, e): Promise<void> => {
const {
@@ -254,6 +253,7 @@ const Form: React.FC<Props> = (props) => {
fieldErrors.forEach((err) => {
dispatchFields({
type: 'UPDATE',
...(contextRef.current?.fields?.[err.field] || {}),
valid: false,
errorMessage: err.message,
@@ -268,7 +268,7 @@ const Form: React.FC<Props> = (props) => {
return;
}
const message = errorMessages[res.status] || 'An unknown error occurrred.';
const message = errorMessages[res.status] || 'An unknown error occurred.';
toast.error(message);
}
@@ -283,6 +283,7 @@ const Form: React.FC<Props> = (props) => {
action,
disableSuccessStatus,
disabled,
dispatchFields,
fields,
handleResponse,
history,
@@ -298,7 +299,6 @@ const Form: React.FC<Props> = (props) => {
const getData = useCallback(() => reduceFieldsToValues(contextRef.current.fields, true), [contextRef]);
const getSiblingData = useCallback((path: string) => getSiblingDataFunc(contextRef.current.fields, path), [contextRef]);
const getDataByPath = useCallback<GetDataByPath>((path: string) => getDataByPathFunc(contextRef.current.fields, path), [contextRef]);
const getUnflattenedValues = useCallback(() => reduceFieldsToValues(contextRef.current.fields), [contextRef]);
const createFormData = useCallback((overrides: any = {}) => {
const data = reduceFieldsToValues(contextRef.current.fields, true);
@@ -328,16 +328,14 @@ const Form: React.FC<Props> = (props) => {
const state = await buildStateFromSchema({ fieldSchema, data, user, id, operation, locale });
contextRef.current = { ...initContextState } as FormContextType;
dispatchFields({ type: 'REPLACE_STATE', state });
}, [id, user, operation, locale]);
}, [id, user, operation, locale, dispatchFields]);
contextRef.current.dispatchFields = dispatchFields;
contextRef.current.submit = submit;
contextRef.current.getFields = getFields;
contextRef.current.getField = getField;
contextRef.current.getData = getData;
contextRef.current.getSiblingData = getSiblingData;
contextRef.current.getDataByPath = getDataByPath;
contextRef.current.getUnflattenedValues = getUnflattenedValues;
contextRef.current.validateForm = validateForm;
contextRef.current.createFormData = createFormData;
contextRef.current.setModified = setModified;
@@ -352,7 +350,7 @@ const Form: React.FC<Props> = (props) => {
contextRef.current = { ...initContextState } as FormContextType;
dispatchFields({ type: 'REPLACE_STATE', state: initialState });
}
}, [initialState]);
}, [initialState, dispatchFields]);
useEffect(() => {
if (initialData) {
@@ -361,20 +359,12 @@ const Form: React.FC<Props> = (props) => {
setFormattedInitialData(builtState);
dispatchFields({ type: 'REPLACE_STATE', state: builtState });
}
}, [initialData]);
}, [initialData, dispatchFields]);
useThrottledEffect(() => {
refreshCookie();
}, 15000, [fields]);
// Re-run form validation every second
// as fields change, because field validations can
// potentially rely on OTHER field values to determine
// if they are valid or not (siblingData, data)
useThrottledEffect(() => {
validateForm();
}, 1000, [validateForm, fields]);
useEffect(() => {
contextRef.current = { ...contextRef.current }; // triggers rerender of all components that subscribe to form
setModified(false);
@@ -403,7 +393,9 @@ const Form: React.FC<Props> = (props) => {
<SubmittedContext.Provider value={submitted}>
<ProcessingContext.Provider value={processing}>
<ModifiedContext.Provider value={modified}>
{children}
<FormFieldsContext.Provider value={fieldsReducer}>
{children}
</FormFieldsContext.Provider>
</ModifiedContext.Provider>
</ProcessingContext.Provider>
</SubmittedContext.Provider>

View File

@@ -6,7 +6,6 @@ import {
Submit,
Context,
GetSiblingData,
GetUnflattenedValues,
ValidateForm,
CreateFormData,
SetModified,
@@ -17,7 +16,6 @@ import {
const submit: Submit = () => undefined;
const getSiblingData: GetSiblingData = () => undefined;
const getUnflattenedValues: GetUnflattenedValues = () => ({});
const dispatchFields: DispatchFields = () => undefined;
const validateForm: ValidateForm = () => undefined;
const createFormData: CreateFormData = () => undefined;
@@ -28,12 +26,11 @@ const setSubmitted: SetSubmitted = () => undefined;
const reset: Reset = () => undefined;
const initialContextState: Context = {
getFields: (): Fields => ({ }),
getFields: (): Fields => ({}),
getField: (): Field => undefined,
getData: (): Data => undefined,
getSiblingData,
getDataByPath: () => undefined,
getUnflattenedValues,
validateForm,
createFormData,
submit,
@@ -41,7 +38,6 @@ const initialContextState: Context = {
setModified,
setProcessing,
setSubmitted,
initialState: {},
fields: {},
disabled: false,
formRef: null,

View File

@@ -0,0 +1,41 @@
import { Fields } from './types';
type Result = {
remainingFields: Fields
rows: Fields[]
}
export const separateRows = (path: string, fields: Fields): Result => {
const remainingFields: Fields = {};
const rows = Object.entries(fields).reduce((incomingRows, [fieldPath, field]) => {
const newRows = incomingRows;
if (fieldPath.indexOf(`${path}.`) === 0) {
const index = Number(fieldPath.replace(`${path}.`, '').split('.')[0]);
if (!newRows[index]) newRows[index] = {};
newRows[index][fieldPath.replace(`${path}.${String(index)}.`, '')] = field;
} else {
remainingFields[fieldPath] = field;
}
return newRows;
}, []);
return {
remainingFields,
rows,
};
};
export const flattenRows = (path: string, rows: Fields[]): Fields => {
return rows.reduce((fields, row, i) => ({
...fields,
...Object.entries(row).reduce((subFields, [subPath, subField]) => {
return {
...subFields,
[`${path}.${i}.${subPath}`]: subField,
};
}, {}),
}), {});
};

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { Dispatch } from 'react';
import { Field as FieldConfig, Condition, Validate } from '../../../../fields/config/types';
export type Field = {
@@ -50,14 +50,13 @@ export type SubmitOptions = {
}
export type DispatchFields = React.Dispatch<any>
export type Submit = (options?: SubmitOptions, e?: React.FormEvent<HTMLFormElement>) => void;
export type Submit = (options?: SubmitOptions, e?: React.FormEvent<HTMLFormElement>) => Promise<void>;
export type ValidateForm = () => Promise<boolean>;
export type CreateFormData = (overrides?: any) => FormData;
export type GetFields = () => Fields;
export type GetField = (path: string) => Field;
export type GetData = () => Data;
export type GetSiblingData = (path: string) => Data;
export type GetUnflattenedValues = () => Data;
export type GetDataByPath = <T = unknown>(path: string) => T;
export type SetModified = (modified: boolean) => void;
export type SetSubmitted = (submitted: boolean) => void;
@@ -65,11 +64,73 @@ export type SetProcessing = (processing: boolean) => void;
export type Reset = (fieldSchema: FieldConfig[], data: unknown) => Promise<void>
export type REPLACE_STATE = {
type: 'REPLACE_STATE'
state: Fields
}
export type REMOVE = {
type: 'REMOVE'
path: string
}
export type REMOVE_ROW = {
type: 'REMOVE_ROW'
rowIndex: number
path: string
}
export type ADD_ROW = {
type: 'ADD_ROW'
rowIndex: number
path: string
subFieldState?: Fields
blockType?: string
}
export type DUPLICATE_ROW = {
type: 'DUPLICATE_ROW'
rowIndex: number
path: string
}
export type MOVE_ROW = {
type: 'MOVE_ROW'
moveFromIndex: number
moveToIndex: number
path: string
}
export type MODIFY_CONDITION = {
type: 'MODIFY_CONDITION'
path: string
result: boolean
}
export type UPDATE = {
type: 'UPDATE'
path: string
} & Partial<Field>
export type FieldAction =
| REPLACE_STATE
| REMOVE
| REMOVE_ROW
| ADD_ROW
| DUPLICATE_ROW
| MOVE_ROW
| MODIFY_CONDITION
| UPDATE
export type FormFieldsContext = [Fields, Dispatch<FieldAction>]
export type Context = {
dispatchFields: DispatchFields
submit: Submit
/**
* @deprecated Form context fields may be outdated and should not be relied on. Instead, prefer `useFormFields`.
*/
fields: Fields
initialState: Fields
submit: Submit
dispatchFields: Dispatch<FieldAction>
validateForm: ValidateForm
createFormData: CreateFormData
disabled: boolean
@@ -77,7 +138,6 @@ export type Context = {
getField: GetField
getData: GetData
getSiblingData: GetSiblingData
getUnflattenedValues: GetUnflattenedValues
getDataByPath: GetDataByPath
setModified: SetModified
setProcessing: SetProcessing

View File

@@ -74,57 +74,55 @@ const ArrayFieldType: React.FC<Props> = (props) => {
const locale = useLocale();
const operation = useOperation();
const { dispatchFields } = formContext;
const { dispatchFields, setModified } = formContext;
const memoizedValidate = useCallback((value, options) => {
return validate(value, { ...options, minRows, maxRows, required });
}, [maxRows, minRows, required, validate]);
const [disableFormData, setDisableFormData] = useState(false);
const {
showError,
errorMessage,
value,
setValue,
} = useField({
path,
validate: memoizedValidate,
disableFormData,
condition,
disableFormData: rows?.length > 0,
});
const addRow = useCallback(async (rowIndex: number) => {
const subFieldState = await buildStateFromSchema({ fieldSchema: fields, operation, id, user, locale });
dispatchFields({ type: 'ADD_ROW', rowIndex, subFieldState, path });
dispatchRows({ type: 'ADD', rowIndex });
setValue(value as number + 1);
setModified(true);
setTimeout(() => {
scrollToID(`${path}-row-${rowIndex + 1}`);
}, 0);
}, [dispatchRows, dispatchFields, fields, path, setValue, value, operation, id, user, locale]);
}, [dispatchRows, dispatchFields, fields, path, operation, id, user, locale, setModified]);
const duplicateRow = useCallback(async (rowIndex: number) => {
dispatchFields({ type: 'DUPLICATE_ROW', rowIndex, path });
dispatchRows({ type: 'ADD', rowIndex });
setValue(value as number + 1);
setModified(true);
setTimeout(() => {
scrollToID(`${path}-row-${rowIndex + 1}`);
}, 0);
}, [dispatchRows, dispatchFields, path, setValue, value]);
}, [dispatchRows, dispatchFields, path, setModified]);
const removeRow = useCallback((rowIndex: number) => {
dispatchRows({ type: 'REMOVE', rowIndex });
dispatchFields({ type: 'REMOVE_ROW', rowIndex, path });
setValue(value as number - 1);
}, [dispatchRows, dispatchFields, path, value, setValue]);
setModified(true);
}, [dispatchRows, dispatchFields, path, setModified]);
const moveRow = useCallback((moveFromIndex: number, moveToIndex: number) => {
dispatchRows({ type: 'MOVE', moveFromIndex, moveToIndex });
dispatchFields({ type: 'MOVE_ROW', moveFromIndex, moveToIndex, path });
}, [dispatchRows, dispatchFields, path]);
setModified(true);
}, [dispatchRows, dispatchFields, path, setModified]);
const onDragEnd = useCallback((result) => {
if (!result.destination) return;
@@ -197,16 +195,6 @@ const ArrayFieldType: React.FC<Props> = (props) => {
initializeRowState();
}, [formContext, path, getPreference, preferencesKey, initCollapsed]);
useEffect(() => {
setValue(rows?.length || 0, true);
if (rows?.length === 0) {
setDisableFormData(false);
} else {
setDisableFormData(true);
}
}, [rows, setValue]);
const hasMaxRows = maxRows && rows?.length >= maxRows;
const classes = [

View File

@@ -37,7 +37,7 @@ const labelDefaults = {
plural: 'Blocks',
};
const Index: React.FC<Props> = (props) => {
const BlocksField: React.FC<Props> = (props) => {
const {
label,
name,
@@ -70,25 +70,23 @@ const Index: React.FC<Props> = (props) => {
const { id } = useDocumentInfo();
const locale = useLocale();
const operation = useOperation();
const { dispatchFields } = formContext;
const { dispatchFields, setModified } = formContext;
const [selectorIndexOpen, setSelectorIndexOpen] = useState<number>();
const memoizedValidate = useCallback((value, options) => {
return validate(value, { ...options, minRows, maxRows, required });
}, [maxRows, minRows, required, validate]);
const [disableFormData, setDisableFormData] = useState(false);
const [selectorIndexOpen, setSelectorIndexOpen] = useState<number>();
const {
showError,
errorMessage,
value,
setValue,
} = useField<number>({
path,
validate: memoizedValidate,
disableFormData,
condition,
disableFormData: rows?.length > 0,
});
const onAddPopupToggle = useCallback((open) => {
@@ -102,33 +100,34 @@ const Index: React.FC<Props> = (props) => {
const subFieldState = await buildStateFromSchema({ fieldSchema: block.fields, operation, id, user, locale });
dispatchFields({ type: 'ADD_ROW', rowIndex, subFieldState, path, blockType });
dispatchRows({ type: 'ADD', rowIndex, blockType });
setValue(value as number + 1);
setModified(true);
setTimeout(() => {
scrollToID(`${path}-row-${rowIndex + 1}`);
}, 0);
}, [path, setValue, value, blocks, dispatchFields, operation, id, user, locale]);
}, [path, blocks, dispatchFields, operation, id, user, locale, setModified]);
const duplicateRow = useCallback(async (rowIndex: number, blockType: string) => {
dispatchFields({ type: 'DUPLICATE_ROW', rowIndex, path });
dispatchRows({ type: 'ADD', rowIndex, blockType });
setValue(value as number + 1);
setModified(true);
setTimeout(() => {
scrollToID(`${path}-row-${rowIndex + 1}`);
}, 0);
}, [dispatchRows, dispatchFields, path, setValue, value]);
}, [dispatchRows, dispatchFields, path, setModified]);
const removeRow = useCallback((rowIndex: number) => {
dispatchRows({ type: 'REMOVE', rowIndex });
dispatchFields({ type: 'REMOVE_ROW', rowIndex, path });
setValue(value as number - 1);
}, [path, setValue, value, dispatchFields]);
setModified(true);
}, [path, dispatchFields, setModified]);
const moveRow = useCallback((moveFromIndex: number, moveToIndex: number) => {
dispatchRows({ type: 'MOVE', moveFromIndex, moveToIndex });
dispatchFields({ type: 'MOVE_ROW', moveFromIndex, moveToIndex, path });
}, [dispatchRows, dispatchFields, path]);
setModified(true);
}, [dispatchRows, dispatchFields, path, setModified]);
const onDragEnd = useCallback((result) => {
if (!result.destination) return;
@@ -199,16 +198,6 @@ const Index: React.FC<Props> = (props) => {
initializeRowState();
}, [formContext, path, getPreference, preferencesKey, initCollapsed]);
useEffect(() => {
setValue(rows?.length || 0, true);
if (rows?.length === 0) {
setDisableFormData(false);
} else {
setDisableFormData(true);
}
}, [rows, setValue]);
const hasMaxRows = maxRows && rows?.length >= maxRows;
const classes = [
@@ -419,4 +408,4 @@ const Index: React.FC<Props> = (props) => {
);
};
export default withCondition(Index);
export default withCondition(BlocksField);

View File

@@ -60,7 +60,6 @@ const Code: React.FC<Props> = (props) => {
} = useField({
path,
validate: memoizedValidate,
enableDebouncedValue: true,
condition,
});

View File

@@ -2,15 +2,15 @@ import React, { useCallback } from 'react';
import useField from '../../useField';
import Label from '../../Label';
import Error from '../../Error';
import { useWatchForm } from '../../Form/context';
import { useFormFields } from '../../Form/context';
import './index.scss';
import { Field } from '../../Form/types';
const ConfirmPassword: React.FC = () => {
const { getField } = useWatchForm();
const password = getField('password');
const password = useFormFields<Field>(([fields]) => fields.password);
const validate = useCallback((value) => {
const validate = useCallback((value: string) => {
if (!value) {
return 'This field is required';
}
@@ -31,7 +31,6 @@ const ConfirmPassword: React.FC = () => {
path: 'confirm-password',
disableFormData: true,
validate,
enableDebouncedValue: true,
});
const classes = [

View File

@@ -37,7 +37,6 @@ const Email: React.FC<Props> = (props) => {
const fieldType = useField({
path,
validate: memoizedValidate,
enableDebouncedValue: true,
condition,
});

View File

@@ -73,7 +73,11 @@
}
}
.group-field + .group-field {
.group-field+.group-field {
margin-top: base(-2);
border-top: 0;
}
.group-field--within-collapsible+.group-field--within-collapsible {
margin-top: base(-1);
}

View File

@@ -44,7 +44,6 @@ const NumberField: React.FC<Props> = (props) => {
} = useField({
path,
validate: memoizedValidate,
enableDebouncedValue: true,
condition,
});

View File

@@ -37,7 +37,6 @@ const Password: React.FC<Props> = (props) => {
} = useField({
path,
validate: memoizedValidate,
enableDebouncedValue: true,
});
const classes = [

View File

@@ -44,7 +44,6 @@ const PointField: React.FC<Props> = (props) => {
} = useField<[number, number]>({
path,
validate: memoizedValidate,
enableDebouncedValue: true,
condition,
});

View File

@@ -0,0 +1,117 @@
@import '../../../../../../scss/styles.scss';
.relationship-add-new-modal {
display: flex;
overflow: hidden;
position: fixed;
height: 100vh;
&__blur-bg {
@include blur-bg();
position: absolute;
z-index: 1;
top: 0;
right: 0;
bottom: 0;
left: 0;
opacity: 0;
transition: all 300ms ease-out;
}
.collection-edit {
@include blur-bg();
transform: translateX(#{base(4)});
opacity: 0;
transition: all 300ms ease-out;
position: relative;
z-index: 2;
}
.collection-edit__form {
overflow: auto;
position: relative;
z-index: 1;
}
.collection-edit__document-actions {
&:before,
&:after {
content: none;
}
}
&--animated {
.collection-edit,
.relationship-add-new-modal__blur-bg,
.relationship-add-new-modal__close {
opacity: 1;
}
.collection-edit {
transform: translateX(0);
}
}
.collection-edit__document-actions {
margin-top: base(2.75);
}
&__close {
@extend %btn-reset;
position: relative;
z-index: 2;
flex-shrink: 0;
text-indent: -9999px;
background: rgba(0, 0, 0, 0.08);
cursor: pointer;
opacity: 0;
transition: all 300ms ease-in-out;
transition-delay: 100ms;
&:active,
&:focus {
outline: 0;
}
}
&__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-top: base(2.5);
}
&__header-close {
svg {
width: base(2.5);
height: base(2.5);
position: relative;
top: base(-.5);
right: base(-.75);
.stroke {
stroke-width: .5px;
}
}
}
@include mid-break {
&__header-close {
svg {
top: base(-.75);
}
}
&__close {
width: base(1);
}
}
}
html[data-theme=dark] {
.relationship-add-new-modal__close {
background: rgba(0, 0, 0, 0.2);
}
}

View File

@@ -0,0 +1,109 @@
import React, { useEffect, useState } from 'react';
import { Modal, useModal } from '@faceless-ui/modal';
import { useWindowInfo } from '@faceless-ui/window-info';
import Button from '../../../../../elements/Button';
import { Props } from './types';
import { useAuth } from '../../../../../utilities/Auth';
import RenderCustomComponent from '../../../../../utilities/RenderCustomComponent';
import { useLocale } from '../../../../../utilities/Locale';
import { useConfig } from '../../../../../utilities/Config';
import DefaultEdit from '../../../../../views/collections/Edit/Default';
import X from '../../../../../icons/X';
import { Fields } from '../../../../Form/types';
import buildStateFromSchema from '../../../../Form/buildStateFromSchema';
import { EditDepthContext, useEditDepth } from '../../../../../utilities/EditDepth';
import { DocumentInfoProvider } from '../../../../../utilities/DocumentInfo';
import './index.scss';
const baseClass = 'relationship-add-new-modal';
export const AddNewRelationModal: React.FC<Props> = ({ modalCollection, onSave, modalSlug }) => {
const { serverURL, routes: { api } } = useConfig();
const { toggleModal } = useModal();
const { breakpoints: { m: midBreak } } = useWindowInfo();
const locale = useLocale();
const { permissions, user } = useAuth();
const [initialState, setInitialState] = useState<Fields>();
const [isAnimated, setIsAnimated] = useState(false);
const editDepth = useEditDepth();
const modalAction = `${serverURL}${api}/${modalCollection.slug}?locale=${locale}&depth=0&fallback-locale=null`;
useEffect(() => {
const buildState = async () => {
const state = await buildStateFromSchema({ fieldSchema: modalCollection.fields, data: {}, user, operation: 'create', locale });
setInitialState(state);
};
buildState();
}, [modalCollection, locale, user]);
useEffect(() => {
setIsAnimated(true);
}, []);
return (
<Modal
slug={modalSlug}
className={[
baseClass,
isAnimated && `${baseClass}--animated`,
].filter(Boolean).join(' ')}
>
{editDepth === 1 && (
<div className={`${baseClass}__blur-bg`} />
)}
<DocumentInfoProvider collection={modalCollection}>
<EditDepthContext.Provider value={editDepth + 1}>
<button
className={`${baseClass}__close`}
type="button"
onClick={() => toggleModal(modalSlug)}
style={{
width: `calc(${midBreak ? 'var(--gutter-h)' : 'var(--nav-width)'} + ${editDepth - 1} * 25px)`,
}}
>
<span>
Close
</span>
</button>
<RenderCustomComponent
DefaultComponent={DefaultEdit}
CustomComponent={modalCollection.admin?.components?.views?.Edit}
componentProps={{
isLoading: !initialState,
data: {},
collection: modalCollection,
permissions: permissions.collections[modalCollection.slug],
isEditing: false,
onSave,
initialState,
hasSavePermission: true,
action: modalAction,
disableEyebrow: true,
disableActions: true,
disableLeaveWithoutSaving: true,
customHeader: (
<div className={`${baseClass}__header`}>
<h2>
Add new
{' '}
{modalCollection.labels.singular}
</h2>
<Button
buttonStyle="none"
className={`${baseClass}__header-close`}
onClick={() => toggleModal(modalSlug)}
>
<X />
</Button>
</div>
),
}}
/>
</EditDepthContext.Provider>
</DocumentInfoProvider>
</Modal>
);
};

View File

@@ -0,0 +1,7 @@
import { SanitizedCollectionConfig } from '../../../../../../../collections/config/types';
export type Props = {
modalSlug: string
modalCollection: SanitizedCollectionConfig
onSave: (json: Record<string, unknown>) => void
}

View File

@@ -0,0 +1,60 @@
@import '../../../../../scss/styles.scss';
.relationship-add-new {
display: flex;
align-items: stretch;
.popup__wrapper {
display: flex;
align-items: stretch;
height: 100%;
}
&__add-button {
@include formInput;
height: 100%;
margin-left: -1px;
display: flex;
padding: 0;
.btn__content,
.btn__label {
display: flex;
}
.btn__content,
.btn__label {
height: 100%;
}
.btn__label {
padding: 0 base(.5);
align-items: center;
}
}
&__relations {
list-style: none;
margin: 0;
padding: 0;
li:not(:last-child) {
margin-bottom: base(.375);
}
}
&__relation-button {
@extend %btn-reset;
cursor: pointer;
@extend %btn-reset;
display: block;
padding: base(.125) 0;
text-align: center;
width: 100%;
&:hover {
opacity: .7;
}
}
}

View File

@@ -0,0 +1,138 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useModal } from '@faceless-ui/modal';
import Button from '../../../../elements/Button';
import { Props } from './types';
import { SanitizedCollectionConfig } from '../../../../../../collections/config/types';
import Popup from '../../../../elements/Popup';
import { useRelatedCollections } from './useRelatedCollections';
import { useAuth } from '../../../../utilities/Auth';
import { AddNewRelationModal } from './Modal';
import { useEditDepth } from '../../../../utilities/EditDepth';
import Plus from '../../../../icons/Plus';
import './index.scss';
const baseClass = 'relationship-add-new';
export const AddNewRelation: React.FC<Props> = ({ path, hasMany, relationTo, value, setValue, dispatchOptions }) => {
const relatedCollections = useRelatedCollections(relationTo);
const { toggleModal, isModalOpen } = useModal();
const { permissions } = useAuth();
const [hasPermission, setHasPermission] = useState(false);
const [modalCollection, setModalCollection] = useState<SanitizedCollectionConfig>();
const [popupOpen, setPopupOpen] = useState(false);
const editDepth = useEditDepth();
const modalSlug = `${path}-add-modal-depth-${editDepth}`;
const openModal = useCallback(async (collection: SanitizedCollectionConfig) => {
setModalCollection(collection);
toggleModal(modalSlug);
}, [toggleModal, modalSlug]);
const onSave = useCallback((json) => {
const newValue = Array.isArray(relationTo) ? {
relationTo: modalCollection.slug,
value: json.doc.id,
} : json.doc.id;
dispatchOptions({
type: 'ADD',
hasMultipleRelations: Array.isArray(relationTo),
collection: modalCollection,
docs: [
json.doc,
],
sort: true,
});
if (hasMany) {
setValue([...(Array.isArray(value) ? value : []), newValue]);
} else {
setValue(newValue);
}
setModalCollection(undefined);
toggleModal(modalSlug);
}, [relationTo, modalCollection, hasMany, toggleModal, modalSlug, setValue, value, dispatchOptions]);
const onPopopToggle = useCallback((state) => {
setPopupOpen(state);
}, []);
useEffect(() => {
if (permissions) {
if (relatedCollections.length === 1) {
setHasPermission(permissions.collections[relatedCollections[0].slug].create.permission);
} else {
setHasPermission(relatedCollections.some((collection) => permissions.collections[collection.slug].create.permission));
}
}
}, [permissions, relatedCollections]);
useEffect(() => {
if (!isModalOpen(modalSlug)) {
setModalCollection(undefined);
}
}, [isModalOpen, modalSlug]);
return hasPermission ? (
<div
className={baseClass}
id={`${path}-add-new`}
>
{relatedCollections.length === 1 && (
<Button
className={`${baseClass}__add-button`}
onClick={() => openModal(relatedCollections[0])}
buttonStyle="none"
tooltip={`Add new ${relatedCollections[0].labels.singular}`}
>
<Plus />
</Button>
)}
{relatedCollections.length > 1 && (
<Popup
buttonType="custom"
horizontalAlign="center"
onToggleOpen={onPopopToggle}
button={(
<Button
className={`${baseClass}__add-button`}
buttonStyle="none"
tooltip={popupOpen ? undefined : 'Add new'}
>
<Plus />
</Button>
)}
render={({ close: closePopup }) => (
<ul className={`${baseClass}__relations`}>
{relatedCollections.map((relatedCollection) => {
if (permissions.collections[relatedCollection.slug].create.permission) {
return (
<li key={relatedCollection.slug}>
<button
className={`${baseClass}__relation-button ${baseClass}__relation-button--${relatedCollection.slug}`}
type="button"
onClick={() => { closePopup(); openModal(relatedCollection); }}
>
{relatedCollection.labels.singular}
</button>
</li>
);
}
return null;
})}
</ul>
)}
/>
)}
{modalCollection && (
<AddNewRelationModal
{...{ onSave, modalSlug, modalCollection }}
/>
)}
</div>
) : null;
};

View File

@@ -0,0 +1,11 @@
import React from 'react';
import { Action } from '../types';
export type Props = {
hasMany: boolean
relationTo: string | string[]
path: string
value: unknown
setValue: (value: unknown) => void
dispatchOptions: React.Dispatch<Action>
}

View File

@@ -0,0 +1,13 @@
import { useState } from 'react';
import { SanitizedCollectionConfig } from '../../../../../../collections/config/types';
import { useConfig } from '../../../../utilities/Config';
export const useRelatedCollections = (relationTo: string | string[]): SanitizedCollectionConfig[] => {
const config = useConfig();
const [relatedCollections] = useState(() => {
const relations = typeof relationTo === 'string' ? [relationTo] : relationTo;
return relations.map((relation) => config.collections.find((collection) => collection.slug === relation));
});
return relatedCollections;
};

View File

@@ -5,18 +5,21 @@
margin-bottom: $baseline;
}
.relationship__error-loading {
border: 1px solid var(--theme-error-500);
min-height: base(2);
padding: base(.5) base(.75);
background-color: var(--theme-error-500);
color: var(--theme-elevation-0);
}
.relationship {
&__wrap {
display: flex;
width: 100%;
.relationship--read-only {
div.react-select {
div.rs__control {
background: var(--theme-elevation-100);
div.react-select {
flex-grow: 1;
}
}
}
&__error-loading {
border: 1px solid var(--theme-error-500);
min-height: base(2);
padding: base(.5) base(.75);
background-color: var(--theme-error-500);
color: var(--theme-elevation-0);
}
}

View File

@@ -15,7 +15,7 @@ import FieldDescription from '../../FieldDescription';
import { relationship } from '../../../../../fields/validations';
import { Where } from '../../../../../types';
import { PaginatedDocs } from '../../../../../mongoose/types';
import { useFormProcessing, useWatchForm } from '../../Form/context';
import { useFormProcessing, useAllFormFields } from '../../Form/context';
import optionsReducer from './optionsReducer';
import { Props, Option, ValueWithRelation, GetResults } from './types';
import { createRelationMap } from './createRelationMap';
@@ -23,6 +23,9 @@ import { useDebouncedCallback } from '../../../../hooks/useDebouncedCallback';
import { useDocumentInfo } from '../../../utilities/DocumentInfo';
import { getFilterOptionsQuery } from '../getFilterOptionsQuery';
import wordBoundariesRegex from '../../../../../utilities/wordBoundariesRegex';
import reduceFieldsToValues from '../../Form/reduceFieldsToValues';
import getSiblingData from '../../Form/getSiblingData';
import { AddNewRelation } from './AddNew';
import './index.scss';
@@ -61,7 +64,7 @@ const Relationship: React.FC<Props> = (props) => {
const { id } = useDocumentInfo();
const { user, permissions } = useAuth();
const { getData, getSiblingData } = useWatchForm();
const [fields] = useAllFormFields();
const formProcessing = useFormProcessing();
const hasMultipleRelations = Array.isArray(relationTo);
const [options, dispatchOptions] = useReducer(optionsReducer, required || hasMany ? [] : [{ value: null, label: 'None' }]);
@@ -73,6 +76,7 @@ const Relationship: React.FC<Props> = (props) => {
const [search, setSearch] = useState('');
const [enableWordBoundarySearch, setEnableWordBoundarySearch] = useState(false);
const firstRun = useRef(true);
const fieldsRef = useRef(fields);
const memoizedValidate = useCallback((value, validationOptions) => {
return validate(value, { ...validationOptions, required });
@@ -90,6 +94,13 @@ const Relationship: React.FC<Props> = (props) => {
condition,
});
const getFormData = useCallback(() => {
return [
reduceFieldsToValues(fieldsRef.current, true),
getSiblingData(fieldsRef.current, path),
];
}, [fieldsRef, path]);
const getResults: GetResults = useCallback(async ({
lastFullyLoadedRelation: lastFullyLoadedRelationArg,
lastLoadedPage: lastLoadedPageArg,
@@ -154,7 +165,7 @@ const Relationship: React.FC<Props> = (props) => {
const data: PaginatedDocs<unknown> = await response.json();
if (data.docs.length > 0) {
resultsFetched += data.docs.length;
dispatchOptions({ type: 'ADD', data, relation, hasMultipleRelations, collection, sort });
dispatchOptions({ type: 'ADD', docs: data.docs, hasMultipleRelations, collection, sort });
setLastLoadedPage(data.page);
if (!data.nextPage) {
@@ -170,7 +181,7 @@ const Relationship: React.FC<Props> = (props) => {
} else if (response.status === 403) {
setLastFullyLoadedRelation(relations.indexOf(relation));
lastLoadedPageToUse = 1;
dispatchOptions({ type: 'ADD', data: { docs: [] } as PaginatedDocs<unknown>, relation, hasMultipleRelations, collection, sort, ids: relationMap[relation] });
dispatchOptions({ type: 'ADD', docs: [], hasMultipleRelations, collection, sort, ids: relationMap[relation] });
} else {
setErrorLoading('An error has occurred.');
}
@@ -251,11 +262,11 @@ const Relationship: React.FC<Props> = (props) => {
setSearch(searchArg);
}, [getResults]);
const handleInputChange = (searchArg: string, valueArg: unknown) => {
const handleInputChange = useCallback((searchArg: string, valueArg: unknown) => {
if (search !== searchArg) {
updateSearch(searchArg, valueArg);
}
};
}, [search, updateSearch]);
// ///////////////////////////
// Fetch value options when initialValue changes
@@ -288,9 +299,9 @@ const Relationship: React.FC<Props> = (props) => {
const collection = collections.find((coll) => coll.slug === relation);
if (response.ok) {
const data = await response.json();
dispatchOptions({ type: 'ADD', data, relation, hasMultipleRelations, collection, sort: true, ids });
dispatchOptions({ type: 'ADD', docs: data.docs, hasMultipleRelations, collection, sort: true, ids });
} else if (response.status === 403) {
dispatchOptions({ type: 'ADD', data: { docs: [] } as PaginatedDocs, relation, hasMultipleRelations, collection, sort: true, ids });
dispatchOptions({ type: 'ADD', docs: [], hasMultipleRelations, collection, sort: true, ids });
}
}
}
@@ -301,20 +312,21 @@ const Relationship: React.FC<Props> = (props) => {
}, [hasMany, hasMultipleRelations, relationTo, initialValue, hasLoadedValueOptions, errorLoading, collections, api, serverURL]);
useEffect(() => {
if (!filterOptions) {
return;
}
if (!filterOptions) return;
const [data, siblingData] = getFormData();
const newOptionFilters = getFilterOptionsQuery(filterOptions, {
id,
data: getData(),
data,
relationTo,
siblingData: getSiblingData(path),
siblingData,
user,
});
if (!equal(newOptionFilters, optionFilters)) {
setOptionFilters(newOptionFilters);
}
}, [relationTo, filterOptions, optionFilters, id, getData, getSiblingData, path, user]);
}, [relationTo, filterOptions, optionFilters, id, getFormData, path, user]);
useEffect(() => {
if (optionFilters || !filterOptions) {
@@ -380,50 +392,57 @@ const Relationship: React.FC<Props> = (props) => {
required={required}
/>
{!errorLoading && (
<ReactSelect
isDisabled={readOnly}
onInputChange={(newSearch) => handleInputChange(newSearch, value)}
onChange={!readOnly ? (selected) => {
if (hasMany) {
setValue(selected ? selected.map((option) => {
if (hasMultipleRelations) {
return {
relationTo: option.relationTo,
value: option.value,
};
}
<div className={`${baseClass}__wrap`}>
<ReactSelect
isDisabled={readOnly}
onInputChange={(newSearch) => handleInputChange(newSearch, value)}
onChange={!readOnly ? (selected) => {
if (hasMany) {
setValue(selected ? selected.map((option) => {
if (hasMultipleRelations) {
return {
relationTo: option.relationTo,
value: option.value,
};
}
return option.value;
}) : null);
} else if (hasMultipleRelations) {
setValue({
relationTo: selected.relationTo,
value: selected.value,
return option.value;
}) : null);
} else if (hasMultipleRelations) {
setValue({
relationTo: selected.relationTo,
value: selected.value,
});
} else {
setValue(selected.value);
}
} : undefined}
onMenuScrollToBottom={() => {
getResults({
lastFullyLoadedRelation,
lastLoadedPage: lastLoadedPage + 1,
search,
value: initialValue,
sort: false,
});
} else {
setValue(selected.value);
}
} : undefined}
onMenuScrollToBottom={() => {
getResults({
lastFullyLoadedRelation,
lastLoadedPage: lastLoadedPage + 1,
search,
value: initialValue,
sort: false,
});
}}
value={valueToRender}
showError={showError}
disabled={formProcessing}
options={options}
isMulti={hasMany}
isSortable={isSortable}
filterOption={enableWordBoundarySearch ? (item, searchFilter) => {
const r = wordBoundariesRegex(searchFilter || '');
return r.test(item.label);
} : undefined}
/>
}}
value={valueToRender}
showError={showError}
disabled={formProcessing}
options={options}
isMulti={hasMany}
isSortable={isSortable}
filterOption={enableWordBoundarySearch ? (item, searchFilter) => {
const r = wordBoundariesRegex(searchFilter || '');
return r.test(item.label);
} : undefined}
/>
{!readOnly && (
<AddNewRelation
{...{ path, hasMany, relationTo, value, setValue, dispatchOptions }}
/>
)}
</div>
)}
{errorLoading && (
<div className={`${baseClass}__error-loading`}>

View File

@@ -29,7 +29,8 @@ const optionsReducer = (state: Option[], action: Action): Option[] => {
}
case 'ADD': {
const { hasMultipleRelations, collection, relation, data, sort, ids = [] } = action;
const { hasMultipleRelations, collection, docs, sort, ids = [] } = action;
const relation = collection.slug;
const labelKey = collection.admin.useAsTitle || 'id';
@@ -38,20 +39,19 @@ const optionsReducer = (state: Option[], action: Action): Option[] => {
if (!hasMultipleRelations) {
const options = [
...state,
...data.docs.reduce((docs, doc) => {
...docs.reduce((docOptions, doc) => {
if (loadedIDs.indexOf(doc.id) === -1) {
loadedIDs.push(doc.id);
return [
...docs,
...docOptions,
{
label: doc[labelKey] || `Untitled - ID: ${doc.id}`,
value: doc.id,
},
];
}
return docs;
},
[]),
return docOptions;
}, []),
];
ids.forEach((id) => {
@@ -69,12 +69,12 @@ const optionsReducer = (state: Option[], action: Action): Option[] => {
const newOptions = [...state];
const optionsToAddTo = newOptions.find((optionGroup) => optionGroup.label === collection.labels.plural);
const newSubOptions = data.docs.reduce((docs, doc) => {
const newSubOptions = docs.reduce((docSubOptions, doc) => {
if (loadedIDs.indexOf(doc.id) === -1) {
loadedIDs.push(doc.id);
return [
...docs,
...docSubOptions,
{
label: doc[labelKey] || `Untitled - ID: ${doc.id}`,
relationTo: relation,
@@ -83,7 +83,7 @@ const optionsReducer = (state: Option[], action: Action): Option[] => {
];
}
return docs;
return docSubOptions;
}, []);
ids.forEach((id) => {

View File

@@ -1,5 +1,4 @@
import { SanitizedCollectionConfig } from '../../../../../collections/config/types';
import { PaginatedDocs } from '../../../../../mongoose/types';
import { RelationshipField } from '../../../../../fields/config/types';
export type Props = Omit<RelationshipField, 'type'> & {
@@ -19,8 +18,7 @@ type CLEAR = {
type ADD = {
type: 'ADD'
data: PaginatedDocs<any>
relation: string
docs: any[]
hasMultipleRelations: boolean
collection: SanitizedCollectionConfig
sort?: boolean

View File

@@ -94,22 +94,27 @@ const RichText: React.FC<Props> = (props) => {
}, [enabledElements, path, props]);
const renderLeaf = useCallback(({ attributes, children, leaf }) => {
const matchedLeafName = Object.keys(enabledLeaves).find((leafName) => leaf[leafName]);
const matchedLeaves = Object.entries(enabledLeaves).filter(([leafName]) => leaf[leafName]);
if (enabledLeaves[matchedLeafName]?.Leaf) {
const { Leaf } = enabledLeaves[matchedLeafName];
if (matchedLeaves.length > 0) {
return matchedLeaves.reduce((result, [leafName], i) => {
if (enabledLeaves[leafName]?.Leaf) {
const Leaf = enabledLeaves[leafName]?.Leaf;
return (
<Leaf
key={i}
leaf={leaf}
path={path}
fieldProps={props}
editorRef={editorRef}
>
{result}
</Leaf>
);
}
return (
<Leaf
attributes={attributes}
leaf={leaf}
path={path}
fieldProps={props}
editorRef={editorRef}
>
{children}
</Leaf>
);
return result;
}, <span {...attributes}>{children}</span>);
}
return (
@@ -180,8 +185,8 @@ const RichText: React.FC<Props> = (props) => {
useEffect(() => {
function setClickableState(clickState: 'disabled' | 'enabled') {
const selectors = 'button, a, [role="button"]';
const toolbarButtons: (HTMLButtonElement | HTMLAnchorElement)[] = toolbarRef.current.querySelectorAll(selectors);
const editorButtons: (HTMLButtonElement | HTMLAnchorElement)[] = editorRef.current.querySelectorAll(selectors);
const toolbarButtons: (HTMLButtonElement | HTMLAnchorElement)[] = toolbarRef.current?.querySelectorAll(selectors);
const editorButtons: (HTMLButtonElement | HTMLAnchorElement)[] = editorRef.current?.querySelectorAll(selectors);
[...(toolbarButtons || []), ...(editorButtons || [])].forEach((child) => {
const isButton = child.tagName === 'BUTTON';

View File

@@ -1,7 +1,7 @@
import React, { Fragment, useState, useEffect } from 'react';
import { useConfig } from '../../../../../../../utilities/Config';
import { useAuth } from '../../../../../../../utilities/Auth';
import { useWatchForm } from '../../../../../../Form/context';
import { useFormFields } from '../../../../../../Form/context';
import Relationship from '../../../../../Relationship';
import Select from '../../../../../Select';
@@ -24,9 +24,7 @@ const RelationshipFields = () => {
const { permissions } = useAuth();
const [options, setOptions] = useState(() => createOptions(collections, permissions));
const { getData } = useWatchForm();
const { relationTo } = getData();
const relationTo = useFormFields<string>(([fields]) => fields.relationTo?.value as string);
useEffect(() => {
setOptions(createOptions(collections, permissions));

View File

@@ -42,7 +42,7 @@ const insertUpload = (editor, { value, relationTo }) => {
};
const UploadButton: React.FC<{ path: string }> = ({ path }) => {
const { toggleModal, modalState } = useModal();
const { toggleModal, isModalOpen } = useModal();
const editor = useSlate();
const { serverURL, routes: { api }, collections } = useConfig();
const [availableCollections] = useState(() => collections.filter(({ admin: { enableRichTextRelationship }, upload }) => (Boolean(upload) && enableRichTextRelationship)));
@@ -65,7 +65,7 @@ const UploadButton: React.FC<{ path: string }> = ({ path }) => {
const modalSlug = `${path}-add-upload`;
const moreThanOneAvailableCollection = availableCollections.length > 1;
const isOpen = modalState[modalSlug]?.isOpen;
const isOpen = isModalOpen(modalSlug);
// If modal is open, get active page of upload gallery
const apiURL = isOpen ? `${serverURL}${api}/${modalCollection.slug}` : null;

View File

@@ -3,12 +3,4 @@
.field-type.select {
position: relative;
margin-bottom: $baseline;
}
.select--read-only {
div.react-select {
div.rs__control {
background: var(--theme-elevation-100);
}
}
}
}

View File

@@ -35,7 +35,6 @@ const Text: React.FC<Props> = (props) => {
const field = useField<string>({
path,
validate: memoizedValidate,
enableDebouncedValue: true,
condition,
});

View File

@@ -42,7 +42,6 @@ const Textarea: React.FC<Props> = (props) => {
} = useField({
path,
validate: memoizedValidate,
enableDebouncedValue: true,
condition,
});

View File

@@ -11,6 +11,7 @@ import { FieldTypes } from '..';
import AddModal from './Add';
import SelectExistingModal from './SelectExisting';
import { SanitizedCollectionConfig } from '../../../../../collections/config/types';
import { useEditDepth, EditDepthContext } from '../../../utilities/EditDepth';
import './index.scss';
@@ -58,13 +59,15 @@ const UploadInput: React.FC<UploadInputProps> = (props) => {
filterOptions,
} = props;
const { toggleModal } = useModal();
const { toggleModal, modalState } = useModal();
const editDepth = useEditDepth();
const addModalSlug = `${path}-add`;
const selectExistingModalSlug = `${path}-select-existing`;
const addModalSlug = `${path}-add-depth-${editDepth}`;
const selectExistingModalSlug = `${path}-select-existing-depth-${editDepth}`;
const [file, setFile] = useState(undefined);
const [missingFile, setMissingFile] = useState(false);
const [modalToRender, setModalToRender] = useState<string>();
const classes = [
'field-type',
@@ -98,6 +101,12 @@ const UploadInput: React.FC<UploadInputProps> = (props) => {
serverURL,
]);
useEffect(() => {
if (!modalState[addModalSlug]?.isOpen && !modalState[selectExistingModalSlug]?.isOpen) {
setModalToRender(undefined);
}
}, [modalState, addModalSlug, selectExistingModalSlug]);
return (
<div
className={classes}
@@ -132,6 +141,7 @@ const UploadInput: React.FC<UploadInputProps> = (props) => {
buttonStyle="secondary"
onClick={() => {
toggleModal(addModalSlug);
setModalToRender(addModalSlug);
}}
>
Upload new
@@ -142,33 +152,40 @@ const UploadInput: React.FC<UploadInputProps> = (props) => {
buttonStyle="secondary"
onClick={() => {
toggleModal(selectExistingModalSlug);
setModalToRender(selectExistingModalSlug);
}}
>
Choose from existing
</Button>
</div>
)}
<AddModal
{...{
collection,
slug: addModalSlug,
fieldTypes,
setValue: (e) => {
setMissingFile(false);
onChange(e);
},
}}
/>
<SelectExistingModal
{...{
collection,
slug: selectExistingModalSlug,
setValue: onChange,
addModalSlug,
filterOptions,
path,
}}
/>
<EditDepthContext.Provider value={editDepth + 1}>
{modalToRender === addModalSlug && (
<AddModal
{...{
collection,
slug: addModalSlug,
fieldTypes,
setValue: (e) => {
setMissingFile(false);
onChange(e);
},
}}
/>
)}
{modalToRender === selectExistingModalSlug && (
<SelectExistingModal
{...{
collection,
slug: selectExistingModalSlug,
setValue: onChange,
addModalSlug,
filterOptions,
path,
}}
/>
)}
</EditDepthContext.Provider>
<FieldDescription
value={file}
description={description}

View File

@@ -15,7 +15,7 @@ import PerPage from '../../../../elements/PerPage';
import formatFields from '../../../../views/collections/List/formatFields';
import { getFilterOptionsQuery } from '../../getFilterOptionsQuery';
import { useDocumentInfo } from '../../../../utilities/DocumentInfo';
import { useWatchForm } from '../../../Form/context';
import { useForm } from '../../../Form/context';
import ViewDescription from '../../../../elements/ViewDescription';
import './index.scss';
@@ -43,8 +43,8 @@ const SelectExistingUploadModal: React.FC<Props> = (props) => {
const { serverURL, routes: { api } } = useConfig();
const { id } = useDocumentInfo();
const { user } = useAuth();
const { getData, getSiblingData } = useWatchForm();
const { toggleModal, modalState } = useModal();
const { getData, getSiblingData } = useForm();
const { toggleModal, isModalOpen } = useModal();
const [fields] = useState(() => formatFields(collection));
const [limit, setLimit] = useState(defaultLimit);
const [sort, setSort] = useState(null);
@@ -56,7 +56,7 @@ const SelectExistingUploadModal: React.FC<Props> = (props) => {
baseClass,
].filter(Boolean).join(' ');
const isOpen = modalState[modalSlug]?.isOpen;
const isOpen = isModalOpen(modalSlug);
const apiURL = isOpen ? `${serverURL}${api}/${collectionSlug}` : null;

View File

@@ -1,170 +1,126 @@
import {
useCallback, useEffect, useState,
} from 'react';
import { useCallback, useMemo } from 'react';
import { useAuth } from '../../utilities/Auth';
import { useFormProcessing, useFormSubmitted, useFormModified, useForm } from '../Form/context';
import useDebounce from '../../../hooks/useDebounce';
import { useFormProcessing, useFormSubmitted, useFormModified, useForm, useFormFields } from '../Form/context';
import { Options, FieldType } from './types';
import { useDocumentInfo } from '../../utilities/DocumentInfo';
import { useOperation } from '../../utilities/OperationProvider';
import useThrottledEffect from '../../../hooks/useThrottledEffect';
import { UPDATE } from '../Form/types';
const useField = <T extends unknown>(options: Options): FieldType<T> => {
const {
path,
validate,
enableDebouncedValue,
disableFormData = false,
condition,
} = options;
const formContext = useForm();
const submitted = useFormSubmitted();
const processing = useFormProcessing();
const modified = useFormModified();
const { user } = useAuth();
const { id } = useDocumentInfo();
const operation = useOperation();
const field = useFormFields(([fields]) => fields[path]);
const dispatchField = useFormFields(([_, dispatch]) => dispatch);
const {
dispatchFields,
getField,
getData,
getSiblingData,
setModified,
} = formContext || {};
// Get field by path
const field = getField(path);
const { getData, getSiblingData, setModified } = useForm();
const value = field?.value as T;
const initialValue = field?.initialValue as T;
const [internalInitialValue, setInternalInitialValue] = useState(() => field?.initialValue as T);
const [internalValue, setInternalValue] = useState(() => field?.value as T);
const [internallyValid, setInternallyValid] = useState<boolean>(undefined);
// Debounce internal values to update form state only every 60ms
const debouncedValue = useDebounce(internalValue, 120);
// Validation is defined by two ways -
// 1. by field state
// 2. maintained locally to reflect instant validation state changes
let valid = true;
if (field && typeof field.valid === 'boolean') {
valid = field.valid;
}
if (typeof internallyValid === 'boolean') {
valid = internallyValid;
}
const valid = typeof field?.valid === 'boolean' ? field.valid : true;
const showError = valid === false && submitted;
// Method to send update field values from field component(s)
// Should only be used internally
const sendField = useCallback(async (valueToSend) => {
const fieldToDispatch = {
// Method to return from `useField`, used to
// update field values from field component(s)
const setValue = useCallback((e, disableModifyingForm = false) => {
const val = (e && e.target) ? e.target.value : e;
if (!modified && !disableModifyingForm) {
if (typeof setModified === 'function') {
Promise.resolve(() => setModified(true))
}
}
dispatchField({
type: 'UPDATE',
path,
value: val,
disableFormData,
initialValue,
validate,
condition,
value: valueToSend,
valid: false,
errorMessage: undefined,
};
const validateOptions = {
id,
user,
data: getData(),
siblingData: getSiblingData(path),
operation,
};
const validationResult = typeof validate === 'function' ? await validate(valueToSend, validateOptions) : true;
if (typeof validationResult === 'string') {
fieldToDispatch.errorMessage = validationResult;
fieldToDispatch.valid = false;
setInternallyValid(false);
} else {
fieldToDispatch.valid = validationResult;
fieldToDispatch.errorMessage = undefined;
setInternallyValid(true);
}
if (typeof dispatchFields === 'function') {
dispatchFields(fieldToDispatch);
}
});
}, [
setModified,
modified,
path,
dispatchField,
disableFormData,
]);
// Store result from hook as ref
// to prevent unnecessary rerenders
const result = useMemo(() => ({
showError,
errorMessage: field?.errorMessage,
value,
formSubmitted: submitted,
formProcessing: processing,
setValue,
initialValue,
}), [field, processing, setValue, showError, submitted, value, initialValue]);
// Throttle the validate function
useThrottledEffect(() => {
const validateField = async () => {
const action: UPDATE = {
type: 'UPDATE',
path,
disableFormData,
validate,
condition,
value,
valid: false,
errorMessage: undefined,
};
const validateOptions = {
id,
user,
data: getData(),
siblingData: getSiblingData(path),
operation,
};
const validationResult = typeof validate === 'function' ? await validate(value, validateOptions) : true;
if (typeof validationResult === 'string') {
action.errorMessage = validationResult;
action.valid = false;
} else {
action.valid = validationResult;
action.errorMessage = undefined;
}
if (typeof dispatchField === 'function') {
dispatchField(action);
}
};
validateField();
}, 150, [
value,
condition,
disableFormData,
dispatchFields,
dispatchField,
getData,
getSiblingData,
id,
initialValue,
operation,
path,
user,
validate,
]);
// Method to return from `useField`, used to
// update internal field values from field component(s)
// as fast as they arrive. NOTE - this method is NOT debounced
const setValue = useCallback((e, disableModifyingForm = false) => {
const val = (e && e.target) ? e.target.value : e;
if (!modified && !disableModifyingForm) {
if (typeof setModified === 'function') {
setModified(true);
}
}
setInternalValue(val);
}, [
setModified,
modified,
]);
useEffect(() => {
if (internalInitialValue !== initialValue) {
setInternalValue(initialValue);
setInternalInitialValue(initialValue);
}
setInternallyValid(undefined);
}, [initialValue, internalInitialValue]);
// The only time that the FORM value should be updated
// is when the debounced value updates. So, when the debounced value updates,
// send it up to the form
const valueToSend = enableDebouncedValue ? debouncedValue : internalValue;
useEffect(() => {
if ((field?.value !== valueToSend && valueToSend !== undefined) || disableFormData !== field?.disableFormData) {
sendField(valueToSend);
}
}, [
path,
valueToSend,
sendField,
field,
disableFormData,
]);
return {
...options,
showError,
errorMessage: field?.errorMessage,
value: internalValue,
formSubmitted: submitted,
formProcessing: processing,
setValue,
initialValue,
};
return result;
};
export default useField;

View File

@@ -3,9 +3,7 @@ import { Condition, Validate } from '../../../../fields/config/types';
export type Options = {
path: string
validate?: Validate
enableDebouncedValue?: boolean
disableFormData?: boolean
ignoreWhileFlattening?: boolean
condition?: Condition
}

View File

@@ -1,6 +1,8 @@
import React, { useEffect } from 'react';
import { FieldBase } from '../../../../fields/config/types';
import { useWatchForm } from '../Form/context';
import { useAllFormFields } from '../Form/context';
import getSiblingData from '../Form/getSiblingData';
import reduceFieldsToValues from '../Form/reduceFieldsToValues';
const withCondition = <P extends Record<string, unknown>>(Field: React.ComponentType<P>): React.FC<P> => {
const CheckForCondition: React.FC<P> = (props) => {
@@ -30,13 +32,13 @@ const withCondition = <P extends Record<string, unknown>>(Field: React.Component
const path = typeof pathFromProps === 'string' ? pathFromProps : name;
const { getData, getSiblingData, getField, dispatchFields } = useWatchForm();
const [fields, dispatchFields] = useAllFormFields();
const data = getData();
const siblingData = getSiblingData(path);
const data = reduceFieldsToValues(fields, true);
const siblingData = getSiblingData(fields, path);
const hasCondition = Boolean(condition);
const currentlyPassesCondition = hasCondition ? condition(data, siblingData) : true;
const field = getField(path);
const field = fields[path];
const existingConditionPasses = field?.passesCondition;
useEffect(() => {

View File

@@ -3,7 +3,7 @@
.leave-without-saving {
@include blur-bg;
position: fixed;
z-index: var(--z-modal);
z-index: calc(var(--z-modal) + 1);
top: 0;
right: 0;
bottom: 0;
@@ -17,4 +17,4 @@
.btn {
margin-right: $baseline;
}
}
}

View File

@@ -15,7 +15,13 @@ const modalSlug = 'stay-logged-in';
const StayLoggedInModal: React.FC<Props> = (props) => {
const { refreshCookie } = props;
const history = useHistory();
const { routes: { admin } } = useConfig();
const config = useConfig();
const {
routes: { admin },
admin: {
logoutRoute
}
} = config;
const { toggleModal } = useModal();
return (
@@ -31,7 +37,7 @@ const StayLoggedInModal: React.FC<Props> = (props) => {
buttonStyle="secondary"
onClick={() => {
toggleModal(modalSlug);
history.push(`${admin}/logout`);
history.push(`${admin}${logoutRoute}`);
}}
>
Log out

View File

@@ -20,4 +20,4 @@
padding: 0 0 $baseline;
}
}
}
}

View File

@@ -25,6 +25,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
const {
admin: {
user: userSlug,
inactivityRoute: logoutInactivityRoute,
},
serverURL,
routes: {
@@ -57,7 +58,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
setUser(json.user);
} else {
setUser(null);
push(`${admin}/logout-inactivity`);
push(`${admin}${logoutInactivityRoute}`);
}
}, 1000);
}
@@ -69,11 +70,11 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
setTokenInMemory(token);
}, []);
const logOut = () => {
const logOut = useCallback(() => {
setUser(null);
setTokenInMemory(undefined);
requests.post(`${serverURL}${api}/${userSlug}/logout`);
};
}, [serverURL, api, userSlug]);
// On mount, get user and set
useEffect(() => {
@@ -145,7 +146,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
if (remainingTime > 0) {
forceLogOut = setTimeout(() => {
setUser(null);
push(`${admin}/logout-inactivity`);
push(`${admin}${logoutInactivityRoute}`);
closeAllModals();
}, Math.min(remainingTime * 1000, maxTimeoutTime));
}

View File

@@ -10,6 +10,8 @@ export type ContextType = {
collection?: SanitizedCollectionConfig
global?: SanitizedGlobalConfig
type: 'global' | 'collection'
/** Slug of the collection or global */
slug?: string
id?: string | number
preferencesKey?: string
versions?: PaginatedDocs<Version>

View File

@@ -0,0 +1,5 @@
import { useContext, createContext } from 'react';
export const EditDepthContext = createContext(0);
export const useEditDepth = (): number => useContext(EditDepthContext);

View File

@@ -80,9 +80,9 @@ const DefaultGlobalView: React.FC<Props> = (props) => {
{label}
</h1>
{description && (
<div className={`${baseClass}__sub-header`}>
<ViewDescription description={description} />
</div>
<div className={`${baseClass}__sub-header`}>
<ViewDescription description={description} />
</div>
)}
</header>
<RenderFields

View File

@@ -68,7 +68,6 @@
min-height: 100%;
}
&__collection-actions,
&__document-actions,
&__meta,
&__sidebar-fields {
@@ -88,7 +87,7 @@
}
}
&__document-actions--with-preview {
&__document-actions--has-2 {
display: flex;
>* {
@@ -106,8 +105,8 @@
.form-submit {
.btn {
width: 100%;
padding-left: base(2);
padding-right: base(2);
padding-left: base(.5);
padding-right: base(.5);
}
}
}
@@ -215,4 +214,4 @@
height: auto;
}
}
}
}

View File

@@ -18,12 +18,15 @@ const baseClass = 'login';
const Login: React.FC = () => {
const history = useHistory();
const { user, setToken } = useAuth();
const config = useConfig();
const {
admin: {
user: userSlug,
logoutRoute,
components: {
beforeLogin,
afterLogin,
logout
} = {},
},
serverURL,
@@ -32,7 +35,7 @@ const Login: React.FC = () => {
api,
},
collections,
} = useConfig();
} = config;
const collection = collections.find(({ slug }) => slug === userSlug);
@@ -56,7 +59,7 @@ const Login: React.FC = () => {
<p>
To log in with another user, you should
{' '}
<Link to={`${admin}/logout`}>log out</Link>
<Link to={`${admin}${logoutRoute}`}>log out</Link>
{' '}
first.
</p>

View File

@@ -17,8 +17,9 @@ import HiddenInput from '../../forms/field-types/HiddenInput';
const baseClass = 'reset-password';
const ResetPassword: React.FC = () => {
const { admin: { user: userSlug }, serverURL, routes: { admin, api } } = useConfig();
const { token } = useParams<{token?: string}>();
const config = useConfig();
const { admin: { user: userSlug, logoutRoute }, serverURL, routes: { admin, api } } = config;
const { token } = useParams<{ token?: string }>();
const history = useHistory();
const { user, setToken } = useAuth();
@@ -43,7 +44,7 @@ const ResetPassword: React.FC = () => {
<p>
To log in with another user, you should
{' '}
<Link to={`${admin}/logout`}>log out</Link>
<Link to={`${admin}${logoutRoute}`}>log out</Link>
{' '}
first.
</p>

View File

@@ -5,8 +5,13 @@ import Meta from '../../utilities/Meta';
import MinimalTemplate from '../../templates/Minimal';
const Unauthorized: React.FC = () => {
const { routes: { admin } } = useConfig();
const config = useConfig();
const {
routes: { admin },
admin: {
logoutRoute
},
} = config;
return (
<MinimalTemplate className="unauthorized">
<Meta
@@ -19,7 +24,7 @@ const Unauthorized: React.FC = () => {
<br />
<Button
el="link"
to={`${admin}/logout`}
to={`${admin}${logoutRoute}`}
>
Log out
</Button>

View File

@@ -22,7 +22,7 @@ const generateLabelFromValue = (
): string => {
let relation: string;
let relatedDoc: RelationshipValue;
let valueToReturn = '';
let valueToReturn = '' as any;
if (Array.isArray(field.relationTo)) {
if (typeof value === 'object') {
@@ -58,7 +58,7 @@ const generateLabelFromValue = (
return valueToReturn;
};
const Relationship: React.FC<Props & { field: RelationshipField}> = ({ field, version, comparison }) => {
const Relationship: React.FC<Props & { field: RelationshipField }> = ({ field, version, comparison }) => {
const { collections } = useConfig();
const locale = useLocale();

View File

@@ -4,7 +4,7 @@ import useField from '../../../../forms/useField';
import Label from '../../../../forms/Label';
import CopyToClipboard from '../../../../elements/CopyToClipboard';
import { text } from '../../../../../../fields/validations';
import { useWatchForm } from '../../../../forms/Form/context';
import { useFormFields } from '../../../../forms/Form/context';
import GenerateConfirmation from '../../../../elements/GenerateConfirmation';
@@ -16,9 +16,7 @@ const APIKey: React.FC = () => {
const [initialAPIKey, setInitialAPIKey] = useState(null);
const [highlightedField, setHighlightedField] = useState(false);
const { getField } = useWatchForm();
const apiKey = getField(path);
const apiKey = useFormFields(([fields]) => fields[path]);
const apiKeyValue = apiKey?.value;

View File

@@ -6,7 +6,7 @@ import Password from '../../../../forms/field-types/Password';
import Checkbox from '../../../../forms/field-types/Checkbox';
import Button from '../../../../elements/Button';
import ConfirmPassword from '../../../../forms/field-types/ConfirmPassword';
import { useWatchForm, useFormModified } from '../../../../forms/Form/context';
import { useFormModified, useFormFields } from '../../../../forms/Form/context';
import { Props } from './types';
import APIKey from './APIKey';
@@ -18,11 +18,10 @@ const baseClass = 'auth-fields';
const Auth: React.FC<Props> = (props) => {
const { useAPIKey, requirePassword, verify, collection: { slug }, collection, email, operation } = props;
const [changingPassword, setChangingPassword] = useState(requirePassword);
const { getField, dispatchFields } = useWatchForm();
const enableAPIKey = useFormFields(([fields]) => fields.enableAPIKey);
const dispatchFields = useFormFields((reducer) => reducer[1]);
const modified = useFormModified();
const enableAPIKey = getField('enableAPIKey');
const {
serverURL,
routes: {

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { Link, useRouteMatch } from 'react-router-dom';
import { Link } from 'react-router-dom';
import format from 'date-fns/format';
import { useConfig } from '../../../utilities/Config';
import Eyebrow from '../../../elements/Eyebrow';
@@ -20,7 +20,6 @@ import VersionsCount from '../../../elements/VersionsCount';
import Upload from './Upload';
import { Props } from './types';
import Autosave from '../../../elements/Autosave';
import Status from '../../../elements/Status';
import Publish from '../../../elements/Publish';
import SaveDraft from '../../../elements/SaveDraft';
@@ -33,7 +32,6 @@ import './index.scss';
const baseClass = 'collection-edit';
const DefaultEditView: React.FC<Props> = (props) => {
const { params: { id } = {} } = useRouteMatch<Record<string, string>>();
const { admin: { dateFormat }, routes: { admin } } = useConfig();
const { publishedDoc } = useDocumentInfo();
@@ -48,6 +46,11 @@ const DefaultEditView: React.FC<Props> = (props) => {
apiURL,
action,
hasSavePermission,
disableEyebrow,
disableActions,
disableLeaveWithoutSaving,
customHeader,
id,
} = props;
const {
@@ -93,31 +96,36 @@ const DefaultEditView: React.FC<Props> = (props) => {
description={`${isEditing ? 'Editing' : 'Creating'} - ${collection.labels.singular}`}
keywords={`${collection.labels.singular}, Payload, CMS`}
/>
<Eyebrow />
{!(collection.versions?.drafts && collection.versions?.drafts?.autosave) && (
<LeaveWithoutSaving />
{!disableEyebrow && (
<Eyebrow />
)}
{(!(collection.versions?.drafts && collection.versions?.drafts?.autosave) && !disableLeaveWithoutSaving) && (
<LeaveWithoutSaving />
)}
<Gutter className={`${baseClass}__edit`}>
<header className={`${baseClass}__header`}>
<h1>
<RenderTitle {...{ data, useAsTitle, fallback: '[Untitled]' }} />
</h1>
{customHeader && customHeader}
{!customHeader && (
<h1>
<RenderTitle {...{ data, useAsTitle, fallback: '[Untitled]' }} />
</h1>
)}
</header>
{auth && (
<Auth
useAPIKey={auth.useAPIKey}
requirePassword={!isEditing}
verify={auth.verify}
collection={collection}
email={data?.email}
operation={operation}
/>
<Auth
useAPIKey={auth.useAPIKey}
requirePassword={!isEditing}
verify={auth.verify}
collection={collection}
email={data?.email}
operation={operation}
/>
)}
{upload && (
<Upload
data={data}
collection={collection}
/>
<Upload
data={data}
collection={collection}
/>
)}
<RenderFields
readOnly={!hasSavePermission}
@@ -131,80 +139,82 @@ const DefaultEditView: React.FC<Props> = (props) => {
<div className={`${baseClass}__sidebar-wrap`}>
<div className={`${baseClass}__sidebar`}>
<div className={`${baseClass}__sidebar-sticky-wrap`}>
<ul className={`${baseClass}__collection-actions`}>
{(permissions?.create?.permission) && (
<React.Fragment>
<li>
<Link
id="action-create"
to={`${admin}/collections/${slug}/create`}
>
Create New
</Link>
</li>
{!disableDuplicate && isEditing && (
<li>
<DuplicateDocument
collection={collection}
id={id}
slug={slug}
/>
</li>
)}
</React.Fragment>
)}
{permissions?.delete?.permission && (
<li>
<DeleteDocument
collection={collection}
id={id}
buttonId="action-delete"
/>
</li>
)}
</ul>
<div className={`${baseClass}__document-actions${((collection.versions?.drafts && !collection.versions?.drafts?.autosave) || (isEditing && preview)) ? ` ${baseClass}__document-actions--has-2` : ''}`}>
{(preview && (!collection.versions?.drafts || collection.versions?.drafts?.autosave)) && (
<PreviewButton
generatePreviewURL={preview}
data={data}
/>
)}
{hasSavePermission && (
<React.Fragment>
{collection.versions?.drafts && (
{!disableActions && (
<ul className={`${baseClass}__collection-actions`}>
{(permissions?.create?.permission) && (
<React.Fragment>
{!collection.versions.drafts.autosave && (
<SaveDraft />
<li>
<Link
id="action-create"
to={`${admin}/collections/${slug}/create`}
>
Create New
</Link>
</li>
{!disableDuplicate && isEditing && (
<li>
<DuplicateDocument
collection={collection}
id={id}
slug={slug}
/>
</li>
)}
<Publish />
</React.Fragment>
)}
{!collection.versions?.drafts && (
<FormSubmit buttonId="action-save">Save</FormSubmit>
{permissions?.delete?.permission && (
<li>
<DeleteDocument
collection={collection}
id={id}
buttonId="action-delete"
/>
</li>
)}
</React.Fragment>
</ul>
)}
<div className={`${baseClass}__document-actions${((collection.versions?.drafts && !collection.versions?.drafts?.autosave) || (isEditing && preview)) ? ` ${baseClass}__document-actions--has-2` : ''}`}>
{(preview && (!collection.versions?.drafts || collection.versions?.drafts?.autosave)) && (
<PreviewButton
generatePreviewURL={preview}
data={data}
/>
)}
{hasSavePermission && (
<React.Fragment>
{collection.versions?.drafts && (
<React.Fragment>
{!collection.versions.drafts.autosave && (
<SaveDraft />
)}
<Publish />
</React.Fragment>
)}
{!collection.versions?.drafts && (
<FormSubmit buttonId="action-save">Save</FormSubmit>
)}
</React.Fragment>
)}
</div>
<div className={`${baseClass}__sidebar-fields`}>
{(isEditing && preview && (collection.versions?.drafts && !collection.versions?.drafts?.autosave)) && (
<PreviewButton
generatePreviewURL={preview}
data={data}
/>
<PreviewButton
generatePreviewURL={preview}
data={data}
/>
)}
{collection.versions?.drafts && (
<React.Fragment>
<Status />
{(collection.versions?.drafts.autosave && hasSavePermission) && (
<Autosave
publishedDocUpdatedAt={publishedDoc?.updatedAt || data?.createdAt}
collection={collection}
id={id}
/>
)}
</React.Fragment>
)}
<React.Fragment>
<Status />
{(collection.versions?.drafts.autosave && hasSavePermission) && (
<Autosave
publishedDocUpdatedAt={publishedDoc?.updatedAt || data?.createdAt}
collection={collection}
id={id}
/>
)}
</React.Fragment>
)}
<RenderFields
readOnly={!hasSavePermission}
permissions={permissions.fields}
@@ -213,51 +223,53 @@ const DefaultEditView: React.FC<Props> = (props) => {
fieldSchema={fields}
/>
</div>
{isEditing && (
<ul className={`${baseClass}__meta`}>
{!hideAPIURL && (
<li className={`${baseClass}__api-url`}>
<span className={`${baseClass}__label`}>
API URL
{' '}
<CopyToClipboard value={apiURL} />
</span>
<a
href={apiURL}
target="_blank"
rel="noopener noreferrer"
>
{apiURL}
</a>
</li>
)}
{versions && (
<li>
<div className={`${baseClass}__label`}>Versions</div>
<VersionsCount
collection={collection}
id={id}
/>
</li>
)}
{timestamps && (
<React.Fragment>
{data.updatedAt && (
<li>
<div className={`${baseClass}__label`}>Last Modified</div>
<div>{format(new Date(data.updatedAt), dateFormat)}</div>
{
isEditing && (
<ul className={`${baseClass}__meta`}>
{!hideAPIURL && (
<li className={`${baseClass}__api-url`}>
<span className={`${baseClass}__label`}>
API URL
{' '}
<CopyToClipboard value={apiURL} />
</span>
<a
href={apiURL}
target="_blank"
rel="noopener noreferrer"
>
{apiURL}
</a>
</li>
)}
{(publishedDoc?.createdAt || data?.createdAt) && (
{versions && (
<li>
<div className={`${baseClass}__label`}>Created</div>
<div>{format(new Date(publishedDoc?.createdAt || data?.createdAt), dateFormat)}</div>
<div className={`${baseClass}__label`}>Versions</div>
<VersionsCount
collection={collection}
id={id}
/>
</li>
)}
</React.Fragment>
)}
</ul>
)}
{timestamps && (
<React.Fragment>
{data.updatedAt && (
<li>
<div className={`${baseClass}__label`}>Last Modified</div>
<div>{format(new Date(data.updatedAt), dateFormat)}</div>
</li>
)}
{(publishedDoc?.createdAt || data?.createdAt) && (
<li>
<div className={`${baseClass}__label`}>Created</div>
<div>{format(new Date(publishedDoc?.createdAt || data?.createdAt), dateFormat)}</div>
</li>
)}
</React.Fragment>
)}
</ul>
)
}
</div>
</div>
</div>

View File

@@ -15,6 +15,7 @@ import { StepNavItem } from '../../../elements/StepNav/types';
import { useDocumentInfo } from '../../../utilities/DocumentInfo';
import { Fields } from '../../../forms/Form/types';
import { usePreferences } from '../../../utilities/Preferences';
import { EditDepthContext } from '../../../utilities/EditDepth';
const EditView: React.FC<IndexProps> = (props) => {
const { collection: incomingCollection, isEditing } = props;
@@ -130,22 +131,25 @@ const EditView: React.FC<IndexProps> = (props) => {
const hasSavePermission = (isEditing && collectionPermissions?.update?.permission) || (!isEditing && collectionPermissions?.create?.permission);
return (
<RenderCustomComponent
DefaultComponent={DefaultEdit}
CustomComponent={CustomEdit}
componentProps={{
isLoading: !initialState,
data: dataToRender,
collection,
permissions: collectionPermissions,
isEditing,
onSave,
initialState,
hasSavePermission,
apiURL,
action,
}}
/>
<EditDepthContext.Provider value={1}>
<RenderCustomComponent
DefaultComponent={DefaultEdit}
CustomComponent={CustomEdit}
componentProps={{
id,
isLoading: !initialState,
data: dataToRender,
collection,
permissions: collectionPermissions,
isEditing,
onSave,
initialState,
hasSavePermission,
apiURL,
action,
}}
/>
</EditDepthContext.Provider>
);
};
export default EditView;

View File

@@ -1,3 +1,4 @@
import React from 'react';
import { SanitizedCollectionConfig } from '../../../../../collections/config/types';
import { CollectionPermission } from '../../../../../auth/types';
import { Document } from '../../../../../types';
@@ -11,6 +12,7 @@ export type IndexProps = {
export type Props = IndexProps & {
data: Document
onSave?: () => void
id?: string
permissions: CollectionPermission
isLoading: boolean
initialState?: Fields
@@ -18,4 +20,8 @@ export type Props = IndexProps & {
action: string
hasSavePermission: boolean
autosaveEnabled: boolean
disableEyebrow?: boolean
disableActions?: boolean
disableLeaveWithoutSaving?: boolean
customHeader?: React.ReactNode
}

View File

@@ -1,8 +1,7 @@
import { useWatchForm } from '../components/forms/Form/context';
import { useFormFields } from '../components/forms/Form/context';
const useTitle = (useAsTitle: string): string => {
const { getField } = useWatchForm();
const titleField = getField(useAsTitle);
const titleField = useFormFields(([fields]) => fields[useAsTitle]);
return titleField?.value as string;
};

View File

@@ -34,7 +34,7 @@ const Index = () => (
<Router>
<ModalProvider
classPrefix="payload"
zIndex={50}
zIndex="var(--z-modal)"
transTime={0}
>
<AuthProvider>

View File

@@ -9,10 +9,13 @@
--breakpoint-m-width : #{$breakpoint-m-width};
--breakpoint-l-width : #{$breakpoint-l-width};
--scrollbar-width: 17px;
--nav-width: #{base(9)};
--theme-bg: var(--theme-elevation-0);
--theme-input-bg: var(--theme-elevation-0);
--theme-text: var(--theme-elevation-800);
--theme-baseline: #{$baseline-px};
--theme-baseline-body-size: #{$baseline-body-size};
--font-body: 'Suisse Intl', system-ui;
--font-mono: monospace;
--font-serif: 'Merriweather', serif;
@@ -26,10 +29,12 @@
@include large-break {
--gutter-h: #{base(3)};
--nav-width: #{base(8)};
}
@include mid-break {
--gutter-h: #{base(2)};
--nav-width: 100%;
}
@include small-break {
@@ -172,4 +177,4 @@ dialog {
z-index: var(--z-modal);
}
@import '~payload-user-css';
@import '~payload-user-css';

View File

@@ -20,12 +20,13 @@ function accessResolver(payload: Payload) {
req: context.req,
};
let accessResults = await access(options);
const accessResults = await access(options);
accessResults = formatConfigNames(accessResults, payload.config.collections);
accessResults = formatConfigNames(accessResults, payload.config.globals);
return accessResults;
return {
...accessResults,
...formatConfigNames(accessResults.collections, payload.config.collections),
...formatConfigNames(accessResults.globals, payload.config.globals)
};
}
return resolver;

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