Compare commits

..

3 Commits

Author SHA1 Message Date
PatrikKozak
8308f9eb2e fix: updates implementation to allow custom mongo url during test 2023-05-31 13:25:02 -04:00
swenzel
2ae5bc6ca2 add typecheck for mongourl 2023-05-13 21:53:37 +02:00
swenzel
70e71d7a18 allow custom mongourl during test 2023-05-13 21:48:20 +02:00
135 changed files with 2697 additions and 23806 deletions

View File

@@ -2,7 +2,7 @@ name: build
on:
pull_request:
types: [opened, reopened, synchronize]
types: [opened, reopened, edited, synchronize]
push:
branches: ["master"]
@@ -45,7 +45,7 @@ jobs:
run: yarn dev:generate-types fields
- name: Generate GraphQL schema file
run: yarn dev:generate-graphql-schema graphql-schema-gen
run: yarn dev:generate-graphql-schema
- name: Install Playwright Browsers
run: npx playwright install --with-deps

2
.gitignore vendored
View File

@@ -164,7 +164,7 @@ GitHub.sublime-settings
# CMake
cmake-build-debug/
# MongoDB Explorer plugin:
# Mongo Explorer plugin:
.idea/**/mongoSettings.xml
## File-based project format:

View File

@@ -7,7 +7,7 @@
"tag": false
},
"github": {
"release": true
"release": false
},
"npm": {
"skipChecks": true,

View File

@@ -1,16 +1,5 @@
## [1.8.3](https://github.com/payloadcms/payload/compare/v1.8.3-canary.2...v1.8.3) (2023-05-24)
### Bug Fixes
* [#2662](https://github.com/payloadcms/payload/issues/2662), draft=true querying by id ([3b78ab0](https://github.com/payloadcms/payload/commit/3b78ab04c7a68e39afa9936ac692169ed2c8fb74))
* [#2685](https://github.com/payloadcms/payload/issues/2685), graphql querying relationships with custom id ([9bb5470](https://github.com/payloadcms/payload/commit/9bb54703423b3f0fdb242a5e63f322d346323b06))
* adds credentials to doc access request ([#2705](https://github.com/payloadcms/payload/issues/2705)) ([c716954](https://github.com/payloadcms/payload/commit/c716954e89b0aef976cbcbef9ece981ec9bab233))
* prevents add new relationship modal from adding duplicative values to the parent doc [#2688](https://github.com/payloadcms/payload/issues/2688) ([a2a8ac9](https://github.com/payloadcms/payload/commit/a2a8ac9549bd67e6ab578772689684fd2bc64872))
* unable to clear relationships or open relationship drawer on mobile [#2691](https://github.com/payloadcms/payload/issues/2691) [#2692](https://github.com/payloadcms/payload/issues/2692) ([782f8ca](https://github.com/payloadcms/payload/commit/782f8ca047178cadb4214702854a0e0cb2d9eaab))
## [1.8.2](https://github.com/payloadcms/payload/compare/v1.8.1...v1.8.2) (2023-05-10)
@@ -600,7 +589,7 @@ If not already defined, add the following to your `compilerOptions`:
#### ✋ Versions may need to be migrated
This release includes a substantial simplification / optimization of how Versions work within Payload. They are now significantly more performant and easier to understand behind-the-scenes. We've removed ~600 lines of code and have ensured that Payload can be compatible with all flavors of MongoDB - including versions earlier than 4.0, Azure Cosmos MongoDB, AWS' DocumentDB and more.
This release includes a substantial simplification / optimization of how Versions work within Payload. They are now significantly more performant and easier to understand behind-the-scenes. We've removed ~600 lines of code and have ensured that Payload can be compatible with all flavors of Mongo - including versions earlier than 4.0, Azure Cosmos MongoDB, AWS' DocumentDB and more.
But, some of your draft-enabled documents may need to be migrated.
@@ -3195,4 +3184,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

@@ -49,6 +49,12 @@ The directory split up in this way specifically to reduce friction when creating
The following command will start Payload with your config: `yarn dev my-test-dir`. This command will start up Payload using your config and refresh a test database on every restart.
If you wish to use to your own Mongo database for the `test` directory instead of using the in memory database, all you need to do is add the following env vars to the `test/dev.ts` file:
- `process.env.NODE_ENV`
- `process.env.PAYLOAD_TEST_MONGO_URL`
- Simply set `process.env.NODE_ENV` to `test` and set `process.env.PAYLOAD_TEST_MONGO_URL` to your mongo url e.g. `mongodb://127.0.0.1/your-test-db`.
NOTE: It is recommended to add the test credentials (located in `test/credentials.ts`) to your autofill for `localhost:3000/admin` as this will be required on every nodemon restart. The default credentials are `dev@payloadcms.com` as E-Mail and `test` as password.
## Pull Requests

View File

@@ -38,7 +38,7 @@ const defaultPayloadAccess = ({ req: { user } }) => {
<Banner type="success">
<strong>Note:</strong><br/>
In the Local API, all Access Control functions are skipped by default, allowing your server to do whatever it needs. But, you can opt back in by setting the option <strong>overrideAccess</strong> to <strong>false</strong>.
In the Local API, all Access Control functions are skipped by default, allowing your server to do whatever it needs. But, you can opt back in by setting the option <strong>overrideAccess</strong> to <strong>true</strong>.
</Banner>
### Access Control Types

View File

@@ -67,7 +67,7 @@ You can customize the way that the Admin panel behaves on a collection-by-collec
| Option | Description |
|------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `group` | Text used as a label for grouping collection and global links together in the navigation. |
| `group` | Text used as a label for grouping collection links together in the navigation. |
| `hidden` | Set to true or a function, called with the current user, returning true to exclude this collection from navigation and admin routing. |
| `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. |

View File

@@ -67,7 +67,6 @@ You can customize the way that the Admin panel behaves on a Global-by-Global bas
| Option | Description |
|--------------|-----------------------------------------------------------------------------------------------------------------------------------|
| `group` | Text used as a label for grouping collection and global links together in the navigation. |
| `hidden` | Set to true or a function, called with the current user, returning true to exclude this global from navigation and admin routing. |
| `components` | Swap in your own React components to be used within this Global. [More](/docs/admin/components#globals) |
| `preview` | Function to generate a preview URL within the Admin panel for this global that can point to your app. [More](#preview). |

View File

@@ -143,7 +143,7 @@ payload.init({
[06:37:21] INFO (payload): Starting Payload...
[06:37:22] INFO (payload): Payload Demo Initialized
[06:37:22] INFO (payload): listening on 3000...
[06:37:22] INFO (payload): Connected to MongoDB server successfully!
[06:37:22] INFO (payload): Connected to Mongo server successfully!
[06:37:23] INFO (payload): E-mail configured with mock configuration
[06:37:23] INFO (payload): Log into mock email provider at https://ethereal.email
[06:37:23] INFO (payload): Mock email account username: hhav5jw7doo4euev@ethereal.email

View File

@@ -146,7 +146,7 @@ The shape of the data to save for a document with the field configured this way
```json
{
// MongoDB ObjectID of the related user
// Mongo ObjectID of the related user
"owner": "6031ac9e1289176380734024"
}
```

View File

@@ -11,8 +11,8 @@ keywords: documentation, getting started, guide, Content Management System, cms,
Payload requires the following software:
- Yarn or NPM
- Node.js version 14+
- A MongoDB Database
- NodeJS version 10+
- A Mongo Database
<Banner type="warning">
Before proceeding any further, please ensure that you have the above
@@ -77,13 +77,12 @@ To initialize Payload, update your `server.ts` file to reflect the following cod
import express from "express";
import payload from "payload";
require("dotenv").config();
const app = express();
const start = async () => {
await payload.init({
secret: process.env.PAYLOAD_SECRET,
mongoURL: process.env.MONGODB_URI,
secret: "SECRET_KEY",
mongoURL: "mongodb://localhost/payload",
express: app,
});
@@ -97,13 +96,6 @@ const start = async () => {
start();
```
A quick reminder: in this configuration, we're making use of two environmental variables, `process.env.PAYLOAD_SECRET` and `process.env.MONGODB_URI`. Often, it's smart to store these values in an `.env` file at the root of your directory and set different values for each of your environments (local, stage, prod, etc). The `dotenv` package is very handy and works well alongside of Payload. A typical `.env` file will look like this:
```
MONGODB_URI=mongodb://127.0.0.1/your-payload-app
PAYLOAD_SECRET=your-payload-secret
```
Here is a list of all properties available to pass through `payload.init`:
##### `express`
@@ -112,21 +104,21 @@ Here is a list of all properties available to pass through `payload.init`:
##### `secret`
**Required**. This is a secure string that will be used to authenticate with Payload. It can be random but should be at least 14 characters and be very difficult to guess.
**Required**. This is a secure string that will be used to authenticate with Payload. It can be random but should be at least 14 characters and be very difficult to guess. Often, it's smart to store this value in an `env` and set different values for each of your environments (local, stage, prod, etc). The `dotenv` package is very handy and works well alongside of Payload.
Payload uses this secret key to generate secure user tokens (JWT). Behind the scenes, we do not use your secret key to encrypt directly - instead, we first take the secret key and create an encrypted string using the SHA-256 hash function. Then, we reduce the encrypted string to its first 32 characters. This final value is what Payload uses for encryption.
##### `mongoURL`
**Required**. This is a fully qualified MongoDB connection string that points to your MongoDB database. If you don't have MongoDB installed locally, you can [follow these steps for Mac OSX](https://docs.mongodb.com/manual/tutorial/install-mongodb-on-os-x/) and [these steps](https://docs.mongodb.com/manual/tutorial/install-mongodb-on-windows/) for Windows 10. If you want to use a local database and you know you have MongoDB installed locally, a typical connection string will look like this:
**Required**. This is a fully qualified MongoDB connection string that points to your Mongo database. If you don't have Mongo installed locally, you can [follow these steps for Mac OSX](https://docs.mongodb.com/manual/tutorial/install-mongodb-on-os-x/) and [these steps](https://docs.mongodb.com/manual/tutorial/install-mongodb-on-windows/) for Windows 10. If you want to use a local database and you know you have MongoDB installed locally, a typical connection string will look like this:
`mongodb://127.0.0.1/payload`
`mongodb://localhost/payload`
In contrast to running MongoDB locally, a popular option is to sign up for a free [MongoDB Atlas account](https://www.mongodb.com/cloud/atlas), which is a fully hosted and cloud-based installation of MongoDB that you don't need to ever worry about.
In contrast to running Mongo locally, a popular option is to sign up for a free [MongoDB Atlas account](https://www.mongodb.com/cloud/atlas), which is a fully hosted and cloud-based installation of Mongo that you don't need to ever worry about.
##### `mongoOptions`
Customize MongoDB connection options. Payload will connect to your MongoDB database using default options which you can override and extend to include all the [options](https://mongoosejs.com/docs/connections.html#options) available to mongoose.
Customize Mongo connection options. Payload will connect to your MongoDB database using default options which you can override and extend to include all the [options](https://mongoosejs.com/docs/connections.html#options) available to mongoose.
##### `email`

View File

@@ -19,7 +19,7 @@ keywords: documentation, getting started, guide, Content Management System, cms,
Out of the box, Payload gives you a lot of the things that you often need when developing a new website, web app, or native app:
- A MongoDB database to store your data
- A Mongo database to store your data
- A way to store, retrieve, and manipulate data of any shape via full REST and GraphQL APIs
- Authentication—complete with commonly required functionality like registration, email verification, login, & password reset
- Deep access control to your data, based on document or field-level functions

View File

@@ -80,9 +80,8 @@ Example
async (obj, args, context, info) => { }
```
**`obj`**
**`obj`** The previous object. Not very often used and usually discarded.
The previous object. Not very often used and usually discarded.
**`args`**

View File

@@ -8,11 +8,11 @@ keywords: hooks, globals, config, configuration, documentation, Content Manageme
Globals feature the ability to define the following hooks:
- [beforeValidate](#beforevalidate)
- [beforeChange](#beforechange)
- [afterChange](#afterchange)
- [beforeRead](#beforeread)
- [afterRead](#afterread)
- [beforeValidate](#beforeValidate)
- [beforeChange](#beforeChange)
- [afterChange](#afterChange)
- [beforeRead](#beforeRead)
- [afterRead](#afterRead)
## Config

View File

@@ -7,8 +7,7 @@ keywords: rest, api, documentation, Content Management System, cms, headless, ja
---
<Banner>
A fully functional REST API is automatically generated from your Collection
and Global configs.
A fully functional REST API is automatically generated from your Collection and Global configs.
</Banner>
All Payload API routes are mounted prefixed to your config's `routes.api` URL segment (default: `/api`).
@@ -27,518 +26,58 @@ Note: Collection slugs must be formatted in kebab-case
**All CRUD operations are exposed as follows:**
<RestExamples
data={[
{
operation: "Find",
method: "GET",
path: "/api/{collection-slug}",
description: "Find paginated documents",
example: {
slug: "getCollection",
req: true,
res: {
paginated: true,
data: {
id: "644a5c24cc1383022535fc7c",
title: "Home",
content: "REST API examples",
slug: "home",
createdAt: "2023-04-27T11:27:32.419Z",
updatedAt: "2023-04-27T11:27:32.419Z",
},
},
drawerContent: (
<>
<h6>Additional <code>find</code> query parameters</h6>
The <code>find</code> endpoint supports the following additional query parameters:
<ul>
<li>
<a href="/docs/queries/overview#sort">sort</a> - sort by field
</li>
<li>
<a href="/docs/queries/overview">where</a> - pass a where query to constrain returned
documents
</li>
<li>
<a href="/docs/queries/pagination#pagination-controls">limit</a> - limit the returned
documents to a certain number
</li>
<li>
<a href="/docs/queries/pagination#pagination-controls">page</a> - get a specific page of
documents
</li>
</ul>
</>
),
},
},
{
operation: "Find By ID",
method: "GET",
path: "/api/{collection-slug}/{id}",
description: "Find a specific document by ID",
example: {
slug: "findByID",
req: true,
res: {
id: "644a5c24cc1383022535fc7c",
title: "Home",
content: "REST API examples",
slug: "home",
createdAt: "2023-04-27T11:27:32.419Z",
updatedAt: "2023-04-27T11:27:32.419Z",
},
},
},
{
operation: "Create",
method: "POST",
path: "/api/{collection-slug}",
description: "Create a new document",
example: {
slug: "createDocument",
req: {
headers: true,
body: {
title: "New page",
content: "Here is some content",
},
},
res: {
message: "Page successfully created.",
doc: {
id: "644ba34c86359864f9535932",
title: "New page",
content: "Here is some content",
slug: "new-page",
createdAt: "2023-04-28T10:43:24.466Z",
updatedAt: "2023-04-28T10:43:24.466Z",
},
},
},
},
{
operation: "Update",
method: "PATCH",
path: "/api/{collection-slug}",
description: "Update all documents matching the where query",
example: {
slug: "updateDocument",
req: {
query: true,
headers: true,
body: {
title: "I have been updated!",
},
},
res: {
docs: [
{
id: "644ba34c86359864f9535932",
title: "I have been updated!",
content: "Here is some content",
slug: "new-page",
createdAt: "2023-04-28T10:43:24.466Z",
updatedAt: "2023-04-28T10:45:23.724Z",
},
],
errors: [],
},
},
},
{
operation: "Update By ID",
method: "PATCH",
path: "/api/{collection-slug}/{id}",
description: "Update a document by ID",
example: {
slug: "updateDocumentByID",
req: {
headers: true,
body: {
title: "I have been updated by ID!",
categories: "example-uuid",
tags: {
relationTo: "location",
value: "another-example-uuid",
},
},
},
res: {
message: "Updated successfully.",
doc: {
id: "644a5c24cc1383022535fc7c",
title: "I have been updated by ID!",
content: "REST API examples",
categories: {
id: "example-uuid",
name: "Test Category",
},
tags: [
{
relationTo: "location",
value: {
id: "another-example-uuid",
name: "Test Location",
},
},
],
slug: "home",
createdAt: "2023-04-27T11:27:32.419Z",
updatedAt: "2023-04-28T10:47:59.259Z",
},
},
},
},
{
operation: "Delete",
method: "DELETE",
path: "/api/{collection-slug}",
description: "Delete all documents matching the where query",
example: {
slug: "deleteDocuments",
req: {
query: true,
headers: true,
},
res: {
docs: [
{
id: "644ba4cf86359864f953594b",
title: "New page",
content: "Here is some content",
slug: "new-page",
createdAt: "2023-04-28T10:49:51.359Z",
updatedAt: "2023-04-28T10:49:51.359Z",
},
],
errors: [],
},
},
},
{
operation: "Delete by ID",
method: "DELETE",
path: "/api/{collection-slug}/{id}",
description: "Delete an existing document by ID",
example: {
slug: "deleteByID",
req: {
headers: true,
},
res: {
id: "644ba51786359864f9535954",
title: "New page",
content: "Here is some content",
slug: "new-page",
createdAt: "2023-04-28T10:51:03.028Z",
updatedAt: "2023-04-28T10:51:03.028Z",
},
},
},
| Method | Path | Description |
|----------|-------------------------------|--------------------------------------------------|
| `GET` | `/api/{collection-slug}` | Find paginated documents |
| `GET` | `/api/{collection-slug}/:id` | Find a specific document by ID |
| `POST` | `/api/{collection-slug}` | Create a new document |
| `PATCH` | `/api/{collection-slug}` | Update all documents matching the `where` query |
| `PATCH` | `/api/{collection-slug}/:id` | Update a document by ID |
| `DELETE` | `/api/{collection-slug}` | Delete all documents matching the `where` query |
| `DELETE` | `/api/{collection-slug}/:id` | Delete an existing document by ID |
]}
/>
##### Additional `find` query parameters
The `find` endpoint supports the following additional query parameters:
- [sort](/docs/queries/overview#sort) - sort by field
- [where](/docs/queries/overview) - pass a `where` query to constrain returned documents
- [limit](/docs/queries/pagination#pagination-controls) - limit the returned documents to a certain number
- [page](/docs/queries/pagination#pagination-controls) - get a specific page of documents
## Auth Operations
Auth enabled collections are also given the following endpoints:
<RestExamples
data={[
{
operation: "Login",
method: "POST",
path: "/api/{user-collection}/login",
description: "Logs in a user with email / password",
example: {
slug: "login",
req: {
headers: true,
body: {
email: "dev@payloadcms.com",
password: "password",
},
},
res: {
message: "Auth Passed",
user: {
id: "644b8453cd20c7857da5a9b0",
email: "dev@payloadcms.com",
_verified: true,
createdAt: "2023-04-28T08:31:15.788Z",
updatedAt: "2023-04-28T11:11:03.716Z",
},
token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9",
exp: 1682689147,
},
},
},
{
operation: "Logout",
method: "POST",
path: "/api/{user-collection}/logout",
description: "Logs out a user",
example: {
slug: "logout",
req: {
headers: true,
},
res: {
message: "You have been logged out successfully.",
},
},
},
{
operation: "Unlock",
method: "POST",
path: "/api/{user-collection}/unlock",
description: "Unlock a user account",
example: {
slug: "unlockCollection",
req: {
headers: true,
body: {
email: "dev@payloadcms.com",
},
},
res: {
message: "Success",
},
},
},
{
operation: "Refresh",
method: "POST",
path: "/api/{user-collection}/refresh-token",
description: "Refreshes a token that has not yet expired",
example: {
slug: "refreshToken",
req: {
headers: true,
},
res: {
message: "Token refresh successful",
refreshedToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9",
exp: 1682689362,
user: {
email: "dev@payloadcms.com",
id: "644b8453cd20c7857da5a9b0",
collection: "users",
},
},
},
},
{
operation: "Verify User",
method: "POST",
path: "/api/{user-collection}/verify/{token}",
description: "User verification",
example: {
slug: "verifyUser",
req: {
prop: "token: string, user-collection: string",
headers: true,
},
res: {
message: "Email verified successfully.",
},
},
},
{
operation: "Current User",
method: "GET",
path: "/api/{user-collection}/me",
description: "Returns the currently logged in user with token",
example: {
slug: "currentUser",
req: {
headers: true,
},
res: {
user: {
id: "644b8453cd20c7857da5a9b0",
email: "dev@payloadcms.com",
_verified: true,
createdAt: "2023-04-28T08:31:15.788Z",
updatedAt: "2023-04-28T11:45:23.926Z",
_strategy: "local-jwt",
},
collection: "users",
token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9",
exp: 1682689523,
},
},
},
{
operation: "Forgot Password",
method: "POST",
path: "/api/{user-collection}/forgot-password",
description: "Password reset workflow entry point",
example: {
slug: "forgotPassword",
req: {
headers: true,
body: {
email: "dev@payloadcms.com",
},
},
res: {
message: "Success",
},
},
},
{
operation: "Reset Password",
method: "POST",
path: "/api/{user-collection}/reset-password",
description: "Reset user password",
example: {
slug: "resetPassword",
req: {
headers: true,
body: {
token: "7eac3830ffcfc7f9f66c00315dabeb11575dba91",
password: "newPassword",
},
},
res: {
message: "Password reset successfully.",
token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9",
user: {
id: "644baa473ea9538765cc30fc",
email: "dev@payloadcms.com",
_verified: true,
createdAt: "2023-04-28T11:13:11.569Z",
updatedAt: "2023-04-28T11:49:23.860Z",
},
},
},
},
]}
/>
| Method | Path | Description |
| -------- | --------------------------- | ----------- |
| `POST` | `/api/{collection-slug}/verify/:token` | [Email verification](/docs/authentication/operations#verify-by-email), if enabled. |
| `POST` | `/api/{collection-slug}/unlock` | [Unlock a user's account](/docs/authentication/operations#unlock), if enabled. |
| `POST` | `/api/{collection-slug}/login` | [Logs in](/docs/authentication/operations#login) a user with email / password. |
| `POST` | `/api/{collection-slug}/logout` | [Logs out](/docs/authentication/operations#logout) a user. |
| `POST` | `/api/{collection-slug}/refresh-token` | [Refreshes a token](/docs/authentication/operations#refresh) that has not yet expired. |
| `GET` | `/api/{collection-slug}/me` | [Returns the currently logged in user with token](/docs/authentication/operations#me). |
| `POST` | `/api/{collection-slug}/forgot-password` | [Password reset workflow](/docs/authentication/operations#forgot-password) entry point. |
| `POST` | `/api/{collection-slug}/reset-password` | [To reset the user's password](/docs/authentication/operations#reset-password). |
## Globals
Globals cannot be created or deleted, so there are only two REST endpoints opened:
<RestExamples
data={[
{
operation: "Get Global",
method: "GET",
path: "/api/globals/{global-slug}",
description: "Get a global by slug",
example: {
slug: "getGlobal",
req: {
headers: true,
},
res: {
announcement: "Here is an announcement!",
globalType: "announcement",
createdAt: "2023-04-28T08:53:56.066Z",
updatedAt: "2023-04-28T08:53:56.066Z",
id: "644b89a496c64a833fe579c9",
},
},
},
{
operation: "Update Global",
method: "POST",
path: "/api/globals/{global-slug}",
description: "Update a global by slug",
example: {
slug: "updateGlobal",
req: {
headers: true,
body: {
announcement: "Paging Doctor Scrunt",
},
},
res: {
announcement: "Paging Doctor Scrunt",
globalType: "announcement",
createdAt: "2023-04-28T08:53:56.066Z",
updatedAt: "2023-04-28T08:53:56.066Z",
id: "644b89a496c64a833fe579c9",
},
},
},
]}
/>
| Method | Path | Description |
| -------- | --------------------------- | ----------------------- |
| `GET` | `/api/globals/{globalSlug}` | Get a global by slug |
| `POST` | `/api/globals/{globalSlug}` | Update a global by slug |
## Preferences
In addition to the dynamically generated endpoints above Payload also has REST endpoints to manage the admin user [preferences](/docs/admin/overview#preferences) for data specific to the authenticated user.
<RestExamples
data={[
{
operation: "Get Preference",
method: "GET",
path: "/api/_preferences/{key}",
description: "Get a preference by key",
example: {
slug: "getPreference",
req: {
headers: true,
},
res: {
_id: "644bb7a8307b3d363c6edf2c",
key: "region",
user: "644b8453cd20c7857da5a9b0",
userCollection: "users",
__v: 0,
createdAt: "2023-04-28T12:10:16.689Z",
updatedAt: "2023-04-28T12:10:16.689Z",
value: "Europe/London",
},
},
},
{
operation: "Create Preference",
method: "POST",
path: "/api/_preferences/{key}",
description: "Create or update a preference by key",
example: {
slug: "createPreference",
req: {
headers: true,
body: {
value: "Europe/London",
},
},
res: {
message: "Updated successfully.",
doc: {
user: "644b8453cd20c7857da5a9b0",
key: "region",
userCollection: "users",
value: "Europe/London",
},
},
},
},
{
operation: "Delete Preference",
method: "DELETE",
path: "/api/_preferences/{key}",
description: "Delete a preference by key",
example: {
slug: "deletePreference",
req: {
headers: true,
},
res: {
message: "deletedSuccessfully",
},
},
},
]}
/>
| Method | Path | Description |
| -------- | --------------------------- | ----------------------- |
| `GET` | `/api/_preferences/{key}` | Get a preference by key |
| `POST` | `/api/_preferences/{key}` | Create or update by key |
| `DELETE` | `/api/_preferences/{key}` | Delete a user preference by key |
## Custom Endpoints
@@ -546,48 +85,43 @@ Additional REST API endpoints can be added to your application by providing an a
Each endpoint object needs to have:
| Property | Description |
| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`path`** | A string for the endpoint route after the collection or globals slug |
| **`method`** | The lowercase HTTP verb to use: 'get', 'head', 'post', 'put', 'delete', 'connect' or 'options' |
| **`handler`** | A function or array of functions to be called with **req**, **res** and **next** arguments. [Express](https://expressjs.com/en/guide/routing.html#route-handlers) |
| **`root`** | When `true`, defines the endpoint on the root Express app, bypassing Payload handlers and the `routes.api` subpath. Note: this only applies to top-level endpoints of your Payload config, endpoints defined on `collections` or `globals` cannot be root. |
| **`custom`** | Extension point for adding custom data (e.g. for plugins) |
| Property | Description |
| ---------- | ----------------------------------------- |
| **`path`** | A string for the endpoint route after the collection or globals slug |
| **`method`** | The lowercase HTTP verb to use: 'get', 'head', 'post', 'put', 'delete', 'connect' or 'options' |
| **`handler`** | A function or array of functions to be called with **req**, **res** and **next** arguments. [Express](https://expressjs.com/en/guide/routing.html#route-handlers) |
| **`root`** | When `true`, defines the endpoint on the root Express app, bypassing Payload handlers and the `routes.api` subpath. Note: this only applies to top-level endpoints of your Payload config, endpoints defined on `collections` or `globals` cannot be root. |
| **`custom`** | Extension point for adding custom data (e.g. for plugins) |
Example:
```ts
import { CollectionConfig } from "payload/types";
import { CollectionConfig } from 'payload/types';
// a collection of 'orders' with an additional route for tracking details, reachable at /api/orders/:id/tracking
export const Orders: CollectionConfig = {
slug: "orders",
fields: [
/* ... */
],
slug: 'orders',
fields: [ /* ... */ ],
// highlight-start
endpoints: [
{
path: "/:id/tracking",
method: "get",
path: '/:id/tracking',
method: 'get',
handler: async (req, res, next) => {
const tracking = await getTrackingInfo(req.params.id);
if (tracking) {
res.status(200).send({ tracking });
} else {
res.status(404).send({ error: "not found" });
res.status(404).send({ error: 'not found' });
}
},
},
}
}
],
// highlight-end
};
}
```
<Banner>
<strong>Note:</strong>
<br />
**req** will have the **payload** object and can be used inside your endpoint
handlers for making calls like req.payload.find() that will make use of access
control and hooks.
<strong>Note:</strong><br/>
**req** will have the **payload** object and can be used inside your endpoint handlers for making calls like req.payload.find() that will make use of access control and hooks.
</Banner>

View File

@@ -1,4 +0,0 @@
MONGODB_URI=mongodb://localhost/payload-example-email
PAYLOAD_SECRET=
NODE_ENV=development
PAYLOAD_PUBLIC_SERVER_URL=http://localhost:8000

View File

@@ -1,7 +0,0 @@
module.exports = {
root: true,
extends: ['@payloadcms'],
rules: {
'@typescript-eslint/no-unused-vars': 'warn',
},
}

View File

@@ -1,5 +0,0 @@
build
dist
node_modules
package-lock.json
.env

View File

@@ -1,63 +0,0 @@
# Payload Email Example
This example demonstrates how to integrate email functionality into Payload.
## Quick Start
To spin up this example locally, follow these steps:
1. Clone this repo
2. `cd` into this directory and run `yarn` or `npm install`
3. `cp .env.example .env` to copy the example environment variables
4. `yarn dev` or `npm run dev` to start the server and seed the database
5. `open http://localhost:8000/admin` to access the admin panel
6. Create your first user
## How it works
Payload utilizes [NodeMailer](https://nodemailer.com/about/) for email functionality. Once you add your email configuration to `payload.init()`, you send email from anywhere in your application just by calling `payload.sendEmail({})`.
1. Navigate to `src/server.ts` - this is where your email config gets passed to Payload
2. Open `src/email/transport.ts` - here we are defining the email config. You can use an env variable to switch between the mock email transport and live email service.
Now we can start sending email!
3. Go to `src/collections/Newsletter.ts` - with an `afterChange` hook, we are sending an email when a new user signs up for the newsletter
Let's not forget our authentication emails...
4. Auth-enabled collections have built-in options to verify the user and reset the user password. Open `src/collections/Users.ts` and see how we customize these emails.
Speaking of customization...
5. Take a look at `src/email/generateEmailHTML` and how it compiles a custom template when sending email. You change this to any HTML template of your choosing.
That's all you need, now you can go ahead and test out this repo by creating a new `user` or `newsletter-signup` and see the email integration in action.
## Development
To spin up this example locally, follow the [Quick Start](#quick-start).
## Production
To run Payload in production, you need to build and serve the Admin panel. To do so, follow these steps:
1. First invoke the `payload build` script by running `yarn build` or `npm run build` in your project root. This creates a `./build` directory with a production-ready admin bundle.
1. Then run `yarn serve` or `npm run serve` to run Node in production and serve Payload from the `./build` directory.
### Deployment
The easiest way to deploy your project is to use [Payload Cloud](https://payloadcms.com/new/import), a one-click hosting solution to deploy production-ready instances of your Payload apps directly from your GitHub repo. You can also deploy your app manually, check out the [deployment documentation](https://payloadcms.com/docs/production/deployment) for full details.
## Resources
For more information on integrating email, check out these resources:
<!-- Update with live blog post URL when published -->
- [Blog Post - Email 101](https://payloadcms.com/blog)
- [Email Documentation](https://payloadcms.com/docs/email/overview#email-functionality)
## Questions
If you have any issues or questions, reach out to us on [Discord](https://discord.com/invite/r6sCXqVk3v) or start a [GitHub discussion](https://github.com/payloadcms/payload/discussions).

View File

@@ -1,4 +0,0 @@
{
"ext": "ts",
"exec": "ts-node src/server.ts"
}

View File

@@ -1,35 +0,0 @@
{
"name": "payload-example-email",
"description": "Payload Email integration example.",
"version": "1.0.0",
"main": "dist/server.js",
"license": "MIT",
"scripts": {
"dev": "cross-env PAYLOAD_SEED=true PAYLOAD_DROP_DATABASE=true PAYLOAD_CONFIG_PATH=src/payload.config.ts nodemon",
"build:payload": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts payload build",
"build:server": "tsc",
"build": "yarn copyfiles && yarn build:payload && yarn build:server",
"serve": "cross-env PAYLOAD_CONFIG_PATH=dist/payload.config.js NODE_ENV=production node dist/server.js",
"copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,ttf,woff,woff2,eot,svg,jpg,png}\" dist/",
"generate:types": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts payload generate:types",
"generate:graphQLSchema": "PAYLOAD_CONFIG_PATH=src/payload.config.ts payload generate:graphQLSchema",
"lint": "eslint src",
"lint:fix": "eslint --fix --ext .ts,.tsx src"
},
"dependencies": {
"dotenv": "^8.2.0",
"express": "^4.17.1",
"payload": "1.6.29",
"handlebars": "^4.7.7",
"inline-css": "^4.0.2"
},
"devDependencies": {
"@types/express": "^4.17.9",
"copyfiles": "^2.4.1",
"cross-env": "^7.0.3",
"eslint": "^8.19.0",
"nodemon": "^2.0.6",
"ts-node": "^9.1.1",
"typescript": "^4.8.4"
}
}

View File

@@ -1,39 +0,0 @@
import type { CollectionConfig } from 'payload/types'
import generateEmailHTML from '../email/generateEmailHTML'
const Newsletter: CollectionConfig = {
slug: 'newsletter-signups',
admin: {
defaultColumns: ['name', 'email'],
},
hooks: {
afterChange: [
async ({ doc, operation, req }) => {
if (operation === 'create') {
req.payload.sendEmail({
to: doc.email,
from: 'sender@example.com',
subject: 'Thanks for signing up!',
html: await generateEmailHTML({
headline: 'Welcome to the newsletter!',
content: `<p>${doc.name ? `Hi ${doc.name}!` : 'Hi!'} We'll be in touch soon...</p>`,
}),
})
}
},
],
},
fields: [
{
name: 'name',
type: 'text',
},
{
name: 'email',
type: 'text',
required: true,
}
],
}
export default Newsletter

View File

@@ -1,29 +0,0 @@
import type { CollectionConfig } from 'payload/types'
import generateForgotPasswordEmail from '../email/generateForgotPasswordEmail'
import generateVerificationEmail from '../email/generateVerificationEmail'
const Users: CollectionConfig = {
slug: 'users',
auth: {
verify: {
generateEmailSubject: () => 'Verify your email',
generateEmailHTML: generateVerificationEmail,
},
forgotPassword: {
generateEmailSubject: () => 'Reset your password',
generateEmailHTML: generateForgotPasswordEmail,
},
},
admin: {
useAsTitle: 'email',
},
fields: [
{
name: 'name',
type: 'text',
},
],
}
export default Users

View File

@@ -1,24 +0,0 @@
import fs from 'fs'
import Handlebars from 'handlebars'
import inlineCSS from 'inline-css'
import path from 'path'
const template = fs.readFileSync
? fs.readFileSync(path.join(__dirname, './template.html'), 'utf8')
: ''
// Compile the template
const getHTML = Handlebars.compile(template)
const generateEmailHTML = async (data): Promise<string> => {
const preInlinedCSS = getHTML(data)
const html = await inlineCSS(preInlinedCSS, {
url: ' ',
removeStyleTags: false,
})
return html
}
export default generateEmailHTML

View File

@@ -1,14 +0,0 @@
import generateEmailHTML from './generateEmailHTML'
const generateForgotPasswordEmail = async ({ token }): Promise<string> =>
generateEmailHTML({
headline: 'Locked out?',
content:
'<p>Let&apos;s get you back in.</p>',
cta: {
buttonLabel: 'Reset your password',
url: `${process.env.PAYLOAD_PUBLIC_SERVER_URL}/reset-password?token=${token}`,
},
})
export default generateForgotPasswordEmail

View File

@@ -1,16 +0,0 @@
import generateEmailHTML from './generateEmailHTML'
const generateVerificationEmail = async (args): Promise<string> => {
const { user, token } = args
return generateEmailHTML({
headline: 'Verify your account',
content: `<p>Hi${user.name ? ' ' + user.name : ''}! Validate your account by clicking the button below.</p>`,
cta: {
buttonLabel: 'Verify',
url: `${process.env.PAYLOAD_PUBLIC_SERVER_URL}/verify?token=${token}&email=${user.email}`,
},
})
}
export default generateVerificationEmail

View File

@@ -1,317 +0,0 @@
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style type="text/css">
body,
html {
margin: 0;
padding: 0;
}
body,
html,
.bg {
height: 100%;
}
body,
h1,
h2,
h3,
h4,
p,
em,
strong {
font-family: sans-serif;
}
body {
font-size: 15px;
color: #333333;
}
a {
color: #333333;
outline: 0;
text-decoration: underline;
}
a img {
border: 0;
outline: 0;
}
img {
max-width: 100%;
height: auto;
vertical-align: top;
}
h1,
h2,
h3,
h4,
h5 {
font-weight: 900;
line-height: 1.25;
}
h1 {
font-size: 40px;
color: #333333;
margin: 0 0 25px 0;
}
h2 {
color: #333333;
margin: 0 0 25px 0;
font-size: 30px;
line-height: 30px;
}
h3 {
font-size: 25px;
color: #333333;
margin: 0 0 25px 0;
}
h4 {
font-size: 20px;
color: #333333;
margin: 0 0 15px 0;
line-height: 30px;
}
h5 {
color: #333333;
font-size: 17px;
font-weight: 900;
margin: 0 0 15px;
}
table {
border-collapse: collapse;
}
p,
td {
font-size: 14px;
line-height: 25px;
color: #333333;
}
p {
margin: 0 0 25px;
}
ul {
padding-left: 15px;
margin-left: 15px;
font-size: 14px;
line-height: 25px;
margin-bottom: 25px;
}
li {
font-size: 14px;
line-height: 25px;
color: #333333;
}
table.hr td {
font-size: 0;
line-height: 2px;
}
.white {
color: white;
}
/********************************
MAIN
********************************/
.main {
background: white;
}
/********************************
MAX WIDTHS
********************************/
.max-width {
max-width: 800px;
width: 94%;
margin: 0 3%;
}
/********************************
REUSABLES
********************************/
.padding {
padding: 60px;
}
.center {
text-align: center;
}
.no-border {
border: 0;
outline: none;
text-decoration: none;
}
.no-margin {
margin: 0;
}
.spacer {
line-height: 45px;
height: 45px;
}
/********************************
PANELS
********************************/
.panel {
width: 100%;
}
@media screen and (max-width : 800px) {
h1 {
font-size: 24px !important;
margin: 0 0 20px 0 !important;
}
h2 {
font-size: 20px !important;
margin: 0 0 20px 0 !important;
}
h3 {
font-size: 20px !important;
margin: 0 0 20px 0 !important;
}
h4 {
font-size: 18px !important;
margin: 0 0 15px 0 !important;
}
h5 {
font-size: 15px !important;
margin: 0 0 10px !important;
}
.max-width {
width: 90% !important;
margin: 0 5% !important;
}
td.padding {
padding: 30px !important;
}
td.padding-vert {
padding-top: 20px !important;
padding-bottom: 20px !important;
}
td.padding-horiz {
padding-left: 20px !important;
padding-right: 20px !important;
}
.spacer {
line-height: 20px !important;
height: 20px !important;
}
}
</style>
</head>
<body>
<div style="background-color:#F3F3F3; height: 100%;">
<table height="100%" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#f3f3f3"
style="background-color: #f3f3f3;">
<tr>
<td valign="top" align="left">
<table cellpadding="0" cellspacing="0" border="0" width="100%">
<tbody>
<tr>
<td align="center" valign="top">
<table cellpadding="0" cellspacing="0" border="0" width="100%">
<tbody>
<tr>
<td align="center">
<table class="max-width" cellpadding="0" cellspacing="0" border="0" width="100%"
style="width: 100%;">
<tbody>
<tr>
<td class="spacer">&nbsp;</td>
</tr>
<tr>
<td class="padding main">
<table cellpadding="0" cellspacing="0" border="0" width="100%">
<tbody>
<tr>
<td>
<!-- LOGO -->
<a href="https://payloadcms.com/" target="_blank">
<img src="https://payloadcms.com/images/logo-dark.png" width="150"
height="auto" />
</a>
</td>
</tr>
<tr>
<td class="spacer">&nbsp;</td>
</tr>
<tr>
<td>
<!-- HEADLINE -->
<h1 style="margin: 0 0 30px;">{{headline}}</h1>
</td>
</tr>
<tr>
<td>
<!-- CONTENT -->
{{{content}}}
<!-- CTA -->
{{#if cta}}
<div>
<a href="{{cta.url}}"
style="background-color:#222222;border-radius:4px;color:#ffffff;display:inline-block;font-family:sans-serif;font-size:13px;font-weight:bold;line-height:60px;text-align:center;text-decoration:none;width:200px;-webkit-text-size-adjust:none;">
{{cta.buttonLabel}}
</a>
</div>
{{/if}}
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</table>
</div>
</body>
</html>

View File

@@ -1,20 +0,0 @@
let email
if (process.env.NODE_ENV === 'production') {
email = {
fromName: 'Payload',
fromAddress: 'info@payloadcms.com',
transportOptions: {
// Configure a custom transport here
},
}
} else {
email = {
fromName: 'Ethereal Email',
fromAddress: 'example@ethereal.com',
logMockCredentials: true,
}
}
export default email

View File

@@ -1 +0,0 @@
export default {};

View File

@@ -1,35 +0,0 @@
/* tslint:disable */
/**
* This file was automatically generated by Payload CMS.
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
* and re-run `payload generate:types` to regenerate this file.
*/
export interface Config {
collections: {
'newsletter-signups': NewsletterSignup;
users: User;
};
globals: {};
}
export interface NewsletterSignup {
id: string;
name?: string;
email: string;
createdAt: string;
updatedAt: string;
}
export interface User {
id: string;
name?: string;
email?: string;
resetPasswordToken?: string;
resetPasswordExpiration?: string;
_verified?: boolean;
_verificationToken?: string;
loginAttempts?: number;
lockUntil?: string;
createdAt: string;
updatedAt: string;
password?: string;
}

View File

@@ -1,46 +0,0 @@
import dotenv from 'dotenv'
import path from 'path'
import { buildConfig } from 'payload/config'
import Users from './collections/Users'
import Newsletter from './collections/Newsletter'
dotenv.config({
path: path.resolve(__dirname, '../.env'),
})
const mockModulePath = path.resolve(__dirname, './emptyModule.js')
export default buildConfig({
admin: {
webpack: config => ({
...config,
resolve: {
...config?.resolve,
alias: [
'fs',
'handlebars',
'inline-css',
path.resolve(__dirname, './email/transport'),
path.resolve(__dirname, './email/generateEmailHTML'),
path.resolve(__dirname, './email/generateForgotPasswordEmail'),
path.resolve(__dirname, './email/generateVerificationEmail'),
].reduce(
(aliases, importPath) => ({
...aliases,
[importPath]: mockModulePath,
}),
config.resolve.alias,
),
},
}),
},
collections: [
Newsletter,
Users,
],
typescript: {
outputFile: path.resolve(__dirname, 'payload-types.ts'),
},
})

View File

@@ -1,30 +0,0 @@
import express from 'express'
import path from 'path'
import payload from 'payload'
import email from './email/transport'
require('dotenv').config({
path: path.resolve(__dirname, '../.env'),
})
const app = express()
app.get('/', (_, res) => {
res.redirect('/admin')
})
const start = async (): Promise<void> => {
await payload.init({
secret: process.env.PAYLOAD_SECRET,
mongoURL: process.env.MONGODB_URI,
express: app,
email,
onInit: () => {
payload.logger.info(`Payload Admin URL: ${payload.getAdminURL()}`)
},
})
app.listen(8000)
}
start()

View File

@@ -1,38 +0,0 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"strict": false,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "./dist",
"rootDir": "./src",
"jsx": "react",
"sourceMap": true,
"resolveJsonModule": true,
"paths": {
"payload/generated-types": [
"./src/payload-types.ts"
],
"node_modules/*": [
"./node_modules/*"
]
},
},
"include": [
"src"
],
"exclude": [
"node_modules",
"dist",
"build",
],
"ts-node": {
"transpileOnly": true
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +0,0 @@
MONGODB_URI=mongodb://localhost/payload-example-auth
PAYLOAD_SECRET=PAYLOAD_AUTH_EXAMPLE_SECRET_KEY
PAYLOAD_PUBLIC_SERVER_URL=http://localhost:3000
PAYLOAD_SEED=true
PAYLOAD_DROP_DATABASE=true

View File

@@ -1,4 +0,0 @@
module.exports = {
root: true,
extends: ['@payloadcms'],
}

View File

@@ -1,5 +0,0 @@
build
dist
node_modules
package-lock.json
.env

View File

@@ -1 +0,0 @@
legacy-peer-deps=true

View File

@@ -1,8 +0,0 @@
module.exports = {
printWidth: 100,
parser: "typescript",
semi: false,
singleQuote: true,
trailingComma: "all",
arrowParens: "avoid",
};

View File

@@ -1,130 +0,0 @@
# Payload Multi-Tenant Example
This example demonstrates how to achieve a multi-tenancy in [Payload](https://github.com/payloadcms/payload). This is a powerful way to vertically scale your application by sharing infrastructure across tenants.
## Quick Start
To spin up this example locally, follow these steps:
1. First clone the repo
1. Then `cd YOUR_PROJECT_REPO && cp .env.example .env`
1. Next `yarn && yarn dev`
1. Now `open http://localhost:3000/admin` to access the admin panel
1. Login with email `dev@payloadcms.com` and password `test`
That's it! Changes made in `./src` will be reflected in your app. See the [Development](#development) section for more details on how to log in as a tenant.
## How it works
A multi-tenant Payload application is a single server that hosts multiple "tenants". Examples of tenants may be your agency's clients, your business conglomerate's organizations, or your SaaS customers.
Each tenant has its own set of users, pages, and other data that is scoped to that tenant. This means that your application will be shared across tenants but the data will be scoped to each tenant. Tenants also run on separate domains entirely, so users are not aware of their tenancy.
### Collections
See the [Collections](https://payloadcms.com/docs/configuration/collections) docs for details on how to extend any of this functionality.
- #### Users
The `users` collection is auth-enabled and encompass both app-wide and tenant-scoped users based on the value of their `roles` and `tenants` fields. Users with the role `super-admin` can manage your entire application, while users with the _tenant role_ of `admin` have limited access to the platform and can manage only the tenant(s) they are assigned to, see [Tenants](#tenants) for more details.
For additional help with authentication, see the official [Auth Example](https://github.com/payloadcms/payload/tree/master/examples/auth/cms#readme) or the [Authentication](https://payloadcms.com/docs/authentication/overview#authentication-overview) docs.
- #### Tenants
A `tenants` collection is used to achieve tenant-based access control. Each user is assigned an array of `tenants` which includes a relationship to a `tenant` and their `roles` within that tenant. You can then scope any document within your application to any of your tenants using a simple [relationship](https://payloadcms.com/docs/fields/relationship) field on the `users` or `pages` collections, or any other collection that your application needs. The value of this field is used to filter documents in the admin panel and API to ensure that users can only access documents that belong to their tenant and are within their role. See [Access Control](#access-control) for more details.
For more details on how to extend this functionality, see the [Payload Access Control](https://payloadcms.com/docs/access-control/overview) docs.
- #### Pages
Each page is assigned a `tenant` which is used to control access and scope API requests. Pages that are created by tenants are automatically assigned that tenant based on that user's `lastLoggedInTenant` field.
## Access control
Basic role-based access control is setup to determine what users can and cannot do based on their roles, which are:
- `super-admin`: They can access the Payload admin panel to manage your multi-tenant application. They can see all tenants and make all operations.
- `user`: They can only access the Payload admin panel if they are a tenant-admin, in which case they have a limited access to operations based on their tenant (see below).
This applies to each collection in the following ways:
- `users`: Only super-admins, tenant-admins, and the user themselves can access their profile. Anyone can create a user, but only these admins can delete users. See [Users](#users) for more details.
- `tenants`: Only super-admins and tenant-admins can read, create, update, or delete tenants. See [Tenants](#tenants) for more details.
- `pages`: Everyone can access pages, but only super-admins and tenant-admins can create, update, or delete them.
> If you have versions and drafts enabled on your pages, you will need to add additional read access control condition to check the user's tenants that prevents them from accessing draft documents of other tenants.
For more details on how to extend this functionality, see the [Payload Access Control](https://payloadcms.com/docs/access-control/overview#access-control) docs.
## CORS
This multi-tenant setup requires an open CORS policy. Since each tenant contains a dynamic list of domains, there's no way to know specifically which domains to whitelist at runtime without significant performance implications. This also means that the `serverURL` is not set, as this scopes all requests to a single domain.
Alternatively, if you know the domains of your tenants ahead of time and these values won't change often, you could simply remove the `domains` field altogether and instead use static values.
For more details on this, see the [CORS](https://payloadcms.com/docs/production/preventing-abuse#cross-origin-resource-sharing-cors) docs.
## Front-end
If you're building a website or other front-end for your tenant, you will need specify the `tenant` in your requests. For example, if you wanted to fetch all pages for the tenant `ABC`, you would make a request to `/api/pages?where[tenant][slug][equals]=abc`.
For a head start on building a website for your tenant(s), check out the official [Website Template](https://github.com/payloadcms/template-website). It includes a page layout builder, preview, SEO, and much more. It is not multi-tenant, though, but you can easily take the concepts from that example and apply them here.
## Development
To spin up this example locally, follow the [Quick Start](#quick-start).
### Seed
On boot, a seed script is included to scaffold a basic database for you to use as an example. This is done by setting the `PAYLOAD_DROP_DATABASE` and `PAYLOAD_SEED` environment variables which are included in the `.env.example` by default. You can remove these from your `.env` to prevent this behavior. You can also freshly seed your project at any time by running `yarn seed`. This seed creates a super-admin user with email `dev@payloadcms.com` and password `test` along with the following tenants:
- `ABC`
- Domains:
- `abc.localhost.com:3000`
- Users:
- `admin@abc.com` with role `admin` and password `test`
- `user@abc.com` with role `user` and password `test`
- Pages:
- `ABC Home` with content `Hello, ABC!`
- `BBC`
- Domains:
- `bbc.localhost.com:3000`
- Users:
- `admin@bbc.com` with role `admin` and password `test`
- `user@bbc.com` with role `user` and password `test`
- Pages:
- `BBC Home` with content `Hello, BBC!`
> NOTICE: seeding the database is destructive because it drops your current database to populate a fresh one from the seed template. Only run this command if you are starting a new project or can afford to lose your current data.
### Hosts file
To fully experience the multi-tenancy of this example locally, your app must run on one of the domains listed in any of your tenant's `domains` field. The simplest way to do this to add the following lines to your hosts file.
```bash
# these domains were provided in the seed script
# if needed, change them based on your own tenant settings
# remember to specify the port number when browsing to these domains
127.0.0.1 abc.localhost.com
127.0.0.1 bbc.localhost.com
```
> On Mac you can find the hosts file at `/etc/hosts`. On Windows, it's at `C:\Windows\System32\drivers\etc\hosts`.
Then you can access your app at `http://abc.localhost.com:3000` and `http://bbc.localhost.com:3000`. Access control will be scoped to the correct tenant based on that user's `tenants`, see [Access Control](#access-control) for more details.
## Production
To run Payload in production, you need to build and serve the Admin panel. To do so, follow these steps:
1. First, invoke the `payload build` script by running `yarn build` or `npm run build` in your project root. This creates a `./build` directory with a production-ready admin bundle.
1. Then, run `yarn serve` or `npm run serve` to run Node in production and serve Payload from the `./build` directory.
### Deployment
The easiest way to deploy your project is to use [Payload Cloud](https://payloadcms.com/new/import), a one-click hosting solution to deploy production-ready instances of your Payload apps directly from your GitHub repo. You can also choose to self-host your app, check out the [Deployment](https://payloadcms.com/docs/production/deployment) docs for more details.
## Questions
If you have any issues or questions, reach out to us on [Discord](https://discord.com/invite/r6sCXqVk3v) or start a [GitHub discussion](https://github.com/payloadcms/payload/discussions).

View File

@@ -1,4 +0,0 @@
{
"ext": "ts",
"exec": "ts-node src/server.ts"
}

View File

@@ -1,46 +0,0 @@
{
"name": "payload-example-multi-tenant",
"description": "Payload multi-tenant example.",
"version": "1.0.0",
"main": "dist/server.js",
"license": "MIT",
"scripts": {
"dev": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts nodemon",
"seed": "rm -rf media && cross-env PAYLOAD_SEED=true PAYLOAD_DROP_DATABASE=true PAYLOAD_CONFIG_PATH=src/payload.config.ts ts-node src/server.ts",
"build:payload": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts payload build",
"build:server": "tsc",
"build": "yarn copyfiles && yarn build:payload && yarn build:server",
"serve": "cross-env PAYLOAD_CONFIG_PATH=dist/payload.config.js NODE_ENV=production node dist/server.js",
"copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,ttf,woff,woff2,eot,svg,jpg,png}\" dist/",
"generate:types": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts payload generate:types",
"generate:graphQLSchema": "PAYLOAD_CONFIG_PATH=src/payload.config.ts payload generate:graphQLSchema",
"lint": "eslint src",
"lint:fix": "eslint --fix --ext .ts,.tsx src"
},
"dependencies": {
"dotenv": "^8.2.0",
"express": "^4.17.1",
"payload": "^1.8.2"
},
"devDependencies": {
"@payloadcms/eslint-config": "^0.0.1",
"@types/express": "^4.17.9",
"@types/node": "18.11.3",
"@types/react": "18.0.21",
"@typescript-eslint/eslint-plugin": "^5.51.0",
"@typescript-eslint/parser": "^5.51.0",
"copyfiles": "^2.4.1",
"cross-env": "^7.0.3",
"eslint": "^8.19.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-filenames": "^1.3.2",
"eslint-plugin-import": "2.25.4",
"eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-simple-import-sort": "^10.0.0",
"nodemon": "^2.0.6",
"prettier": "^2.7.1",
"ts-node": "^9.1.1",
"typescript": "^4.8.4"
}
}

View File

@@ -1,6 +0,0 @@
import type { Access } from 'payload/types'
import { checkUserRoles } from '../../utilities/checkUserRoles'
export const lastLoggedInTenant: Access = ({ req: { user }, data }) =>
checkUserRoles(['super-admin'], user) || user?.lastLoggedInTenant?.id === data?.id

View File

@@ -1,5 +0,0 @@
import type { Access } from 'payload/config'
export const loggedIn: Access = ({ req: { user } }) => {
return Boolean(user)
}

View File

@@ -1,21 +0,0 @@
import type { Access } from 'payload/config'
import { checkUserRoles } from '../../utilities/checkUserRoles'
// the user must be an admin of the document's tenant
export const tenantAdmins: Access = ({ req: { user } }) => {
if (checkUserRoles(['super-admin'], user)) {
return true
}
return {
tenant: {
in:
user?.tenants
?.map(({ tenant, roles }) =>
roles.includes('admin') ? (typeof tenant === 'string' ? tenant : tenant.id) : null,
) // eslint-disable-line function-paren-newline
.filter(Boolean) || [],
},
}
}

View File

@@ -1,13 +0,0 @@
import type { Access } from 'payload/types'
import { isSuperAdmin } from '../../utilities/isSuperAdmin'
export const tenants: Access = ({ req: { user }, data }) =>
isSuperAdmin(user) ||
// individual documents
(data?.tenant?.id && user?.lastLoggedInTenant?.id === data.tenant.id) || {
// list of documents
tenant: {
equals: user?.lastLoggedInTenant?.id,
},
}

View File

@@ -1,27 +0,0 @@
import type { FieldHook } from 'payload/types'
const format = (val: string): string =>
val
.replace(/ /g, '-')
.replace(/[^\w-]+/g, '')
.toLowerCase()
const formatSlug =
(fallback: string): FieldHook =>
({ operation, value, originalDoc, data }) => {
if (typeof value === 'string') {
return format(value)
}
if (operation === 'create') {
const fallbackData = data?.[fallback] || originalDoc?.[fallback]
if (fallbackData && typeof fallbackData === 'string') {
return format(fallbackData)
}
}
return value
}
export default formatSlug

View File

@@ -1,43 +0,0 @@
import type { CollectionConfig } from 'payload/types'
import richText from '../fields/richText'
import { tenant } from '../fields/tenant'
import { loggedIn } from './access/loggedIn'
import { tenantAdmins } from './access/tenantAdmins'
import { tenants } from './access/tenants'
import formatSlug from './hooks/formatSlug'
export const Pages: CollectionConfig = {
slug: 'pages',
admin: {
useAsTitle: 'title',
defaultColumns: ['title', 'slug', 'updatedAt'],
},
access: {
read: tenants,
create: loggedIn,
update: tenantAdmins,
delete: tenantAdmins,
},
fields: [
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'slug',
label: 'Slug',
type: 'text',
index: true,
admin: {
position: 'sidebar',
},
hooks: {
beforeValidate: [formatSlug('title')],
},
},
tenant,
richText(),
],
}

View File

@@ -1,21 +0,0 @@
import type { Access } from 'payload/config'
import { checkUserRoles } from '../../utilities/checkUserRoles'
// the user must be an admin of the tenant being accessed
export const tenantAdmins: Access = ({ req: { user } }) => {
if (checkUserRoles(['super-admin'], user)) {
return true
}
return {
id: {
in:
user?.tenants
?.map(({ tenant, roles }) =>
roles.includes('admin') ? (typeof tenant === 'string' ? tenant : tenant.id) : null,
) // eslint-disable-line function-paren-newline
.filter(Boolean) || [],
},
}
}

View File

@@ -1,36 +0,0 @@
import type { CollectionConfig } from 'payload/types'
import { superAdmins } from '../access/superAdmins'
import { tenantAdmins } from './access/tenantAdmins'
export const Tenants: CollectionConfig = {
slug: 'tenants',
access: {
create: superAdmins,
read: tenantAdmins,
update: tenantAdmins,
delete: superAdmins,
},
admin: {
useAsTitle: 'name',
},
fields: [
{
name: 'name',
type: 'text',
required: true,
},
{
name: 'domains',
type: 'array',
index: true,
fields: [
{
name: 'domain',
type: 'text',
required: true,
},
],
},
],
}

View File

@@ -1,37 +0,0 @@
import type { Access } from 'payload/config'
import type { User } from 'payload/generated-types'
import { isSuperAdmin } from '../../utilities/isSuperAdmin'
export const adminsAndSelf: Access<any, User> = async ({ req: { user } }) => {
if (user) {
if (isSuperAdmin(user)) {
return true
}
// allow users to read themselves and any users within the tenants they are admins of
return {
or: [
{
id: {
equals: user.id,
},
},
{
'tenants.tenant': {
in:
user?.tenants
?.map(({ tenant, roles }) =>
roles.includes('admin')
? typeof tenant === 'string'
? tenant
: tenant.id
: null,
) // eslint-disable-line function-paren-newline
.filter(Boolean) || [],
},
},
],
}
}
}

View File

@@ -1,19 +0,0 @@
import type { FieldAccess } from 'payload/types'
import { checkUserRoles } from '../../utilities/checkUserRoles'
import { checkTenantRoles } from '../utilities/checkTenantRoles'
export const tenantAdmins: FieldAccess = args => {
const {
req: { user },
doc,
} = args
return (
checkUserRoles(['super-admin'], user) ||
doc?.tenants?.some(({ tenant }) => {
const id = typeof tenant === 'string' ? tenant : tenant?.id
return checkTenantRoles(['admin'], user, id)
})
)
}

View File

@@ -1,29 +0,0 @@
import type { AfterChangeHook } from 'payload/dist/collections/config/types'
export const loginAfterCreate: AfterChangeHook = async ({
doc,
req,
req: { payload, body = {}, res },
operation,
}) => {
if (operation === 'create' && !req.user) {
const { email, password } = body
if (email && password) {
const { user, token } = await payload.login({
collection: 'users',
data: { email, password },
req,
res,
})
return {
...doc,
token,
user,
}
}
}
return doc
}

View File

@@ -1,34 +0,0 @@
import type { AfterLoginHook } from 'payload/dist/collections/config/types'
import { isSuperAdmin } from '../../utilities/isSuperAdmin'
export const recordLastLoggedInTenant: AfterLoginHook = async ({ req, user }) => {
try {
if (!isSuperAdmin(user)) {
const relatedOrg = await req.payload.find({
collection: 'tenants',
where: {
'domains.domain': {
in: [req.headers.host],
},
},
depth: 0,
limit: 1,
})
if (relatedOrg.docs.length > 0) {
await req.payload.update({
id: user.id,
collection: 'users',
data: {
lastLoggedInTenant: relatedOrg.docs[0].id,
},
})
}
}
} catch (err: unknown) {
req.payload.logger.error(`Error recording last logged in tenant for user ${user.id}: ${err}`)
}
return user
}

View File

@@ -1,104 +0,0 @@
import type { CollectionConfig } from 'payload/types'
import { anyone } from '../access/anyone'
import { superAdminFieldAccess } from '../access/superAdmins'
import { adminsAndSelf } from './access/adminsAndSelf'
import { tenantAdmins } from './access/tenantAdmins'
import { loginAfterCreate } from './hooks/loginAfterCreate'
import { recordLastLoggedInTenant } from './hooks/recordLastLoggedInTenant'
import { isSuperOrTenantAdmin } from './utilities/isSuperOrTenantAdmin'
export const Users: CollectionConfig = {
slug: 'users',
auth: true,
admin: {
useAsTitle: 'email',
},
access: {
read: adminsAndSelf,
create: anyone,
update: adminsAndSelf,
delete: adminsAndSelf,
admin: isSuperOrTenantAdmin,
},
hooks: {
afterChange: [loginAfterCreate],
afterLogin: [recordLastLoggedInTenant],
},
fields: [
{
name: 'firstName',
type: 'text',
},
{
name: 'lastName',
type: 'text',
},
{
name: 'roles',
type: 'select',
hasMany: true,
required: true,
access: {
create: superAdminFieldAccess,
update: superAdminFieldAccess,
read: superAdminFieldAccess,
},
options: [
{
label: 'Super Admin',
value: 'super-admin',
},
{
label: 'User',
value: 'user',
},
],
},
{
name: 'tenants',
type: 'array',
label: 'Tenants',
access: {
create: tenantAdmins,
update: tenantAdmins,
read: tenantAdmins,
},
fields: [
{
name: 'tenant',
type: 'relationship',
relationTo: 'tenants',
required: true,
},
{
name: 'roles',
type: 'select',
hasMany: true,
required: true,
options: [
{
label: 'Admin',
value: 'admin',
},
{
label: 'User',
value: 'user',
},
],
},
],
},
{
name: 'lastLoggedInTenant',
type: 'relationship',
relationTo: 'tenants',
index: true,
access: {
create: () => false,
read: tenantAdmins,
update: () => false,
},
},
],
}

View File

@@ -1,23 +0,0 @@
import type { User } from '../../../payload-types'
export const checkTenantRoles = (
allRoles: User['tenants'][0]['roles'] = [],
user: User = undefined,
tenant: User['tenants'][0]['tenant'] = undefined,
): boolean => {
if (tenant) {
const id = typeof tenant === 'string' ? tenant : tenant?.id
if (
allRoles.some(role => {
return user?.tenants?.some(({ tenant: userTenant, roles }) => {
const tenantID = typeof userTenant === 'string' ? userTenant : userTenant?.id
return tenantID === id && roles?.includes(role)
})
})
)
return true
}
return false
}

View File

@@ -1,70 +0,0 @@
import type { PayloadRequest } from 'payload/dist/types'
import { isSuperAdmin } from '../../utilities/isSuperAdmin'
const logs = false
export const isSuperOrTenantAdmin = async (args: { req: PayloadRequest }): Promise<boolean> => {
const {
req,
req: { user, payload },
} = args
// always allow super admins through
if (isSuperAdmin(user)) {
return true
}
if (logs) {
const msg = `Finding tenant with host: '${req.headers.host}'`
payload.logger.info({ msg })
}
// read `req.headers.host`, lookup the tenant by `domain` to ensure it exists, and check if the user is an admin of that tenant
const foundTenants = await payload.find({
collection: 'tenants',
where: {
'domains.domain': {
in: [req.headers.host],
},
},
depth: 0,
limit: 1,
})
// if this tenant does not exist, deny access
if (foundTenants.totalDocs === 0) {
if (logs) {
const msg = `No tenant found for ${req.headers.host}`
payload.logger.info({ msg })
}
return false
}
if (logs) {
const msg = `Found tenant: '${foundTenants.docs?.[0]?.name}', checking if user is an tenant admin`
payload.logger.info({ msg })
}
// finally check if the user is an admin of this tenant
const tenantWithUser = user?.tenants?.find(
({ tenant: userTenant }) => userTenant?.id === foundTenants.docs[0].id,
)
if (tenantWithUser?.roles?.some(role => role === 'admin')) {
if (logs) {
const msg = `User is an admin of ${foundTenants.docs[0].name}, allowing access`
payload.logger.info({ msg })
}
return true
}
if (logs) {
const msg = `User is not an admin of ${foundTenants.docs[0].name}, denying access`
payload.logger.info({ msg })
}
return false
}

View File

@@ -1,3 +0,0 @@
import type { Access } from 'payload/config'
export const anyone: Access = () => true

View File

@@ -1,9 +0,0 @@
import type { Access } from 'payload/config'
import type { FieldHook } from 'payload/types'
import { checkUserRoles } from '../utilities/checkUserRoles'
export const superAdmins: Access = ({ req: { user } }) => checkUserRoles(['super-admin'], user)
export const superAdminFieldAccess: FieldHook = ({ req: { user } }) =>
checkUserRoles(['super-admin'], user)

View File

@@ -1,145 +0,0 @@
import type { Field } from 'payload/types'
import deepMerge from '../utilities/deepMerge'
export const appearanceOptions = {
primary: {
label: 'Primary Button',
value: 'primary',
},
secondary: {
label: 'Secondary Button',
value: 'secondary',
},
default: {
label: 'Default',
value: 'default',
},
}
export type LinkAppearances = 'primary' | 'secondary' | 'default'
type LinkType = (options?: {
appearances?: LinkAppearances[] | false
disableLabel?: boolean
overrides?: Record<string, unknown>
}) => Field
const link: LinkType = ({ appearances, disableLabel = false, overrides = {} } = {}) => {
const linkResult: Field = {
name: 'link',
type: 'group',
admin: {
hideGutter: true,
},
fields: [
{
type: 'row',
fields: [
{
name: 'type',
type: 'radio',
options: [
{
label: 'Internal link',
value: 'reference',
},
{
label: 'Custom URL',
value: 'custom',
},
],
defaultValue: 'reference',
admin: {
layout: 'horizontal',
width: '50%',
},
},
{
name: 'newTab',
label: 'Open in new tab',
type: 'checkbox',
admin: {
width: '50%',
style: {
alignSelf: 'flex-end',
},
},
},
],
},
],
}
const linkTypes: Field[] = [
{
name: 'reference',
label: 'Document to link to',
type: 'relationship',
relationTo: ['pages'],
required: true,
maxDepth: 1,
admin: {
condition: (_, siblingData) => siblingData?.type === 'reference',
},
},
{
name: 'url',
label: 'Custom URL',
type: 'text',
required: true,
admin: {
condition: (_, siblingData) => siblingData?.type === 'custom',
},
},
]
if (!disableLabel) {
linkTypes[0].admin.width = '50%'
linkTypes[1].admin.width = '50%'
linkResult.fields.push({
type: 'row',
fields: [
...linkTypes,
{
name: 'label',
label: 'Label',
type: 'text',
required: true,
admin: {
width: '50%',
},
},
],
})
} else {
linkResult.fields = [...linkResult.fields, ...linkTypes]
}
if (appearances !== false) {
let appearanceOptionsToUse = [
appearanceOptions.default,
appearanceOptions.primary,
appearanceOptions.secondary,
]
if (appearances) {
appearanceOptionsToUse = appearances.map(appearance => appearanceOptions[appearance])
}
linkResult.fields.push({
name: 'appearance',
type: 'select',
defaultValue: 'default',
options: appearanceOptionsToUse,
admin: {
description: 'Choose how the link should be rendered.',
},
})
}
return deepMerge(linkResult, overrides)
}
export default link

View File

@@ -1,5 +0,0 @@
import type { RichTextElement } from 'payload/dist/fields/config/types'
const elements: RichTextElement[] = ['blockquote', 'h2', 'h3', 'h4', 'h5', 'h6', 'link']
export default elements

View File

@@ -1,86 +0,0 @@
import type { RichTextElement, RichTextField, RichTextLeaf } from 'payload/dist/fields/config/types'
import deepMerge from '../../utilities/deepMerge'
import link from '../link'
import elements from './elements'
import leaves from './leaves'
type RichText = (
overrides?: Partial<RichTextField>,
additions?: {
elements?: RichTextElement[]
leaves?: RichTextLeaf[]
},
) => RichTextField
const richText: RichText = (
overrides,
additions = {
elements: [],
leaves: [],
},
) =>
deepMerge<RichTextField, Partial<RichTextField>>(
{
name: 'richText',
type: 'richText',
required: true,
admin: {
upload: {
collections: {
media: {
fields: [
{
type: 'richText',
name: 'caption',
label: 'Caption',
admin: {
elements: [...elements],
leaves: [...leaves],
},
},
{
type: 'radio',
name: 'alignment',
label: 'Alignment',
options: [
{
label: 'Left',
value: 'left',
},
{
label: 'Center',
value: 'center',
},
{
label: 'Right',
value: 'right',
},
],
},
{
name: 'enableLink',
type: 'checkbox',
label: 'Enable Link',
},
link({
appearances: false,
disableLabel: true,
overrides: {
admin: {
condition: (_, data) => Boolean(data?.enableLink),
},
},
}),
],
},
},
},
elements: [...elements, ...(additions.elements || [])],
leaves: [...leaves, ...(additions.leaves || [])],
},
},
overrides,
)
export default richText

View File

@@ -1,5 +0,0 @@
import type { RichTextLeaf } from 'payload/dist/fields/config/types'
const defaultLeaves: RichTextLeaf[] = ['bold', 'italic', 'underline']
export default defaultLeaves

View File

@@ -1,16 +0,0 @@
import type { FieldAccess } from 'payload/types'
import { checkUserRoles } from '../../../utilities/checkUserRoles'
export const tenantAdminFieldAccess: FieldAccess = ({ req: { user }, doc }) => {
return (
checkUserRoles(['super-admin'], user) ||
!doc?.tenant ||
(doc?.tenant &&
user?.tenants?.some(
({ tenant: userTenant, roles }) =>
(typeof doc?.tenant === 'string' ? doc?.tenant : doc?.tenant.id) === userTenant?.id &&
roles?.includes('admin'),
))
)
}

View File

@@ -1,42 +0,0 @@
import type { Field } from 'payload/types'
import { superAdminFieldAccess } from '../../access/superAdmins'
import { isSuperAdmin } from '../../utilities/isSuperAdmin'
import { tenantAdminFieldAccess } from './access/tenantAdmins'
export const tenant: Field = {
name: 'tenant',
type: 'relationship',
relationTo: 'tenants',
// don't require this field because we need to auto-populate it, see below
// required: true,
// we also don't want to hide this field because super-admins may need to manage it
// to achieve this, create a custom component that conditionally renders the field based on the user's role
// hidden: true,
index: true,
admin: {
position: 'sidebar',
},
access: {
create: superAdminFieldAccess,
read: tenantAdminFieldAccess,
update: superAdminFieldAccess,
},
hooks: {
// automatically set the tenant to the last logged in tenant
// for super admins, allow them to set the tenant
beforeChange: [
async ({ req, req: { user }, data }) => {
if ((await isSuperAdmin(req.user)) && data?.tenant) {
return data.tenant
}
if (user?.lastLoggedInTenant?.id) {
return user.lastLoggedInTenant.id
}
return undefined
},
],
},
}

View File

@@ -1,16 +0,0 @@
import type { User } from '../../payload-types'
export const checkUserRoles = (allRoles: User['roles'] = [], user: User = undefined): boolean => {
if (user) {
if (
allRoles.some(role => {
return user?.roles?.some(individualRole => {
return individualRole === role
})
})
)
return true
}
return false
}

View File

@@ -1,32 +0,0 @@
/**
* Simple object check.
* @param item
* @returns {boolean}
*/
export function isObject(item: unknown): boolean {
return item && typeof item === 'object' && !Array.isArray(item)
}
/**
* Deep merge two objects.
* @param target
* @param ...sources
*/
export default function deepMerge<T, R>(target: T, source: R): T {
const output = { ...target }
if (isObject(target) && isObject(source)) {
Object.keys(source).forEach(key => {
if (isObject(source[key])) {
if (!(key in (target as Record<string, unknown>))) {
Object.assign(output, { [key]: source[key] })
} else {
output[key] = deepMerge(target[key], source[key])
}
} else {
Object.assign(output, { [key]: source[key] })
}
})
}
return output
}

View File

@@ -1,8 +0,0 @@
import type { User } from 'payload/generated-types'
import { checkUserRoles } from './checkUserRoles'
export const isSuperAdmin = (user: User): boolean => {
if (user?.email === 'dev@payloadcms.com') return true // for the seed script, remove this in production
return checkUserRoles(['super-admin'], user)
}

View File

@@ -1,56 +0,0 @@
/* tslint:disable */
/**
* This file was automatically generated by Payload CMS.
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
* and re-run `payload generate:types` to regenerate this file.
*/
export interface Config {
collections: {
users: User
tenants: Tenant
pages: Page
}
globals: {}
}
export interface User {
id: string
firstName?: string
lastName?: string
roles: Array<'super-admin' | 'user'>
tenants?: Array<{
tenant: string | Tenant
roles: Array<'admin' | 'user'>
id?: string
}>
lastLoggedInTenant?: string | Tenant
updatedAt: string
createdAt: string
email?: string
resetPasswordToken?: string
resetPasswordExpiration?: string
loginAttempts?: number
lockUntil?: string
password?: string
}
export interface Tenant {
id: string
name: string
domains: Array<{
domain: string
id?: string
}>
updatedAt: string
createdAt: string
}
export interface Page {
id: string
title: string
slug?: string
tenant?: string | Tenant
richText: Array<{
[k: string]: unknown
}>
updatedAt: string
createdAt: string
}

View File

@@ -1,19 +0,0 @@
import dotenv from 'dotenv'
import path from 'path'
dotenv.config({
path: path.resolve(__dirname, '../.env'),
})
import { buildConfig } from 'payload/config'
import { Pages } from './collections/Pages'
import { Tenants } from './collections/Tenants'
import { Users } from './collections/Users'
export default buildConfig({
collections: [Users, Tenants, Pages],
typescript: {
outputFile: path.resolve(__dirname, 'payload-types.ts'),
},
})

View File

@@ -1,119 +0,0 @@
import type { Payload } from 'payload'
export const seed = async (payload: Payload): Promise<void> => {
// create super admin
await payload.create({
collection: 'users',
data: {
email: 'dev@payloadcms.com',
password: 'test',
roles: ['super-admin'],
},
})
// create tenants, use `*.localhost.com` so that accidentally forgotten changes the hosts file are acceptable
const [abc, bbc] = await Promise.all([
await payload.create({
collection: 'tenants',
data: {
name: 'ABC',
domains: [{ domain: 'abc.localhost.com:3000' }],
},
}),
await payload.create({
collection: 'tenants',
data: {
name: 'BBC',
domains: [{ domain: 'bbc.localhost.com:3000' }],
},
}),
])
// create tenant-scoped admins and users
await Promise.all([
await payload.create({
collection: 'users',
data: {
email: 'admin@abc.com',
password: 'test',
roles: ['user'],
tenants: [
{
tenant: abc.id,
roles: ['admin'],
},
],
},
}),
await payload.create({
collection: 'users',
data: {
email: 'user@abc.com',
password: 'test',
roles: ['user'],
tenants: [
{
tenant: abc.id,
roles: ['user'],
},
],
},
}),
await payload.create({
collection: 'users',
data: {
email: 'admin@bbc.com',
password: 'test',
roles: ['user'],
tenants: [
{
tenant: bbc.id,
roles: ['admin'],
},
],
},
}),
await payload.create({
collection: 'users',
data: {
email: 'user@bbc.com',
password: 'test',
roles: ['user'],
tenants: [
{
tenant: bbc.id,
roles: ['user'],
},
],
},
}),
])
// create tenant-scoped pages
await Promise.all([
await payload.create({
collection: 'pages',
data: {
tenant: abc.id,
title: 'ABC Home',
richText: [
{
text: 'Hello, ABC!',
},
],
},
}),
await payload.create({
collection: 'pages',
data: {
title: 'BBC Home',
tenant: bbc.id,
richText: [
{
text: 'Hello, BBC!',
},
],
},
}),
])
}

View File

@@ -1,37 +0,0 @@
import dotenv from 'dotenv'
import path from 'path'
dotenv.config({
path: path.resolve(__dirname, '../.env'),
})
import express from 'express'
import payload from 'payload'
import { seed } from './seed'
const app = express()
app.get('/', (_, res) => {
res.redirect('/admin')
})
const start = async (): Promise<void> => {
await payload.init({
secret: process.env.PAYLOAD_SECRET,
mongoURL: process.env.MONGODB_URI,
express: app,
onInit: () => {
payload.logger.info(`Payload Admin URL: ${payload.getAdminURL()}`)
},
})
if (process.env.PAYLOAD_SEED === 'true') {
payload.logger.info('---- SEEDING DATABASE ----')
await seed(payload)
}
app.listen(3000)
}
start()

View File

@@ -1,34 +0,0 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"strict": false,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "./dist",
"rootDir": "./src",
"jsx": "react",
"sourceMap": true,
"resolveJsonModule": true,
"paths": {
"payload/generated-types": ["./src/payload-types.ts"],
"node_modules/*": ["./node_modules/*"]
},
},
"include": [
"src"
],
"exclude": [
"node_modules",
"dist",
"build",
],
"ts-node": {
"transpileOnly": true
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "payload",
"version": "1.8.3",
"version": "1.8.2",
"description": "Node, React and MongoDB Headless CMS and Application Framework",
"license": "MIT",
"engines": {
@@ -38,7 +38,7 @@
"build:watch": "nodemon --watch 'src/**' --ext 'ts,tsx' --exec \"yarn build:tsc\"",
"dev": "nodemon",
"dev:generate-types": "ts-node -T ./test/generateTypes.ts",
"dev:generate-graphql-schema": "ts-node -T ./test/generateGraphQLSchema.ts",
"dev:generate-graphql-schema": "cross-env PAYLOAD_CONFIG_PATH=test/graphql-schema-gen/config.ts ts-node -T ./src/bin/generateGraphQLSchema.ts",
"pretest": "yarn build",
"test": "yarn test:int && yarn test:components && yarn test:e2e",
"test:int": "cross-env DISABLE_LOGGING=true jest --forceExit --detectOpenHandles",
@@ -87,7 +87,7 @@
"@faceless-ui/modal": "^2.0.1",
"@faceless-ui/scroll-info": "^1.3.0",
"@faceless-ui/window-info": "^2.1.1",
"@monaco-editor/react": "^4.5.1",
"@monaco-editor/react": "^4.4.6",
"@swc/core": "^1.3.26",
"@swc/register": "^0.1.10",
"@types/sharp": "^0.31.1",
@@ -97,7 +97,7 @@
"conf": "^10.2.0",
"connect-history-api-fallback": "^1.6.0",
"css-loader": "^5.2.7",
"css-minimizer-webpack-plugin": "^5.0.0",
"css-minimizer-webpack-plugin": "^3.4.1",
"dataloader": "^2.1.0",
"date-fns": "^2.29.3",
"deep-equal": "^2.2.0",
@@ -136,7 +136,6 @@
"mini-css-extract-plugin": "1.6.2",
"minimist": "^1.2.7",
"mkdirp": "^1.0.4",
"monaco-editor": "^0.38.0",
"mongoose": "6.5.0",
"mongoose-aggregate-paginate-v2": "^1.0.6",
"mongoose-paginate-v2": "^1.6.1",
@@ -149,7 +148,6 @@
"passport-local": "^1.0.0",
"passport-local-mongoose": "^7.1.2",
"path-browserify": "^1.0.1",
"payload": "^1.8.2",
"pino": "^6.4.1",
"pino-pretty": "^9.1.1",
"pluralize": "^8.0.0",
@@ -163,18 +161,17 @@
"react": "^18.2.0",
"react-animate-height": "^2.1.2",
"react-datepicker": "^4.10.0",
"react-diff-viewer-continued": "^3.2.6",
"react-diff-viewer": "^3.1.1",
"react-dom": "^18.2.0",
"react-helmet": "^6.1.0",
"react-i18next": "^11.18.6",
"react-router-dom": "^5.3.4",
"react-router-navigation-prompt": "^1.9.6",
"react-select": "^5.7.3",
"react-select": "^3.2.0",
"react-toastify": "^8.2.0",
"sanitize-filename": "^1.6.3",
"sass": "^1.57.1",
"sass-loader": "^12.6.0",
"scheduler": "^0.23.0",
"sharp": "^0.31.3",
"slate": "^0.91.4",
"slate-history": "^0.86.0",
@@ -200,10 +197,11 @@
"@swc/jest": "^0.2.24",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.4.0",
"@trbl/eslint-config": "^3.0.1",
"@trbl/eslint-config": "^1.2.4",
"@types/asap": "^2.0.0",
"@types/body-parser": "^1.19.2",
"@types/compression": "^1.7.2",
"@types/conf": "^3.0.0",
"@types/connect-history-api-fallback": "^1.3.5",
"@types/eslint": "^7.29.0",
"@types/express": "^4.17.15",
@@ -226,7 +224,7 @@
"@types/minimist": "^1.2.2",
"@types/mkdirp": "^1.0.2",
"@types/mongoose-aggregate-paginate-v2": "^1.0.5",
"@types/node": "^20.1.3",
"@types/mongoose-paginate-v2": "^1.6.4",
"@types/node-fetch": "^2.6.2",
"@types/nodemailer": "^6.4.7",
"@types/nodemon": "^1.19.2",
@@ -235,6 +233,8 @@
"@types/passport-anonymous": "^1.0.3",
"@types/passport-jwt": "^3.0.8",
"@types/passport-local": "^1.0.35",
"@types/pino": "^6.3.12",
"@types/pino-std-serializers": "^4.0.0",
"@types/pluralize": "^0.0.29",
"@types/prismjs": "^1.26.0",
"@types/probe-image-size": "^7.2.0",
@@ -246,10 +246,13 @@
"@types/react-dom": "^18.0.10",
"@types/react-helmet": "^6.1.6",
"@types/react-router-dom": "^5.3.3",
"@types/react-select": "^3.1.2",
"@types/sass": "^1.43.1",
"@types/shelljs": "^0.8.11",
"@types/testing-library__jest-dom": "^5.14.5",
"@types/uuid": "^8.3.4",
"@types/webpack-bundle-analyzer": "^4.6.0",
"@types/webpack-dev-middleware": "^5.3.0",
"@types/webpack-env": "^1.18.0",
"@types/webpack-hot-middleware": "2.25.6",
"@typescript-eslint/eslint-plugin": "^5.59.1",
@@ -272,7 +275,7 @@
"graphql-request": "^3.7.0",
"jest": "^29.3.1",
"jest-environment-jsdom": "^29.3.1",
"mongodb-memory-server": "^8.12.2",
"mongodb-memory-server": "^7.6.3",
"node-fetch": "2",
"nodemon": "^2.0.20",
"object.assign": "^4.1.0",

File diff suppressed because it is too large Load Diff

View File

@@ -28,14 +28,12 @@ const ButtonContents = ({ children, icon, tooltip, showTooltip }) => {
return (
<Fragment>
{tooltip && (
<Tooltip
className={`${baseClass}__tooltip`}
show={showTooltip}
>
{tooltip}
</Tooltip>
)}
<Tooltip
className={`${baseClass}__tooltip`}
show={showTooltip}
>
{tooltip}
</Tooltip>
<span className={`${baseClass}__content`}>
{children && (
<span className={`${baseClass}__label`}>

View File

@@ -16,12 +16,6 @@
}
.btn {
margin: 0;
}
&__actions {
display: flex;
flex-wrap: wrap;
gap: $baseline;
margin-right: $baseline;
}
}

View File

@@ -109,22 +109,20 @@ const DeleteDocument: React.FC<Props> = (props) => {
</strong>
</Trans>
</p>
<div className={`${baseClass}__actions`}>
<Button
id="confirm-cancel"
buttonStyle="secondary"
type="button"
onClick={deleting ? undefined : () => toggleModal(modalSlug)}
>
{t('cancel')}
</Button>
<Button
onClick={deleting ? undefined : handleDelete}
id="confirm-delete"
>
{deleting ? t('deleting') : t('confirm')}
</Button>
</div>
<Button
id="confirm-cancel"
buttonStyle="secondary"
type="button"
onClick={deleting ? undefined : () => toggleModal(modalSlug)}
>
{t('cancel')}
</Button>
<Button
onClick={deleting ? undefined : handleDelete}
id="confirm-delete"
>
{deleting ? t('deleting') : t('confirm')}
</Button>
</MinimalTemplate>
</Modal>
</React.Fragment>

View File

@@ -1,10 +1,14 @@
import React, { HTMLAttributes } from 'react';
import { Props as EditViewProps } from '../../views/collections/Edit/types';
import { SanitizedCollectionConfig } from '../../../../collections/config/types';
export type DocumentDrawerProps = {
collectionSlug: string
id?: string
onSave?: EditViewProps['onSave']
onSave?: (json: {
doc: Record<string, any>
message: string
collectionConfig: SanitizedCollectionConfig
}) => void
customHeader?: React.ReactNode
drawerSlug?: string
}

View File

@@ -1,12 +1,12 @@
import React from 'react';
import { ClearIndicatorProps } from 'react-select';
import { IndicatorProps } from 'react-select';
import X from '../../../icons/X';
import { Option as OptionType } from '../types';
import './index.scss';
const baseClass = 'clear-indicator';
export const ClearIndicator: React.FC<ClearIndicatorProps<OptionType, true>> = (props) => {
export const ClearIndicator: React.FC<IndicatorProps<OptionType, true>> = (props) => {
const {
innerProps: { ref, ...restInnerProps },
} = props;

View File

@@ -6,10 +6,12 @@ export const Control: React.FC<ControlProps<Option, any>> = (props) => {
const {
children,
innerProps,
customProps: {
disableMouseDown,
disableKeyDown,
} = {},
selectProps: {
selectProps: {
disableMouseDown,
disableKeyDown,
},
},
} = props;
return (

View File

@@ -9,6 +9,7 @@ import { Option as OptionType } from '../types';
import './index.scss';
const baseClass = 'multi-value';
export const MultiValue: React.FC<MultiValueProps<OptionType>> = (props) => {
const {
className,
@@ -17,9 +18,12 @@ export const MultiValue: React.FC<MultiValueProps<OptionType>> = (props) => {
data: {
value,
},
customProps: {
disableMouseDown,
} = {},
selectProps: {
selectProps,
selectProps: {
disableMouseDown,
},
},
} = props;
const {
@@ -45,8 +49,6 @@ export const MultiValue: React.FC<MultiValueProps<OptionType>> = (props) => {
className={classes}
innerProps={{
...innerProps,
...attributes,
...listeners,
ref: setNodeRef,
onMouseDown: (e) => {
if (!disableMouseDown) {
@@ -59,6 +61,14 @@ export const MultiValue: React.FC<MultiValueProps<OptionType>> = (props) => {
transform,
},
}}
selectProps={{
...selectProps,
// pass the draggable props through to the label so it alone acts as the draggable handle
draggableProps: {
...attributes,
...listeners,
},
}}
/>
);
};

View File

@@ -7,9 +7,9 @@ const baseClass = 'multi-value-label';
export const MultiValueLabel: React.FC<MultiValueProps<Option>> = (props) => {
const {
customProps: {
selectProps: {
draggableProps,
} = {},
},
} = props;
return (

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { MultiValueRemoveProps } from 'react-select';
import { MultiValueRemoveProps } from 'react-select/src/components/MultiValue';
import X from '../../../icons/X';
import Tooltip from '../../Tooltip';
import { Option as OptionType } from '../types';
@@ -8,33 +8,23 @@ import './index.scss';
const baseClass = 'multi-value-remove';
export const MultiValueRemove: React.FC<MultiValueRemoveProps<OptionType> & {
innerProps: JSX.IntrinsicElements['button']
}> = (props) => {
export const MultiValueRemove: React.FC<MultiValueRemoveProps<OptionType>> = (props) => {
const {
innerProps: {
className,
onClick,
onTouchEnd,
},
innerProps,
} = props;
const [showTooltip, setShowTooltip] = React.useState(false);
const { t } = useTranslation('general');
return (
<button
{...innerProps}
type="button"
className={[
baseClass,
className,
].filter(Boolean).join(' ')}
className={baseClass}
onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)}
onTouchEnd={onTouchEnd}
onClick={(e) => {
setShowTooltip(false);
onClick(e);
innerProps.onClick(e);
}}
aria-label={t('remove')}
>

View File

@@ -0,0 +1,7 @@
@import '../../../../scss/styles.scss';
.react-select--single-value {
.rs__single-value {
color: currentColor;
}
}

View File

@@ -1,24 +1,20 @@
import React from 'react';
import { components as SelectComponents, SingleValueProps } from 'react-select';
import { Option } from '../types';
import './index.scss';
const baseClass = 'react-select--single-value';
export const SingleValue: React.FC<SingleValueProps<Option>> = (props) => {
const {
children,
className,
} = props;
return (
<SelectComponents.SingleValue
{...props}
className={[
baseClass,
className,
].filter(Boolean).join(' ')}
>
{children}
</SelectComponents.SingleValue>
<div className={baseClass}>
<SelectComponents.SingleValue {...props}>
{children}
</SelectComponents.SingleValue>
</div>
);
};

View File

@@ -12,7 +12,6 @@
margin: 0;
padding-top: 0;
padding-bottom: 0;
color: currentColor;
}
&--is-multi {

View File

@@ -8,12 +8,12 @@ const baseClass = 'value-container';
export const ValueContainer: React.FC<ValueContainerProps<Option, any>> = (props) => {
const {
customProps,
selectProps,
} = props;
return (
<div
ref={customProps?.droppableRef}
ref={selectProps.selectProps.droppableRef}
className={baseClass}
>
<SelectComponents.ValueContainer {...props} />

View File

@@ -22,13 +22,13 @@
display: none;
}
.rs__input-container {
color: var(--theme-elevation-1000);
}
.rs__input {
font-family: var(--font-body);
width: 10px;
color: var(--theme-elevation-1000);
input {
font-family: var(--font-body);
width: 10px;
}
}
.rs__menu {

View File

@@ -2,7 +2,7 @@ import React from 'react';
import Select from 'react-select';
import { useTranslation } from 'react-i18next';
import { arrayMove } from '@dnd-kit/sortable';
import { Props as ReactSelectAdapterProps } from './types';
import { Props } from './types';
import Chevron from '../../icons/Chevron';
import { getTranslation } from '../../../../utilities/getTranslation';
import { SingleValue } from './SingleValue';
@@ -16,7 +16,7 @@ import DraggableSortable from '../DraggableSortable';
import './index.scss';
const SelectAdapter: React.FC<ReactSelectAdapterProps> = (props) => {
const SelectAdapter: React.FC<Props> = (props) => {
const { t, i18n } = useTranslation();
const {
@@ -33,6 +33,7 @@ const SelectAdapter: React.FC<ReactSelectAdapterProps> = (props) => {
isLoading,
onMenuOpen,
components,
selectProps,
} = props;
const classes = [
@@ -49,7 +50,7 @@ const SelectAdapter: React.FC<ReactSelectAdapterProps> = (props) => {
{...props}
value={value}
onChange={onChange}
isDisabled={disabled}
disabled={disabled ? 'disabled' : undefined}
className={classes}
classNamePrefix="rs"
options={options}
@@ -58,6 +59,9 @@ const SelectAdapter: React.FC<ReactSelectAdapterProps> = (props) => {
filterOption={filterOption}
onMenuOpen={onMenuOpen}
menuPlacement="auto"
selectProps={{
...selectProps,
}}
components={{
ValueContainer,
SingleValue,
@@ -73,7 +77,7 @@ const SelectAdapter: React.FC<ReactSelectAdapterProps> = (props) => {
);
};
const SortableSelect: React.FC<ReactSelectAdapterProps> = (props) => {
const SortableSelect: React.FC<Props> = (props) => {
const {
onChange,
value,
@@ -99,7 +103,7 @@ const SortableSelect: React.FC<ReactSelectAdapterProps> = (props) => {
);
};
const ReactSelect: React.FC<ReactSelectAdapterProps> = (props) => {
const ReactSelect: React.FC<Props> = (props) => {
const {
isMulti,
isSortable,

View File

@@ -1,34 +1,4 @@
import { CommonProps, GroupBase, Props as ReactSelectStateManagerProps } from 'react-select';
import { DocumentDrawerProps } from '../DocumentDrawer/types';
type CustomSelectProps = {
disableMouseDown?: boolean
disableKeyDown?: boolean
droppableRef?: React.RefObject<HTMLDivElement>
setDrawerIsOpen?: (isOpen: boolean) => void
onSave?: DocumentDrawerProps['onSave']
draggableProps?: any
}
// augment the types for the `Select` component from `react-select`
// this is to include the `selectProps` prop at the top-level `Select` component
declare module 'react-select/dist/declarations/src/Select' {
export interface Props<
Option,
IsMulti extends boolean,
Group extends GroupBase<Option>
> {
customProps?: CustomSelectProps
}
}
// augment the types for the `CommonPropsAndClassName` from `react-select`
// this will include the `selectProps` prop to every `react-select` component automatically
declare module 'react-select/dist/declarations/src' {
export interface CommonPropsAndClassName<Option, IsMulti extends boolean, Group extends GroupBase<Option>> extends CommonProps<Option, IsMulti, Group> {
customProps?: ReactSelectStateManagerProps<Option, IsMulti, Group> & CustomSelectProps
}
}
import { Ref } from 'react';
export type Option = {
[key: string]: unknown
@@ -52,6 +22,7 @@ export type Props = {
isLoading?: boolean
isOptionSelected?: any
isSortable?: boolean,
isDisabled?: boolean
onInputChange?: (val: string) => void
onMenuScrollToBottom?: () => void
placeholder?: string
@@ -64,5 +35,9 @@ export type Props = {
components?: {
[key: string]: React.FC<any>
}
selectProps?: CustomSelectProps
selectProps?: {
disableMouseDown?: boolean
disableKeyDown?: boolean
[key: string]: unknown
}
}

View File

@@ -11,21 +11,12 @@ import { getTranslation } from '../../../../../../utilities/getTranslation';
import Tooltip from '../../../../elements/Tooltip';
import { useDocumentDrawer } from '../../../../elements/DocumentDrawer';
import { useConfig } from '../../../../utilities/Config';
import { Props as EditViewProps } from '../../../../views/collections/Edit/types';
import { Value } from '../types';
import './index.scss';
const baseClass = 'relationship-add-new';
export const AddNewRelation: React.FC<Props> = ({
path,
hasMany,
relationTo,
value,
setValue,
dispatchOptions,
}) => {
export const AddNewRelation: React.FC<Props> = ({ path, hasMany, relationTo, value, setValue, dispatchOptions }) => {
const relatedCollections = useRelatedCollections(relationTo);
const { permissions } = useAuth();
const [show, setShow] = useState(false);
@@ -45,43 +36,30 @@ export const AddNewRelation: React.FC<Props> = ({
collectionSlug: collectionConfig?.slug,
});
const onSave: EditViewProps['onSave'] = useCallback(({
operation,
doc,
}) => {
if (operation === 'create') {
const newValue: Value = Array.isArray(relationTo) ? {
relationTo: collectionConfig.slug,
value: doc.id,
} : doc.id;
const onSave = useCallback((json) => {
const newValue = Array.isArray(relationTo) ? {
relationTo: collectionConfig.slug,
value: json.doc.id,
} : json.doc.id;
// ensure the value is not already in the array
const isNewValue = Array.isArray(relationTo) && Array.isArray(value)
? !value.some((v) => v && typeof v === 'object' && v.value === doc.id)
: value !== doc.id;
dispatchOptions({
type: 'ADD',
collection: collectionConfig,
docs: [
json.doc,
],
sort: true,
i18n,
config,
});
if (isNewValue) {
dispatchOptions({
type: 'ADD',
collection: collectionConfig,
docs: [
doc,
],
sort: true,
i18n,
config,
});
if (hasMany) {
setValue([...(Array.isArray(value) ? value : []), newValue]);
} else {
setValue(newValue);
}
}
setSelectedCollection(undefined);
if (hasMany) {
setValue([...(Array.isArray(value) ? value : []), newValue]);
} else {
setValue(newValue);
}
setSelectedCollection(undefined);
}, [relationTo, collectionConfig, dispatchOptions, i18n, hasMany, setValue, value, config]);
const onPopopToggle = useCallback((state) => {

View File

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

View File

@@ -381,7 +381,7 @@ const Relationship: React.FC<Props> = (props) => {
{!errorLoading && (
<div className={`${baseClass}__wrap`}>
<ReactSelect
disabled={readOnly || formProcessing}
isDisabled={readOnly}
onInputChange={(newSearch) => handleInputChange(newSearch, value)}
onChange={!readOnly ? (selected) => {
if (selected === null) {
@@ -417,6 +417,7 @@ const Relationship: React.FC<Props> = (props) => {
}}
value={valueToRender ?? null}
showError={showError}
disabled={formProcessing}
options={options}
isMulti={hasMany}
isSortable={isSortable}
@@ -450,7 +451,7 @@ const Relationship: React.FC<Props> = (props) => {
/>
{!readOnly && allowCreate && (
<AddNewRelation
{...{ path: pathOrName, hasMany, relationTo, value, setValue, dispatchOptions, options }}
{...{ path: pathOrName, hasMany, relationTo, value, setValue, dispatchOptions }}
/>
)}
</div>

View File

@@ -17,11 +17,11 @@ export const MultiValueLabel: React.FC<MultiValueProps<Option>> = (props) => {
relationTo,
label,
},
customProps: {
selectProps: {
setDrawerIsOpen,
draggableProps,
onSave,
} = {},
},
} = props;
const { permissions } = useAuth();
@@ -54,8 +54,6 @@ export const MultiValueLabel: React.FC<MultiValueProps<Option>> = (props) => {
<DocumentDrawerToggler
className={`${baseClass}__drawer-toggler`}
aria-label={`Edit ${label}`}
onTouchEnd={(e) => e.stopPropagation()} // prevents react-select dropdown from opening
onMouseDown={(e) => e.stopPropagation()} // prevents react-select dropdown from opening
onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)}
onClick={() => setShowTooltip(false)}

View File

@@ -1,8 +1,11 @@
@import '../../../../../../scss/styles.scss';
.relationship--single-value {
&__label-text {
display: flex;
align-items: center;
.rs__single-value {
color: currentColor;
max-width: unset;
display: flex;
align-items: center;

View File

@@ -18,10 +18,12 @@ export const SingleValue: React.FC<SingleValueProps<Option>> = (props) => {
label,
},
children,
customProps: {
setDrawerIsOpen,
onSave,
} = {},
selectProps: {
selectProps: {
setDrawerIsOpen,
onSave,
},
},
} = props;
const [showTooltip, setShowTooltip] = useState(false);
@@ -39,12 +41,9 @@ export const SingleValue: React.FC<SingleValueProps<Option>> = (props) => {
}, [isDrawerOpen, setDrawerIsOpen]);
return (
<SelectComponents.SingleValue
{...props}
className={baseClass}
>
<div className={baseClass}>
<div className={`${baseClass}__label`}>
<div className={`${baseClass}__label-text`}>
<SelectComponents.SingleValue {...props}>
<div className={`${baseClass}__text`}>
{children}
</div>
@@ -53,7 +52,6 @@ export const SingleValue: React.FC<SingleValueProps<Option>> = (props) => {
<DocumentDrawerToggler
className={`${baseClass}__drawer-toggler`}
aria-label={t('editLabel', { label })}
onTouchEnd={(e) => e.stopPropagation()} // prevents react-select dropdown from opening
onMouseDown={(e) => e.stopPropagation()} // prevents react-select dropdown from opening
onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)}
@@ -69,11 +67,11 @@ export const SingleValue: React.FC<SingleValueProps<Option>> = (props) => {
</DocumentDrawerToggler>
</Fragment>
)}
</div>
</SelectComponents.SingleValue>
</div>
{relationTo && hasReadPermission && (
<DocumentDrawer onSave={onSave} />
)}
</SelectComponents.SingleValue>
</div>
);
};

View File

@@ -272,7 +272,7 @@ const RichText: React.FC<Props> = (props) => {
required={required}
/>
<Slate
key={JSON.stringify({ initialValue, path })}
key={JSON.stringify(initialValue)}
editor={editor}
value={valueToRender as any[]}
onChange={handleChange}

View File

@@ -99,7 +99,7 @@ const SelectInput: React.FC<SelectInputProps> = (props) => {
onChange={onChange}
value={valueToRender as Option}
showError={showError}
disabled={readOnly}
isDisabled={readOnly}
options={options.map((option) => ({ ...option, label: getTranslation(option.label, i18n) }))}
isMulti={hasMany}
isSortable={isSortable}

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