Compare commits

..

14 Commits

Author SHA1 Message Date
Elliot DeNolf
6b82196f01 chore(release): v3.0.0-beta.100 [skip ci] 2024-09-06 15:25:41 -04:00
Tylan Davis
ead12c8a49 fix(ui, next): adjust modal alignment and padding (#7931)
## Description

Updates styling on modals and auth forms for more consistent spacing and
alignment.

- [x] I have read and understand the
[CONTRIBUTING.md](https://github.com/payloadcms/payload/blob/main/CONTRIBUTING.md)
document in this repository.

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## Checklist:

- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] Existing test suite passes locally with my changes
- [ ] I have made corresponding changes to the documentation
2024-09-06 14:54:39 -04:00
Jacob Fletcher
6253ec5d1a fix(ui): optimizes the relationship field by sharing a single document drawer across all values (#8094)
## Description

Currently, the relationship field's _value(s)_ each render and controls
its own document drawer. This has led to `hasMany` relationships
processing a potentially large number of drawers unnecessarily. But the
real problem is when attempting to perform side-effects as a result of a
drawer action. Currently, when you change the value of a relationship
field, all drawers within are (rightfully) unmounted because the
component representing the value was itself unmounted. This meant that
you could not update the title of a document, for example, then update
the underlying field's value, without also closing the document drawer
outright. This is needed in order to support things like creating and
duplicating documents within document drawers (#7679).

- [x] I have read and understand the
[CONTRIBUTING.md](https://github.com/payloadcms/payload/blob/main/CONTRIBUTING.md)
document in this repository.

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## Checklist:

- [x] Existing test suite passes locally with my changes
2024-09-06 14:00:53 -04:00
Jacob Fletcher
f9ae56ec88 fix(ui): handles falsey relationship options on reset (#8095) 2024-09-06 12:55:09 -04:00
Sasha
0688c2b79d fix(db-postgres): sanitize tab/group path for table name (#8009)
## Description

Fixes https://github.com/payloadcms/payload/issues/7109

Example of table structures that lead to the problem with camelCased
group / tab names.
`group_field_array_localized` - `groupField` -> `array` (has a localized
field inside)
`group_field_array_nested_array` - `groupField` -> `array` ->
`nestedArray`

<!-- Please include a summary of the pull request and any related issues
it fixes. Please also include relevant motivation and context. -->

- [x] I have read and understand the
[CONTRIBUTING.md](https://github.com/payloadcms/payload/blob/main/CONTRIBUTING.md)
document in this repository.

## Type of change

<!-- Please delete options that are not relevant. -->

- [x] Bug fix (non-breaking change which fixes an issue)

## Checklist:

- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] Existing test suite passes locally with my changes
2024-09-06 11:43:47 -04:00
Sasha
c6246618ba fix(cpa): detect package manager from command execution environment (#8087)
Previously, on some machines this command:
`pnpx create-payload-app@beta app` created a project using `npm`,
instead of `pnpm`, the same with `yarn`.

Also, the way we detected the package manager was always prioritizing
`pnpm`, even if they executed the command with `yarn` / `npm`. Now we
are relying only on from which package manager user executed
`create-payload-app`.

The code for detection is grabbed from create-next-app
https://github.com/vercel/next.js/blob/canary/packages/create-next-app/helpers/get-pkg-manager.ts
2024-09-06 08:57:20 -04:00
Alexander
b69826a81e feat(cpa): add support for bun package manager in v3 installer (#7709)
Adds support for bun package manger in v3, enabled with `--use-bun`
flag.

Related: #6932 (for v2)
2024-09-05 23:50:03 -04:00
Paul
e80da7cb75 chore: add jsdocs for authentication types and add missing config to docs (#8082) 2024-09-06 00:04:13 +00:00
Francisco Lourenço
6f512b6ca8 docs: fix TextFieldProps in client field component example (#8080)
## Description

Without using `React.FC<>`, the type needs to be placed on the right
side of the props object.

- [x] I have read and understand the
[CONTRIBUTING.md](https://github.com/payloadcms/payload/blob/main/CONTRIBUTING.md)
document in this repository.

## Type of change

<!-- Please delete options that are not relevant. -->

- [x] Chore (non-breaking change which does not add functionality)

## Checklist:

- [ ] ~I have added tests that prove my fix is effective or that my
feature works~
- [ ] ~Existing test suite passes locally with my changes~
- [x] I have made corresponding changes to the documentation
2024-09-05 15:41:48 -06:00
Elliot DeNolf
22ee8bf383 chore(release): v3.0.0-beta.99 [skip ci] 2024-09-05 12:38:08 -04:00
Jacob Fletcher
308fad8a7a fix(ui): significantly optimizes relationship field (#8063)
## Description

Reduces the number of client-side requests made by the relationship
field component, and fixes the visual "blink" of the field's value on
initial load. Does so through a new `useIgnoredEffect` hook that allows
this component's effects to be precisely triggered based on whether a
only _subset_ of its dependencies have changed, which looks something
like this:

```tsx
// ...
useIgnoredEffect(() => {
  // Do something
}, [deps], [ignoredDeps])
```

"Ignored deps" are still treated as normal dependencies of the
underlying `useEffect` hook, but they do not cause the provided function
to execute. This is useful if you have a list of dependencies that
change often, but need to scope your effect's logic to explicit
dependencies within that list. This is a typical pattern in React using
refs, just standardized within a reusable hook.

This significantly reduces the overall number of re-renders and
duplicative API requests within the relationship field because the
`useEffect` hooks that control the fetching of these related documents
were running unnecessarily often. In the future, we really ought to
leverage the `RelationshipProvider` used in the List View so that we can
also reduce the number of duplicative requests across _unrelated fields_
within the same document.

Before:


https://github.com/user-attachments/assets/ece7c85e-20fb-49f6-b393-c5e9d5176192

After:


https://github.com/user-attachments/assets/9f0a871e-f10f-4fd6-a58b-8146ece288c4

- [x] I have read and understand the
[CONTRIBUTING.md](https://github.com/payloadcms/payload/blob/main/CONTRIBUTING.md)
document in this repository.

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## Checklist:

- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] Existing test suite passes locally with my changes
2024-09-04 21:37:00 -04:00
Jessica Chowdhury
6427b7eb29 fix: only show restore as draft option when drafts enabled (#8066)
## Description

In version comparison view, the `Restore as draft` button should only be
visible when `versions.drafts: true`.

Before:
<img width="1414" alt="Screenshot 2024-09-04 at 3 33 21 PM"
src="https://github.com/user-attachments/assets/1f96d804-46d7-443a-99ea-7b6481839b47">

After:
<img width="1307" alt="Screenshot 2024-09-04 at 3 38 42 PM"
src="https://github.com/user-attachments/assets/d2621ddd-2b14-4dab-936c-29a5521444de">


- [X] I have read and understand the
[CONTRIBUTING.md](https://github.com/payloadcms/payload/blob/main/CONTRIBUTING.md)
document in this repository.

## Type of change

- [X] Bug fix (non-breaking change which fixes an issue)

## Checklist:

- [ ] I have added tests that prove my fix is effective or that my
feature works
- [X] Existing test suite passes locally with my changes
- [ ] I have made corresponding changes to the documentation
2024-09-04 19:54:34 +00:00
Sasha
3a657847f2 fix(db-postgres): query hasMany text/number in array/blocks (#8003)
## Description

Fixes https://github.com/payloadcms/payload/issues/7671

- [x] I have read and understand the
[CONTRIBUTING.md](https://github.com/payloadcms/payload/blob/main/CONTRIBUTING.md)
document in this repository.

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)
## Checklist:

- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] Existing test suite passes locally with my changes
2024-09-04 11:53:43 -04:00
Elliot DeNolf
8212c0d65f chore(eslint): silence some warnings that always get auto-fixed 2024-09-04 11:26:36 -04:00
94 changed files with 1548 additions and 502 deletions

11
.vscode/settings.json vendored
View File

@@ -31,8 +31,15 @@
"editor.formatOnSave": true
},
"editor.formatOnSaveMode": "file",
// All ESLint rules to 'warn' to differentate from TypeScript's 'error' level
"eslint.rules.customizations": [{ "rule": "*", "severity": "warn" }],
"eslint.rules.customizations": [
// Defaultt all ESLint errors to 'warn' to differentate from TypeScript's 'error' level
{ "rule": "*", "severity": "warn" },
// Silence some warnings that will get auto-fixed
{ "rule": "perfectionist/*", "severity": "off", "fixable": true },
{ "rule": "curly", "severity": "off", "fixable": true },
{ "rule": "object-shorthand", "severity": "off", "fixable": true }
],
"typescript.tsdk": "node_modules/typescript/lib",
// Load .git-blame-ignore-revs file
"gitlens.advanced.blame.customArguments": ["--ignore-revs-file", ".git-blame-ignore-revs"],

View File

@@ -438,7 +438,7 @@ All [Field Components](./fields) automatically receive their respective Client F
import React from 'react'
import type { TextFieldProps } from 'payload'
export const MyClientFieldComponent: TextFieldProps = ({ field: { name } }) => {
export const MyClientFieldComponent = ({ field: { name } }: TextFieldProps) => {
return (
<p>
{`This field's name is ${name}`}

View File

@@ -85,6 +85,7 @@ The following options are available:
| **`lockTime`** | Set the time (in milliseconds) that a user should be locked out if they fail authentication more times than `maxLoginAttempts` allows for. |
| **`loginWithUsername`** | Ability to allow users to login with username/password. [More](/docs/authentication/overview#login-with-username) |
| **`maxLoginAttempts`** | Only allow a user to attempt logging in X amount of times. Automatically locks out a user from authenticating if this limit is passed. Set to `0` to disable. |
| **`removeTokenFromResponses`** | Set to true if you want to remove the token from the returned authentication API responses such as login or refresh. |
| **`strategies`** | Advanced - an array of custom authentification strategies to extend this collection's authentication with. [More details](./custom-strategies). |
| **`tokenExpiration`** | How long (in seconds) to keep the user logged in. JWTs and HTTP-only cookies will both expire at the same time. |
| **`useAPIKey`** | Payload Authentication provides for API keys to be set on each user within an Authentication-enabled Collection. [More details](./api-keys). |

View File

@@ -1,6 +1,6 @@
{
"name": "payload-monorepo",
"version": "3.0.0-beta.98",
"version": "3.0.0-beta.100",
"private": true,
"type": "module",
"scripts": {

View File

@@ -1,6 +1,6 @@
{
"name": "create-payload-app",
"version": "3.0.0-beta.98",
"version": "3.0.0-beta.100",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",

View File

@@ -37,6 +37,8 @@ async function installDeps(args: {
installCmd = 'yarn'
} else if (packageManager === 'pnpm') {
installCmd = 'pnpm install'
} else if (packageManager === 'bun') {
installCmd = 'bun install'
}
try {

View File

@@ -1,12 +1,8 @@
import execa from 'execa'
import fse from 'fs-extra'
import type { CliArgs, PackageManager } from '../types.js'
export async function getPackageManager(args: {
cliArgs?: CliArgs
projectDir: string
}): Promise<PackageManager> {
export function getPackageManager(args: { cliArgs?: CliArgs; projectDir: string }): PackageManager {
const { cliArgs, projectDir } = args
try {
@@ -18,15 +14,11 @@ export async function getPackageManager(args: {
detected = 'yarn'
} else if (cliArgs?.['--use-npm'] || fse.existsSync(`${projectDir}/package-lock.json`)) {
detected = 'npm'
} else if (cliArgs?.['--use-bun'] || fse.existsSync(`${projectDir}/bun.lockb`)) {
detected = 'bun'
} else {
// Otherwise check for existing commands
if (await commandExists('pnpm')) {
detected = 'pnpm'
} else if (await commandExists('yarn')) {
detected = 'yarn'
} else {
detected = 'npm'
}
// Otherwise check the execution environment
detected = getEnvironmentPackageManager()
}
return detected
@@ -35,11 +27,20 @@ export async function getPackageManager(args: {
}
}
async function commandExists(command: string): Promise<boolean> {
try {
await execa.command(`command -v ${command}`)
return true
} catch {
return false
function getEnvironmentPackageManager(): PackageManager {
const userAgent = process.env.npm_config_user_agent || ''
if (userAgent.startsWith('yarn')) {
return 'yarn'
}
if (userAgent.startsWith('pnpm')) {
return 'pnpm'
}
if (userAgent.startsWith('bun')) {
return 'bun'
}
return 'npm'
}

View File

@@ -49,6 +49,7 @@ export class Main {
// Package manager
'--no-deps': Boolean,
'--use-bun': Boolean,
'--use-npm': Boolean,
'--use-pnpm': Boolean,
'--use-yarn': Boolean,
@@ -132,7 +133,7 @@ export class Main {
? path.dirname(nextConfigPath)
: path.resolve(process.cwd(), slugify(projectName))
const packageManager = await getPackageManager({ cliArgs: this.args, projectDir })
const packageManager = getPackageManager({ cliArgs: this.args, projectDir })
if (nextConfigPath) {
p.log.step(

View File

@@ -16,6 +16,7 @@ export interface Args extends arg.Spec {
'--secret': StringConstructor
'--template': StringConstructor
'--template-branch': StringConstructor
'--use-bun': BooleanConstructor
'--use-npm': BooleanConstructor
'--use-pnpm': BooleanConstructor
'--use-yarn': BooleanConstructor

View File

@@ -40,6 +40,7 @@ export function helpMessage(): void {
--use-npm Use npm to install dependencies
--use-yarn Use yarn to install dependencies
--use-pnpm Use pnpm to install dependencies
--use-bun Use bun to install dependencies (experimental)
--no-deps Do not install any dependencies
-h Show help
`)

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/db-mongodb",
"version": "3.0.0-beta.98",
"version": "3.0.0-beta.100",
"description": "The officially supported MongoDB database adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/db-postgres",
"version": "3.0.0-beta.98",
"version": "3.0.0-beta.100",
"description": "The officially supported Postgres database adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/db-sqlite",
"version": "3.0.0-beta.98",
"version": "3.0.0-beta.100",
"description": "The officially supported SQLite database adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/db-vercel-postgres",
"version": "3.0.0-beta.98",
"version": "3.0.0-beta.100",
"description": "Vercel Postgres adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/drizzle",
"version": "3.0.0-beta.98",
"version": "3.0.0-beta.100",
"description": "A library of shared functions used by different payload database adapters",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -80,6 +80,7 @@ export const buildFindManyArgs = ({
depth,
fields,
path: '',
tablePath: '',
topLevelArgs: result,
topLevelTableName: tableName,
})

View File

@@ -14,6 +14,7 @@ type TraverseFieldArgs = {
depth?: number
fields: Field[]
path: string
tablePath: string
topLevelArgs: Record<string, unknown>
topLevelTableName: string
}
@@ -26,6 +27,7 @@ export const traverseFields = ({
depth,
fields,
path,
tablePath,
topLevelArgs,
topLevelTableName,
}: TraverseFieldArgs) => {
@@ -53,6 +55,7 @@ export const traverseFields = ({
depth,
fields: field.fields,
path,
tablePath,
topLevelArgs,
topLevelTableName,
})
@@ -63,6 +66,7 @@ export const traverseFields = ({
if (field.type === 'tabs') {
field.tabs.forEach((tab) => {
const tabPath = tabHasName(tab) ? `${path}${tab.name}_` : path
const tabTablePath = tabHasName(tab) ? `${tablePath}${toSnakeCase(tab.name)}_` : tablePath
traverseFields({
_locales,
@@ -72,6 +76,7 @@ export const traverseFields = ({
depth,
fields: tab.fields,
path: tabPath,
tablePath: tabTablePath,
topLevelArgs,
topLevelTableName,
})
@@ -92,7 +97,7 @@ export const traverseFields = ({
}
const arrayTableName = adapter.tableNameMap.get(
`${currentTableName}_${path}${toSnakeCase(field.name)}`,
`${currentTableName}_${tablePath}${toSnakeCase(field.name)}`,
)
const arrayTableNameWithLocales = `${arrayTableName}${adapter.localesSuffix}`
@@ -116,6 +121,7 @@ export const traverseFields = ({
depth,
fields: field.fields,
path: '',
tablePath: '',
topLevelArgs,
topLevelTableName,
})
@@ -172,6 +178,7 @@ export const traverseFields = ({
depth,
fields: block.fields,
path: '',
tablePath: '',
topLevelArgs,
topLevelTableName,
})
@@ -180,7 +187,7 @@ export const traverseFields = ({
break
case 'group':
case 'group': {
traverseFields({
_locales,
adapter,
@@ -189,11 +196,13 @@ export const traverseFields = ({
depth,
fields: field.fields,
path: `${path}${field.name}_`,
tablePath: `${tablePath}${toSnakeCase(field.name)}_`,
topLevelArgs,
topLevelTableName,
})
break
}
default: {
break

View File

@@ -257,10 +257,10 @@ export const getTableColumnFromPath = ({
tableType = 'numbers'
columnName = 'number'
}
newTableName = `${tableName}_${tableType}`
newTableName = `${rootTableName}_${tableType}`
const joinConstraints = [
eq(adapter.tables[tableName].id, adapter.tables[newTableName].parent),
eq(adapter.tables[newTableName].path, `${constraintPath}${field.name}`),
eq(adapter.tables[rootTableName].id, adapter.tables[newTableName].parent),
like(adapter.tables[newTableName].path, `${constraintPath}${field.name}`),
]
if (locale && field.localized && adapter.payload.config.localization) {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/email-nodemailer",
"version": "3.0.0-beta.98",
"version": "3.0.0-beta.100",
"description": "Payload Nodemailer Email Adapter",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/email-resend",
"version": "3.0.0-beta.98",
"version": "3.0.0-beta.100",
"description": "Payload Resend Email Adapter",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/graphql",
"version": "3.0.0-beta.98",
"version": "3.0.0-beta.100",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/live-preview-react",
"version": "3.0.0-beta.98",
"version": "3.0.0-beta.100",
"description": "The official React SDK for Payload Live Preview",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/live-preview-vue",
"version": "3.0.0-beta.98",
"version": "3.0.0-beta.100",
"description": "The official Vue SDK for Payload Live Preview",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/live-preview",
"version": "3.0.0-beta.98",
"version": "3.0.0-beta.100",
"description": "The official live preview JavaScript SDK for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/next",
"version": "3.0.0-beta.98",
"version": "3.0.0-beta.100",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",

View File

@@ -12,14 +12,14 @@
position: relative;
display: flex;
flex-direction: column;
gap: var(--base);
gap: base(0.8);
padding: base(2);
}
&__content {
display: flex;
flex-direction: column;
gap: var(--base);
gap: base(0.4);
> * {
margin: 0;
@@ -28,7 +28,7 @@
&__controls {
display: flex;
gap: var(--base);
gap: base(0.4);
.btn {
margin: 0;

View File

@@ -14,14 +14,14 @@
&--width-normal {
.template-minimal__wrap {
max-width: 500px;
max-width: base(24);
width: 100%;
}
}
&--width-wide {
.template-minimal__wrap {
max-width: 1024px;
max-width: base(48);
width: 100%;
}
}

View File

@@ -1,6 +1,16 @@
@import '../../scss/styles.scss';
.create-first-user {
display: flex;
flex-direction: column;
gap: base(0.4);
> form > .field-type {
margin-bottom: var(--base);
& .form-submit {
margin: 0;
}
}
}

View File

@@ -4,6 +4,10 @@ import { formatAdminURL } from '@payloadcms/ui/shared'
import LinkImport from 'next/link.js'
import React, { Fragment, useEffect } from 'react'
import './index.scss'
const baseClass = 'logout'
const Link = (LinkImport.default || LinkImport) as unknown as typeof LinkImport.default
export const LogoutClient: React.FC<{
@@ -26,7 +30,7 @@ export const LogoutClient: React.FC<{
if (isLoggingOut) {
return (
<Fragment>
<div className={`${baseClass}__wrap`}>
{inactivity && <h2>{t('authentication:loggedOutInactivity')}</h2>}
{!inactivity && <h2>{t('authentication:loggedOutSuccessfully')}</h2>}
<Button
@@ -43,7 +47,7 @@ export const LogoutClient: React.FC<{
>
{t('authentication:logBackIn')}
</Button>
</Fragment>
</div>
)
}

View File

@@ -1,19 +1,22 @@
@import '../../scss/styles.scss';
.logout {
display: flex;
flex-direction: column;
align-items: center;
flex-wrap: wrap;
min-height: 100vh;
&__wrap {
& > *:first-child {
margin-top: 0;
}
z-index: 1;
position: relative;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: base(0.8);
width: 100%;
max-width: base(36);
& > *:last-child {
margin-bottom: 0;
}
.btn {
& > * {
margin: 0;
}
}

View File

@@ -25,7 +25,7 @@ export const LogoutView: React.FC<
} = initPageResult
return (
<div className={`${baseClass}__wrap`}>
<div className={`${baseClass}`}>
<LogoutClient
adminRoute={adminRoute}
inactivity={inactivity}

View File

@@ -38,8 +38,10 @@ export const NotFoundClient: React.FC<{
.join(' ')}
>
<Gutter className={`${baseClass}__wrap`}>
<h1>{t('general:nothingFound')}</h1>
<p>{t('general:sorryNotFound')}</p>
<div className={`${baseClass}__content`}>
<h1>{t('general:nothingFound')}</h1>
<p>{t('general:sorryNotFound')}</p>
</div>
<Button
className={`${baseClass}__button`}
el="link"

View File

@@ -13,6 +13,24 @@
}
}
&__wrap {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: base(0.8);
max-width: base(36);
}
&__content {
display: flex;
flex-direction: column;
gap: base(0.4);
> * {
margin: 0;
}
}
&__button {
margin: 0;
}

View File

@@ -75,21 +75,23 @@ export const ResetPasswordClient: React.FC<Args> = ({ token }) => {
method="POST"
onSuccess={onSuccess}
>
<PasswordField
field={{
name: 'password',
label: i18n.t('authentication:newPassword'),
required: true,
}}
/>
<ConfirmPasswordField />
<HiddenField
field={{
name: 'token',
}}
forceUsePathFromProps
value={token}
/>
<div className={'inputWrap'}>
<PasswordField
field={{
name: 'password',
label: i18n.t('authentication:newPassword'),
required: true,
}}
/>
<ConfirmPasswordField />
<HiddenField
field={{
name: 'token',
}}
forceUsePathFromProps
value={token}
/>
</div>
<FormSubmit size="large">{i18n.t('authentication:resetPassword')}</FormSubmit>
</Form>
)

View File

@@ -1,5 +1,31 @@
@import '../../scss/styles.scss';
.reset-password {
form > .field-type {
margin-bottom: var(--base);
&__wrap {
z-index: 1;
position: relative;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: base(0.8);
max-width: base(36);
& > form {
width: 100%;
& > .inputWrap {
display: flex;
flex-direction: column;
gap: base(0.8);
> * {
margin: 0;
}
}
}
& > .btn {
margin: 0;
}
}
}

View File

@@ -1,11 +1,10 @@
import type { AdminViewProps } from 'payload'
import { Button, Translation } from '@payloadcms/ui'
import { formatAdminURL } from '@payloadcms/ui/shared'
import { Button } from '@payloadcms/ui'
import { formatAdminURL, Translation } from '@payloadcms/ui/shared'
import LinkImport from 'next/link.js'
import React from 'react'
import { MinimalTemplate } from '../../templates/Minimal/index.js'
import { ResetPasswordClient } from './index.client.js'
import './index.scss'
@@ -37,42 +36,37 @@ export const ResetPassword: React.FC<AdminViewProps> = ({ initPageResult, params
if (user) {
return (
<MinimalTemplate className={resetPasswordBaseClass}>
<div className={`${resetPasswordBaseClass}__wrap`}>
<h1>{i18n.t('authentication:alreadyLoggedIn')}</h1>
<p>
<Translation
elements={{
'0': ({ children }) => (
<Link
href={formatAdminURL({
adminRoute,
path: accountRoute,
})}
>
{children}
</Link>
),
}}
i18nKey="authentication:loggedInChangePassword"
t={i18n.t}
/>
</p>
<br />
<Button buttonStyle="secondary" el="link" Link={Link} to={adminRoute}>
{i18n.t('general:backToDashboard')}
</Button>
</div>
</MinimalTemplate>
<div className={`${resetPasswordBaseClass}__wrap`}>
<h1>{i18n.t('authentication:alreadyLoggedIn')}</h1>
<p>
<Translation
elements={{
'0': ({ children }) => (
<Link
href={formatAdminURL({
adminRoute,
path: accountRoute,
})}
>
{children}
</Link>
),
}}
i18nKey="authentication:loggedInChangePassword"
t={i18n.t}
/>
</p>
<Button buttonStyle="secondary" el="link" Link={Link} size="large" to={adminRoute}>
{i18n.t('general:backToDashboard')}
</Button>
</div>
)
}
return (
<MinimalTemplate className={resetPasswordBaseClass}>
<div className={`${resetPasswordBaseClass}__wrap`}>
<h1>{i18n.t('authentication:resetPassword')}</h1>
<ResetPasswordClient token={token} />
</div>
</MinimalTemplate>
<div className={`${resetPasswordBaseClass}__wrap`}>
<h1>{i18n.t('authentication:resetPassword')}</h1>
<ResetPasswordClient token={token} />
</div>
)
}

View File

@@ -38,24 +38,40 @@
@include blur-bg;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
&__toggle {
@extend %btn-reset;
}
}
.btn {
[dir='ltr'] & {
margin-right: var(--base);
}
[dir='rtl'] & {
margin-left: var(--base);
}
&__wrapper {
z-index: 1;
position: relative;
display: flex;
flex-direction: column;
gap: base(0.8);
padding: base(2);
max-width: base(36);
}
&__content {
display: flex;
flex-direction: column;
gap: base(0.4);
> * {
margin: 0;
}
}
&__modal-template {
position: relative;
z-index: 1;
&__controls {
display: flex;
gap: base(0.4);
.btn {
margin: 0;
}
}
}

View File

@@ -18,7 +18,6 @@ import { toast } from 'sonner'
import type { Props } from './types.js'
import { MinimalTemplate } from '../../../templates/Minimal/index.js'
import './index.scss'
const baseClass = 'restore-version'
@@ -36,11 +35,14 @@ const Restore: React.FC<Props> = ({
}) => {
const {
config: {
collections,
routes: { admin: adminRoute, api: apiRoute },
serverURL,
},
} = useConfig()
const collectionConfig = collections.find((collection) => collection.slug === collectionSlug)
const { toggleModal } = useModal()
const [processing, setProcessing] = useState(false)
const router = useRouter()
@@ -54,7 +56,8 @@ const Restore: React.FC<Props> = ({
let fetchURL = `${serverURL}${apiRoute}`
let redirectURL: string
const canRestoreAsDraft = status !== 'draft'
const canRestoreAsDraft = status !== 'draft' && collectionConfig?.versions?.drafts
if (collectionSlug) {
fetchURL = `${fetchURL}/${collectionSlug}/versions/${versionID}?draft=${draft}`
@@ -119,20 +122,25 @@ const Restore: React.FC<Props> = ({
)}
</div>
<Modal className={`${baseClass}__modal`} slug={modalSlug}>
<MinimalTemplate className={`${baseClass}__modal-template`}>
<h1>{t('version:confirmVersionRestoration')}</h1>
<p>{restoreMessage}</p>
<Button
buttonStyle="secondary"
onClick={processing ? undefined : () => toggleModal(modalSlug)}
type="button"
>
{t('general:cancel')}
</Button>
<Button onClick={processing ? undefined : () => void handleRestore()}>
{processing ? t('version:restoring') : t('general:confirm')}
</Button>
</MinimalTemplate>
<div className={`${baseClass}__wrapper`}>
<div className={`${baseClass}__content`}>
<h1>{t('version:confirmVersionRestoration')}</h1>
<p>{restoreMessage}</p>
</div>
<div className={`${baseClass}__controls`}>
<Button
buttonStyle="secondary"
onClick={processing ? undefined : () => toggleModal(modalSlug)}
size="large"
type="button"
>
{t('general:cancel')}
</Button>
<Button onClick={processing ? undefined : () => void handleRestore()}>
{processing ? t('version:restoring') : t('general:confirm')}
</Button>
</div>
</div>
</Modal>
</Fragment>
)

View File

@@ -1,6 +1,6 @@
{
"name": "payload",
"version": "3.0.0-beta.98",
"version": "3.0.0-beta.100",
"description": "Node, React, Headless CMS and Application Framework built on Next.js",
"keywords": [
"admin panel",

View File

@@ -132,24 +132,70 @@ export type LoginWithUsernameOptions =
}
export interface IncomingAuthType {
/**
* Set cookie options, including secure, sameSite, and domain. For advanced users.
*/
cookies?: {
domain?: string
sameSite?: 'Lax' | 'None' | 'Strict' | boolean
secure?: boolean
}
/**
* How many levels deep a user document should be populated when creating the JWT and binding the user to the req. Defaults to 0 and should only be modified if absolutely necessary, as this will affect performance.
* @default 0
*/
depth?: number
/**
* Advanced - disable Payload's built-in local auth strategy. Only use this property if you have replaced Payload's auth mechanisms with your own.
*/
disableLocalStrategy?: true
/**
* Customize the way that the forgotPassword operation functions.
* @link https://payloadcms.com/docs/beta/authentication/email#forgot-password
*/
forgotPassword?: {
generateEmailHTML?: GenerateForgotPasswordEmailHTML
generateEmailSubject?: GenerateForgotPasswordEmailSubject
}
/**
* Set the time (in milliseconds) that a user should be locked out if they fail authentication more times than maxLoginAttempts allows for.
*/
lockTime?: number
/**
* Ability to allow users to login with username/password.
*
* @link https://payloadcms.com/docs/beta/authentication/overview#login-with-username
*/
loginWithUsername?: boolean | LoginWithUsernameOptions
/**
* Only allow a user to attempt logging in X amount of times. Automatically locks out a user from authenticating if this limit is passed. Set to 0 to disable.
*/
maxLoginAttempts?: number
/***
* Set to true if you want to remove the token from the returned authentication API responses such as login or refresh.
*/
removeTokenFromResponses?: true
/**
* Advanced - an array of custom authentification strategies to extend this collection's authentication with.
* @link https://payloadcms.com/docs/beta/authentication/custom-strategies
*/
strategies?: AuthStrategy[]
/**
* Controls how many seconds the token will be valid for. Default is 2 hours.
* @default 7200
* @link https://payloadcms.com/docs/beta/authentication/overview#config-options
*/
tokenExpiration?: number
/**
* Payload Authentication provides for API keys to be set on each user within an Authentication-enabled Collection.
* @default false
* @link https://payloadcms.com/docs/beta/authentication/api-keys
*/
useAPIKey?: boolean
/**
* Set to true or pass an object with verification options to require users to verify by email before they are allowed to log into your app.
* @link https://payloadcms.com/docs/beta/authentication/email#email-verification
*/
verify?:
| {
generateEmailHTML?: GenerateVerifyEmailHTML

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-cloud-storage",
"version": "3.0.0-beta.98",
"version": "3.0.0-beta.100",
"description": "The official cloud storage plugin for Payload CMS",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-cloud",
"version": "3.0.0-beta.98",
"version": "3.0.0-beta.100",
"description": "The official Payload Cloud plugin",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-form-builder",
"version": "3.0.0-beta.98",
"version": "3.0.0-beta.100",
"description": "Form builder plugin for Payload CMS",
"keywords": [
"payload",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-nested-docs",
"version": "3.0.0-beta.98",
"version": "3.0.0-beta.100",
"description": "The official Nested Docs plugin for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-redirects",
"version": "3.0.0-beta.98",
"version": "3.0.0-beta.100",
"description": "Redirects plugin for Payload",
"keywords": [
"payload",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-relationship-object-ids",
"version": "3.0.0-beta.98",
"version": "3.0.0-beta.100",
"description": "A Payload plugin to store all relationship IDs as ObjectIDs",
"repository": {
"type": "git",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-search",
"version": "3.0.0-beta.98",
"version": "3.0.0-beta.100",
"description": "Search plugin for Payload",
"keywords": [
"payload",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-seo",
"version": "3.0.0-beta.98",
"version": "3.0.0-beta.100",
"description": "SEO plugin for Payload",
"keywords": [
"payload",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-stripe",
"version": "3.0.0-beta.98",
"version": "3.0.0-beta.100",
"description": "Stripe plugin for Payload",
"keywords": [
"payload",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/richtext-lexical",
"version": "3.0.0-beta.98",
"version": "3.0.0-beta.100",
"description": "The officially supported Lexical richtext adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/richtext-slate",
"version": "3.0.0-beta.98",
"version": "3.0.0-beta.100",
"description": "The officially supported Slate richtext adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/storage-azure",
"version": "3.0.0-beta.98",
"version": "3.0.0-beta.100",
"description": "Payload storage adapter for Azure Blob Storage",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/storage-gcs",
"version": "3.0.0-beta.98",
"version": "3.0.0-beta.100",
"description": "Payload storage adapter for Google Cloud Storage",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/storage-s3",
"version": "3.0.0-beta.98",
"version": "3.0.0-beta.100",
"description": "Payload storage adapter for Amazon S3",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/storage-uploadthing",
"version": "3.0.0-beta.98",
"version": "3.0.0-beta.100",
"description": "Payload storage adapter for uploadthing",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/storage-vercel-blob",
"version": "3.0.0-beta.98",
"version": "3.0.0-beta.100",
"description": "Payload storage adapter for Vercel Blob Storage",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/translations",
"version": "3.0.0-beta.98",
"version": "3.0.0-beta.100",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/ui",
"version": "3.0.0-beta.98",
"version": "3.0.0-beta.100",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",

View File

@@ -4,26 +4,39 @@
@include blur-bg;
display: flex;
align-items: center;
height: 100%;
justify-content: center;
padding: base(2);
&__template {
z-index: 1;
position: relative;
}
height: 100%;
&__toggle {
@extend %btn-reset;
}
.btn {
margin: 0;
&__wrapper {
z-index: 1;
position: relative;
display: flex;
flex-direction: column;
gap: base(0.8);
padding: base(2);
max-width: base(36);
}
&__actions {
&__content {
display: flex;
flex-wrap: wrap;
gap: $baseline;
flex-direction: column;
gap: base(0.4);
> * {
margin: 0;
}
}
&__controls {
display: flex;
gap: base(0.4);
.btn {
margin: 0;
}
}
}

View File

@@ -129,31 +129,38 @@ export const DeleteDocument: React.FC<Props> = (props) => {
{t('general:delete')}
</PopupList.Button>
<Modal className={baseClass} slug={modalSlug}>
<div className={`${baseClass}__template`}>
<h1>{t('general:confirmDeletion')}</h1>
<p>
<Translation
elements={{
'1': ({ children }) => <strong>{children}</strong>,
}}
i18nKey="general:aboutToDelete"
t={t}
variables={{
label: getTranslation(singularLabel, i18n),
title: titleToRender,
}}
/>
</p>
<div className={`${baseClass}__actions`}>
<div className={`${baseClass}__wrapper`}>
<div className={`${baseClass}__content`}>
<h1>{t('general:confirmDeletion')}</h1>
<p>
<Translation
elements={{
'1': ({ children }) => <strong>{children}</strong>,
}}
i18nKey="general:aboutToDelete"
t={t}
variables={{
label: getTranslation(singularLabel, i18n),
title: titleToRender,
}}
/>
</p>
</div>
<div className={`${baseClass}__controls`}>
<Button
buttonStyle="secondary"
id="confirm-cancel"
onClick={deleting ? undefined : () => toggleModal(modalSlug)}
size="large"
type="button"
>
{t('general:cancel')}
</Button>
<Button id="confirm-delete" onClick={deleting ? undefined : handleDelete}>
<Button
id="confirm-delete"
onClick={deleting ? undefined : handleDelete}
size="large"
>
{deleting ? t('general:deleting') : t('general:confirm')}
</Button>
</div>

View File

@@ -4,16 +4,35 @@
@include blur-bg;
display: flex;
align-items: center;
height: 100%;
justify-content: center;
padding: base(2);
height: 100%;
&__template {
&__wrapper {
z-index: 1;
position: relative;
display: flex;
flex-direction: column;
gap: base(0.8);
padding: base(2);
max-width: base(36);
}
.btn {
margin-right: $baseline;
&__content {
display: flex;
flex-direction: column;
gap: base(0.4);
> * {
margin: 0;
}
}
&__controls {
display: flex;
gap: base(0.4);
.btn {
margin: 0;
}
}
}

View File

@@ -125,20 +125,25 @@ export const DeleteMany: React.FC<Props> = (props) => {
{t('general:delete')}
</Pill>
<Modal className={baseClass} slug={modalSlug}>
<div className={`${baseClass}__template`}>
<h1>{t('general:confirmDeletion')}</h1>
<p>{t('general:aboutToDeleteCount', { count, label: getTranslation(plural, i18n) })}</p>
<Button
buttonStyle="secondary"
id="confirm-cancel"
onClick={deleting ? undefined : () => toggleModal(modalSlug)}
type="button"
>
{t('general:cancel')}
</Button>
<Button id="confirm-delete" onClick={deleting ? undefined : handleDelete}>
{deleting ? t('general:deleting') : t('general:confirm')}
</Button>
<div className={`${baseClass}__wrapper`}>
<div className={`${baseClass}__content`}>
<h1>{t('general:confirmDeletion')}</h1>
<p>{t('general:aboutToDeleteCount', { count, label: getTranslation(plural, i18n) })}</p>
</div>
<div className={`${baseClass}__controls`}>
<Button
buttonStyle="secondary"
id="confirm-cancel"
onClick={deleting ? undefined : () => toggleModal(modalSlug)}
size="large"
type="button"
>
{t('general:cancel')}
</Button>
<Button id="confirm-delete" onClick={deleting ? undefined : handleDelete} size="large">
{deleting ? t('general:deleting') : t('general:confirm')}
</Button>
</div>
</div>
</Modal>
</React.Fragment>

View File

@@ -5,16 +5,36 @@
@include blur-bg;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
padding: base(2);
}
.btn {
margin-right: $baseline;
&__wrapper {
z-index: 1;
position: relative;
display: flex;
flex-direction: column;
gap: base(0.8);
padding: base(2);
max-width: base(36);
}
&__content {
display: flex;
flex-direction: column;
gap: base(0.4);
> * {
margin: 0;
}
}
&__modal-template {
z-index: 1;
position: relative;
&__controls {
display: flex;
gap: base(0.4);
.btn {
margin: 0;
}
}
}

View File

@@ -118,20 +118,25 @@ export const DuplicateDocument: React.FC<Props> = ({ id, slug, singularLabel })
</PopupList.Button>
{modified && hasClicked && (
<Modal className={`${baseClass}__modal`} slug={modalSlug}>
<div className={`${baseClass}__modal-template`}>
<h1>{t('general:confirmDuplication')}</h1>
<p>{t('general:unsavedChangesDuplicate')}</p>
<Button
buttonStyle="secondary"
id="confirm-cancel"
onClick={() => toggleModal(modalSlug)}
type="button"
>
{t('general:cancel')}
</Button>
<Button id="confirm-duplicate" onClick={() => void confirm()}>
{t('general:duplicateWithoutSaving')}
</Button>
<div className={`${baseClass}__wrapper`}>
<div className={`${baseClass}__content`}>
<h1>{t('general:confirmDuplication')}</h1>
<p>{t('general:unsavedChangesDuplicate')}</p>
</div>
<div className={`${baseClass}__controls`}>
<Button
buttonStyle="secondary"
id="confirm-cancel"
onClick={() => toggleModal(modalSlug)}
size="large"
type="button"
>
{t('general:cancel')}
</Button>
<Button id="confirm-duplicate" onClick={() => void confirm()} size="large">
{t('general:duplicateWithoutSaving')}
</Button>
</div>
</div>
</Modal>
)}

View File

@@ -6,14 +6,33 @@
align-items: center;
justify-content: center;
height: 100%;
padding: base(2);
&__template {
position: relative;
&__wrapper {
z-index: 1;
position: relative;
display: flex;
flex-direction: column;
gap: base(0.8);
padding: base(2);
max-width: base(36);
}
.btn {
margin-right: $baseline;
&__content {
display: flex;
flex-direction: column;
gap: base(0.4);
> * {
margin: 0;
}
}
&__controls {
display: flex;
gap: base(0.4);
.btn {
margin: 0;
}
}
}

View File

@@ -44,28 +44,32 @@ export const GenerateConfirmation: React.FC<GenerateConfirmationProps> = (props)
{t('authentication:generateNewAPIKey')}
</Button>
<Modal className={baseClass} slug={modalSlug}>
<div className={`${baseClass}__template`}>
<h1>{t('authentication:confirmGeneration')}</h1>
<p>
<Translation
elements={{
1: ({ children }) => <strong>{children}</strong>,
<div className={`${baseClass}__wrapper`}>
<div className={`${baseClass}__content`}>
<h1>{t('authentication:confirmGeneration')}</h1>
<p>
<Translation
elements={{
1: ({ children }) => <strong>{children}</strong>,
}}
i18nKey="authentication:generatingNewAPIKeyWillInvalidate"
t={t}
/>
</p>
</div>
<div className={`${baseClass}__controls`}>
<Button
buttonStyle="secondary"
onClick={() => {
toggleModal(modalSlug)
}}
i18nKey="authentication:generatingNewAPIKeyWillInvalidate"
t={t}
/>
</p>
<Button
buttonStyle="secondary"
onClick={() => {
toggleModal(modalSlug)
}}
type="button"
>
{t('general:cancel')}
</Button>
<Button onClick={handleGenerate}>{t('authentication:generate')}</Button>
size="large"
type="button"
>
{t('general:cancel')}
</Button>
<Button onClick={handleGenerate}>{t('authentication:generate')}</Button>
</div>
</div>
</Modal>
</React.Fragment>

View File

@@ -4,15 +4,34 @@
@include blur-bg;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
padding: base(2);
&__template {
&__wrapper {
z-index: 1;
position: relative;
display: flex;
flex-direction: column;
gap: base(0.8);
padding: base(2);
}
.btn {
margin-right: $baseline;
&__content {
display: flex;
flex-direction: column;
gap: base(0.4);
> * {
margin: 0;
}
}
&__controls {
display: flex;
gap: base(0.4);
.btn {
margin: 0;
}
}
}

View File

@@ -127,20 +127,29 @@ export const PublishMany: React.FC<PublishManyProps> = (props) => {
{t('version:publish')}
</Pill>
<Modal className={baseClass} slug={modalSlug}>
<div className={`${baseClass}__template`}>
<h1>{t('version:confirmPublish')}</h1>
<p>{t('version:aboutToPublishSelection', { label: getTranslation(plural, i18n) })}</p>
<Button
buttonStyle="secondary"
id="confirm-cancel"
onClick={submitted ? undefined : () => toggleModal(modalSlug)}
type="button"
>
{t('general:cancel')}
</Button>
<Button id="confirm-publish" onClick={submitted ? undefined : handlePublish}>
{submitted ? t('version:publishing') : t('general:confirm')}
</Button>
<div className={`${baseClass}__wrapper`}>
<div className={`${baseClass}__content`}>
<h1>{t('version:confirmPublish')}</h1>
<p>{t('version:aboutToPublishSelection', { label: getTranslation(plural, i18n) })}</p>
</div>
<div className={`${baseClass}__controls`}>
<Button
buttonStyle="secondary"
id="confirm-cancel"
onClick={submitted ? undefined : () => toggleModal(modalSlug)}
size="large"
type="button"
>
{t('general:cancel')}
</Button>
<Button
id="confirm-publish"
onClick={submitted ? undefined : handlePublish}
size="large"
>
{submitted ? t('version:publishing') : t('general:confirm')}
</Button>
</div>
</div>
</Modal>
</React.Fragment>

View File

@@ -7,6 +7,11 @@ type CustomSelectProps = {
disableMouseDown?: boolean
draggableProps?: any
droppableRef?: React.RefObject<HTMLDivElement | null>
onDocumentDrawerOpen: (args: {
collectionSlug: string
hasReadPermission: boolean
id: number | string
}) => void
onSave?: DocumentDrawerProps['onSave']
setDrawerIsOpen?: (isOpen: boolean) => void
}

View File

@@ -20,22 +20,41 @@
&__modal {
@include blur-bg;
display: flex;
justify-content: center;
align-items: center;
justify-content: center;
height: 100%;
padding: base(2);
&__toggle {
@extend %btn-reset;
}
}
.btn {
margin-right: $baseline;
&__wrapper {
z-index: 1;
position: relative;
display: flex;
flex-direction: column;
gap: base(0.8);
padding: base(2);
max-width: base(36);
}
&__content {
display: flex;
flex-direction: column;
gap: base(0.4);
> * {
margin: 0;
}
}
&__modal-template {
position: relative;
z-index: 1;
&__controls {
display: flex;
gap: base(0.4);
.btn {
margin: 0;
}
}
}

View File

@@ -152,19 +152,27 @@ export const Status: React.FC = () => {
{t('version:unpublish')}
</Button>
<Modal className={`${baseClass}__modal`} slug={unPublishModalSlug}>
<div className={`${baseClass}__modal-template`}>
<h1>{t('version:confirmUnpublish')}</h1>
<p>{t('version:aboutToUnpublish')}</p>
<Button
buttonStyle="secondary"
onClick={processing ? undefined : () => toggleModal(unPublishModalSlug)}
type="button"
>
{t('general:cancel')}
</Button>
<Button onClick={processing ? undefined : () => performAction('unpublish')}>
{t(processing ? 'version:unpublishing' : 'general:confirm')}
</Button>
<div className={`${baseClass}__wrapper`}>
<div className={`${baseClass}__content`}>
<h1>{t('version:confirmUnpublish')}</h1>
<p>{t('version:aboutToUnpublish')}</p>
</div>
<div className={`${baseClass}__controls`}>
<Button
buttonStyle="secondary"
onClick={processing ? undefined : () => toggleModal(unPublishModalSlug)}
size="large"
type="button"
>
{t('general:cancel')}
</Button>
<Button
onClick={processing ? undefined : () => performAction('unpublish')}
size="large"
>
{t(processing ? 'version:unpublishing' : 'general:confirm')}
</Button>
</div>
</div>
</Modal>
</React.Fragment>
@@ -181,22 +189,28 @@ export const Status: React.FC = () => {
{t('version:revertToPublished')}
</Button>
<Modal className={`${baseClass}__modal`} slug={revertModalSlug}>
<div className={`${baseClass}__modal-template`}>
<h1>{t('version:confirmRevertToSaved')}</h1>
<p>{t('version:aboutToRevertToPublished')}</p>
<Button
buttonStyle="secondary"
onClick={processing ? undefined : () => toggleModal(revertModalSlug)}
type="button"
>
{t('general:cancel')}
</Button>
<Button
id="action-revert-to-published-confirm"
onClick={processing ? undefined : () => performAction('revert')}
>
{t(processing ? 'version:reverting' : 'general:confirm')}
</Button>
<div className={`${baseClass}__wrapper`}>
<div className={`${baseClass}__content`}>
<h1>{t('version:confirmRevertToSaved')}</h1>
<p>{t('version:aboutToRevertToPublished')}</p>
</div>
<div className={`${baseClass}__controls`}>
<Button
buttonStyle="secondary"
onClick={processing ? undefined : () => toggleModal(revertModalSlug)}
size="large"
type="button"
>
{t('general:cancel')}
</Button>
<Button
id="action-revert-to-published-confirm"
onClick={processing ? undefined : () => performAction('revert')}
size="large"
>
{t(processing ? 'version:reverting' : 'general:confirm')}
</Button>
</div>
</div>
</Modal>
</React.Fragment>

View File

@@ -1,4 +1,3 @@
'use client'
import type { ClientTranslationKeys, TFunction } from '@payloadcms/translations'
import * as React from 'react'

View File

@@ -4,15 +4,34 @@
@include blur-bg;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
padding: base(2);
&__template {
&__wrapper {
z-index: 1;
position: relative;
display: flex;
flex-direction: column;
gap: base(0.8);
padding: base(2);
}
.btn {
margin-right: $baseline;
&__content {
display: flex;
flex-direction: column;
gap: base(0.4);
> * {
margin: 0;
}
}
&__controls {
display: flex;
gap: base(0.4);
.btn {
margin: 0;
}
}
}

View File

@@ -124,20 +124,29 @@ export const UnpublishMany: React.FC<UnpublishManyProps> = (props) => {
{t('version:unpublish')}
</Pill>
<Modal className={baseClass} slug={modalSlug}>
<div className={`${baseClass}__template`}>
<h1>{t('version:confirmUnpublish')}</h1>
<p>{t('version:aboutToUnpublishSelection', { label: getTranslation(plural, i18n) })}</p>
<Button
buttonStyle="secondary"
id="confirm-cancel"
onClick={submitted ? undefined : () => toggleModal(modalSlug)}
type="button"
>
{t('general:cancel')}
</Button>
<Button id="confirm-unpublish" onClick={submitted ? undefined : handleUnpublish}>
{submitted ? t('version:unpublishing') : t('general:confirm')}
</Button>
<div className={`${baseClass}__wrapper`}>
<div className={`${baseClass}__content`}>
<h1>{t('version:confirmUnpublish')}</h1>
<p>{t('version:aboutToUnpublishSelection', { label: getTranslation(plural, i18n) })}</p>
</div>
<div className={`${baseClass}__controls`}>
<Button
buttonStyle="secondary"
id="confirm-cancel"
onClick={submitted ? undefined : () => toggleModal(modalSlug)}
size="large"
type="button"
>
{t('general:cancel')}
</Button>
<Button
id="confirm-unpublish"
onClick={submitted ? undefined : handleUnpublish}
size="large"
>
{submitted ? t('version:unpublishing') : t('general:confirm')}
</Button>
</div>
</div>
</Modal>
</React.Fragment>

View File

@@ -1,4 +1,5 @@
// IMPORTANT: the shared.ts file CANNOT contain any Server Components _that import client components_.
export { Translation } from '../../elements/Translation/index.js'
export { withMergedProps } from '../../elements/withMergedProps/index.js' // cannot be within a 'use client', thus we export this from shared
export { WithServerSideProps } from '../../elements/WithServerSideProps/index.js'
export { PayloadIcon } from '../../graphics/Icon/index.js'

View File

@@ -6,14 +6,17 @@ import * as qs from 'qs-esm'
import React, { useCallback, useEffect, useReducer, useRef, useState } from 'react'
import type { DocumentDrawerProps } from '../../elements/DocumentDrawer/types.js'
import type { ReactSelectAdapterProps } from '../../elements/ReactSelect/types.js'
import type { GetResults, Option, Value } from './types.js'
import { AddNewRelation } from '../../elements/AddNewRelation/index.js'
import { useDocumentDrawer } from '../../elements/DocumentDrawer/index.js'
import { ReactSelect } from '../../elements/ReactSelect/index.js'
import { useFieldProps } from '../../forms/FieldPropsProvider/index.js'
import { useField } from '../../forms/useField/index.js'
import { withCondition } from '../../forms/withCondition/index.js'
import { useDebouncedCallback } from '../../hooks/useDebouncedCallback.js'
import { useIgnoredEffect } from '../../hooks/useIgnoredEffect.js'
import { useAuth } from '../../providers/Auth/index.js'
import { useConfig } from '../../providers/Config/index.js'
import { useLocale } from '../../providers/Locale/index.js'
@@ -74,14 +77,22 @@ const RelationshipFieldComponent: React.FC<RelationshipFieldProps> = (props) =>
const { permissions } = useAuth()
const { code: locale } = useLocale()
const hasMultipleRelations = Array.isArray(relationTo)
const [options, dispatchOptions] = useReducer(optionsReducer, [])
const [currentlyOpenRelationship, setCurrentlyOpenRelationship] = useState<
Parameters<ReactSelectAdapterProps['customProps']['onDocumentDrawerOpen']>[0]
>({
id: undefined,
collectionSlug: undefined,
hasReadPermission: false,
})
const [lastFullyLoadedRelation, setLastFullyLoadedRelation] = useState(-1)
const [lastLoadedPage, setLastLoadedPage] = useState<Record<string, number>>({})
const [errorLoading, setErrorLoading] = useState('')
const [search, setSearch] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [enableWordBoundarySearch, setEnableWordBoundarySearch] = useState(false)
const menuIsOpen = useRef(false)
const [menuIsOpen, setMenuIsOpen] = useState(false)
const hasLoadedFirstPageRef = useRef(false)
const memoizedValidate = useCallback(
@@ -107,16 +118,23 @@ const RelationshipFieldComponent: React.FC<RelationshipFieldProps> = (props) =>
path: pathFromContext ?? pathFromProps ?? name,
validate: memoizedValidate,
})
const [options, dispatchOptions] = useReducer(optionsReducer, [])
const readOnly = readOnlyFromProps || readOnlyFromContext || formInitializing
const valueRef = useRef(value)
valueRef.current = value
const [drawerIsOpen, setDrawerIsOpen] = useState(false)
const [DocumentDrawer, , { isDrawerOpen, openDrawer }] = useDocumentDrawer({
id: currentlyOpenRelationship.id,
collectionSlug: currentlyOpenRelationship.collectionSlug,
})
const openDrawerWhenRelationChanges = useRef(false)
const getResults: GetResults = useCallback(
async ({
filterOptions,
lastFullyLoadedRelation: lastFullyLoadedRelationArg,
lastLoadedPage: lastLoadedPageArg,
onSuccess,
@@ -273,7 +291,6 @@ const RelationshipFieldComponent: React.FC<RelationshipFieldProps> = (props) =>
search,
collections,
locale,
filterOptions,
serverURL,
sortOptions,
api,
@@ -284,7 +301,13 @@ const RelationshipFieldComponent: React.FC<RelationshipFieldProps> = (props) =>
)
const updateSearch = useDebouncedCallback((searchArg: string, valueArg: Value | Value[]) => {
void getResults({ lastLoadedPage: {}, search: searchArg, sort: true, value: valueArg })
void getResults({
filterOptions,
lastLoadedPage: {},
search: searchArg,
sort: true,
value: valueArg,
})
setSearch(searchArg)
}, 300)
@@ -302,85 +325,88 @@ const RelationshipFieldComponent: React.FC<RelationshipFieldProps> = (props) =>
// Ensure we have an option for each value
// ///////////////////////////////////
useEffect(() => {
const relationMap = createRelationMap({
hasMany,
relationTo,
value,
})
void Object.entries(relationMap).reduce(async (priorRelation, [relation, ids]) => {
await priorRelation
const idsToLoad = ids.filter((id) => {
return !options.find((optionGroup) =>
optionGroup?.options?.find(
(option) => option.value === id && option.relationTo === relation,
),
)
useIgnoredEffect(
() => {
const relationMap = createRelationMap({
hasMany,
relationTo,
value,
})
if (idsToLoad.length > 0) {
const query = {
depth: 0,
draft: true,
limit: idsToLoad.length,
locale,
where: {
id: {
in: idsToLoad,
void Object.entries(relationMap).reduce(async (priorRelation, [relation, ids]) => {
await priorRelation
const idsToLoad = ids.filter((id) => {
return !options.find((optionGroup) =>
optionGroup?.options?.find(
(option) => option.value === id && option.relationTo === relation,
),
)
})
if (idsToLoad.length > 0) {
const query = {
depth: 0,
draft: true,
limit: idsToLoad.length,
locale,
where: {
id: {
in: idsToLoad,
},
},
},
}
if (!errorLoading) {
const response = await fetch(`${serverURL}${api}/${relation}`, {
body: qs.stringify(query),
credentials: 'include',
headers: {
'Accept-Language': i18n.language,
'Content-Type': 'application/x-www-form-urlencoded',
'X-HTTP-Method-Override': 'GET',
},
method: 'POST',
})
const collection = collections.find((coll) => coll.slug === relation)
let docs = []
if (response.ok) {
const data = await response.json()
docs = data.docs
}
dispatchOptions({
type: 'ADD',
collection,
// TODO: fix this
// @ts-expect-error-next-line
config,
docs,
i18n,
ids: idsToLoad,
sort: true,
})
if (!errorLoading) {
const response = await fetch(`${serverURL}${api}/${relation}`, {
body: qs.stringify(query),
credentials: 'include',
headers: {
'Accept-Language': i18n.language,
'Content-Type': 'application/x-www-form-urlencoded',
'X-HTTP-Method-Override': 'GET',
},
method: 'POST',
})
const collection = collections.find((coll) => coll.slug === relation)
let docs = []
if (response.ok) {
const data = await response.json()
docs = data.docs
}
dispatchOptions({
type: 'ADD',
collection,
// TODO: fix this
// @ts-expect-error-next-line
config,
docs,
i18n,
ids: idsToLoad,
sort: true,
})
}
}
}
}, Promise.resolve())
}, [
options,
value,
hasMany,
errorLoading,
collections,
hasMultipleRelations,
serverURL,
api,
i18n,
relationTo,
locale,
config,
])
}, Promise.resolve())
},
[value],
[
options,
hasMany,
errorLoading,
collections,
hasMultipleRelations,
serverURL,
api,
i18n,
relationTo,
locale,
config,
],
)
// Determine if we should switch to word boundary search
useEffect(() => {
@@ -395,41 +421,37 @@ const RelationshipFieldComponent: React.FC<RelationshipFieldProps> = (props) =>
// When (`relationTo` || `filterOptions` || `locale`) changes, reset component
// Note - effect should not run on first run
useEffect(() => {
// If the menu is open while filterOptions changes
// due to latency of getFormState and fast clicking into this field,
// re-fetch options
useIgnoredEffect(
() => {
// If the menu is open while filterOptions changes
// due to latency of getFormState and fast clicking into this field,
// re-fetch options
if (hasLoadedFirstPageRef.current && menuIsOpen) {
setIsLoading(true)
void getResults({
filterOptions,
lastLoadedPage: {},
onSuccess: () => {
hasLoadedFirstPageRef.current = true
setIsLoading(false)
},
value: valueRef.current,
})
}
if (hasLoadedFirstPageRef.current && menuIsOpen.current) {
setIsLoading(true)
void getResults({
lastLoadedPage: {},
onSuccess: () => {
hasLoadedFirstPageRef.current = true
setIsLoading(false)
},
value: valueRef.current,
// If the menu is not open, still reset the field state
// because we need to get new options next time the menu opens
dispatchOptions({
type: 'CLEAR',
exemptValues: valueRef.current,
})
}
// If the menu is not open, still reset the field state
// because we need to get new options next time the menu
// opens by the user
dispatchOptions({ type: 'CLEAR' })
setLastFullyLoadedRelation(-1)
setLastLoadedPage({})
hasLoadedFirstPageRef.current = false
}, [
relationTo,
filterOptions,
locale,
menuIsOpen,
getResults,
valueRef,
hasLoadedFirstPageRef,
path,
])
setLastFullyLoadedRelation(-1)
setLastLoadedPage({})
},
[relationTo, filterOptions, locale, path, menuIsOpen],
[getResults],
)
const onSave = useCallback<DocumentDrawerProps['onSave']>(
(args) => {
@@ -466,6 +488,24 @@ const RelationshipFieldComponent: React.FC<RelationshipFieldProps> = (props) =>
return r.test(string.slice(-breakApartThreshold))
}, [])
const onDocumentDrawerOpen = useCallback<
ReactSelectAdapterProps['customProps']['onDocumentDrawerOpen']
>(({ id, collectionSlug, hasReadPermission }) => {
openDrawerWhenRelationChanges.current = true
setCurrentlyOpenRelationship({
id,
collectionSlug,
hasReadPermission,
})
}, [])
useEffect(() => {
if (openDrawerWhenRelationChanges.current) {
openDrawer()
openDrawerWhenRelationChanges.current = false
}
}, [openDrawer, currentlyOpenRelationship])
const valueToRender = findOptionsByValue({ options, value })
if (!Array.isArray(valueToRender) && valueToRender?.value === 'null') {
@@ -508,18 +548,18 @@ const RelationshipFieldComponent: React.FC<RelationshipFieldProps> = (props) =>
{!errorLoading && (
<div className={`${baseClass}__wrap`}>
<ReactSelect
backspaceRemovesValue={!drawerIsOpen}
backspaceRemovesValue={!isDrawerOpen}
components={{
MultiValueLabel,
SingleValue,
}}
customProps={{
disableKeyDown: drawerIsOpen,
disableMouseDown: drawerIsOpen,
disableKeyDown: isDrawerOpen,
disableMouseDown: isDrawerOpen,
onDocumentDrawerOpen,
onSave,
setDrawerIsOpen,
}}
disabled={readOnly || formProcessing || drawerIsOpen}
disabled={readOnly || formProcessing || isDrawerOpen}
filterOption={enableWordBoundarySearch ? filterOption : undefined}
getOptionValue={(option) => {
if (!option) {
@@ -565,14 +605,15 @@ const RelationshipFieldComponent: React.FC<RelationshipFieldProps> = (props) =>
}
onInputChange={(newSearch) => handleInputChange(newSearch, value)}
onMenuClose={() => {
menuIsOpen.current = false
setMenuIsOpen(false)
}}
onMenuOpen={() => {
menuIsOpen.current = true
setMenuIsOpen(true)
if (!hasLoadedFirstPageRef.current) {
setIsLoading(true)
void getResults({
filterOptions,
lastLoadedPage: {},
onSuccess: () => {
hasLoadedFirstPageRef.current = true
@@ -584,6 +625,7 @@ const RelationshipFieldComponent: React.FC<RelationshipFieldProps> = (props) =>
}}
onMenuScrollToBottom={() => {
void getResults({
filterOptions,
lastFullyLoadedRelation,
lastLoadedPage,
search,
@@ -614,6 +656,9 @@ const RelationshipFieldComponent: React.FC<RelationshipFieldProps> = (props) =>
{...(descriptionProps || {})}
/>
</div>
{currentlyOpenRelationship.collectionSlug && currentlyOpenRelationship.hasReadPermission && (
<DocumentDrawer onSave={onSave} />
)}
</div>
)
}

View File

@@ -29,7 +29,32 @@ const sortOptions = (options: Option[]): Option[] =>
export const optionsReducer = (state: OptionGroup[], action: Action): OptionGroup[] => {
switch (action.type) {
case 'CLEAR': {
return []
const exemptValues = action.exemptValues
? Array.isArray(action.exemptValues)
? action.exemptValues
: [action.exemptValues]
: []
const clearedStateWithExemptValues = state.filter((optionGroup) => {
const clearedOptions = optionGroup.options.filter((option) => {
if (exemptValues) {
return exemptValues.some((exemptValue) => {
return (
exemptValue &&
option.value === (typeof exemptValue === 'object' ? exemptValue.value : exemptValue)
)
})
}
return false
})
optionGroup.options = clearedOptions
return clearedOptions.length > 0
})
return clearedStateWithExemptValues
}
case 'UPDATE': {

View File

@@ -21,6 +21,10 @@
}
&__drawer-toggler {
border: none;
background-color: transparent;
padding: 0;
cursor: pointer;
position: relative;
display: flex;
align-items: center;

View File

@@ -1,12 +1,12 @@
'use client'
import type { MultiValueProps } from 'react-select'
import React, { Fragment, useEffect, useState } from 'react'
import React, { Fragment, useState } from 'react'
import { components } from 'react-select'
import type { ReactSelectAdapterProps } from '../../../../elements/ReactSelect/types.js'
import type { Option } from '../../types.js'
import { useDocumentDrawer } from '../../../../elements/DocumentDrawer/index.js'
import { Tooltip } from '../../../../elements/Tooltip/index.js'
import { EditIcon } from '../../../../icons/Edit/index.js'
import { useAuth } from '../../../../providers/Auth/index.js'
@@ -15,19 +15,17 @@ import './index.scss'
const baseClass = 'relationship--multi-value-label'
export const MultiValueLabel: React.FC<MultiValueProps<Option>> = (props) => {
export const MultiValueLabel: React.FC<
{
selectProps: {
// TODO Fix this - moduleResolution 16 breaks our declare module
customProps: ReactSelectAdapterProps['customProps']
}
} & MultiValueProps<Option>
> = (props) => {
const {
data: { label, relationTo, value },
selectProps: {
// @ts-expect-error-next-line // TODO Fix this - moduleResolution 16 breaks our declare module
customProps: {
// @ts-expect-error-next-line// TODO Fix this - moduleResolution 16 breaks our declare module
draggableProps,
// @ts-expect-error-next-line // TODO Fix this - moduleResolution 16 breaks our declare module
setDrawerIsOpen,
// onSave,
} = {},
} = {},
selectProps: { customProps: { draggableProps, onDocumentDrawerOpen } = {} } = {},
} = props
const { permissions } = useAuth()
@@ -35,17 +33,6 @@ export const MultiValueLabel: React.FC<MultiValueProps<Option>> = (props) => {
const { t } = useTranslation()
const hasReadPermission = Boolean(permissions?.collections?.[relationTo]?.read?.permission)
const [DocumentDrawer, DocumentDrawerToggler, { isDrawerOpen }] = useDocumentDrawer({
id: value?.toString(),
collectionSlug: relationTo,
})
useEffect(() => {
if (typeof setDrawerIsOpen === 'function') {
setDrawerIsOpen(isDrawerOpen)
}
}, [isDrawerOpen, setDrawerIsOpen])
return (
<div className={baseClass}>
<div className={`${baseClass}__content`}>
@@ -59,10 +46,17 @@ export const MultiValueLabel: React.FC<MultiValueProps<Option>> = (props) => {
</div>
{relationTo && hasReadPermission && (
<Fragment>
<DocumentDrawerToggler
<button
aria-label={`Edit ${label}`}
className={`${baseClass}__drawer-toggler`}
onClick={() => setShowTooltip(false)}
onClick={() => {
setShowTooltip(false)
onDocumentDrawerOpen({
id: value,
collectionSlug: relationTo,
hasReadPermission,
})
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.stopPropagation()
@@ -72,13 +66,13 @@ export const MultiValueLabel: React.FC<MultiValueProps<Option>> = (props) => {
onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)}
onTouchEnd={(e) => e.stopPropagation()} // prevents react-select dropdown from opening
type="button"
>
<Tooltip className={`${baseClass}__tooltip`} show={showTooltip}>
{t('general:editLabel', { label: '' })}
</Tooltip>
<EditIcon className={`${baseClass}__icon`} />
</DocumentDrawerToggler>
<DocumentDrawer onSave={/* onSave */ null} />
</button>
</Fragment>
)}
</div>

View File

@@ -21,6 +21,10 @@
}
&__drawer-toggler {
border: none;
background-color: transparent;
padding: 0;
cursor: pointer;
position: relative;
display: inline-flex;
align-items: center;

View File

@@ -1,12 +1,12 @@
'use client'
import type { SingleValueProps } from 'react-select'
import React, { Fragment, useEffect, useState } from 'react'
import React, { Fragment, useState } from 'react'
import { components as SelectComponents } from 'react-select'
import type { ReactSelectAdapterProps } from '../../../../elements/ReactSelect/types.js'
import type { Option } from '../../types.js'
import { useDocumentDrawer } from '../../../../elements/DocumentDrawer/index.js'
import { Tooltip } from '../../../../elements/Tooltip/index.js'
import { EditIcon } from '../../../../icons/Edit/index.js'
import { useAuth } from '../../../../providers/Auth/index.js'
@@ -15,12 +15,18 @@ import './index.scss'
const baseClass = 'relationship--single-value'
export const SingleValue: React.FC<SingleValueProps<Option>> = (props) => {
export const SingleValue: React.FC<
{
selectProps: {
// TODO Fix this - moduleResolution 16 breaks our declare module
customProps: ReactSelectAdapterProps['customProps']
}
} & SingleValueProps<Option>
> = (props) => {
const {
children,
data: { label, relationTo, value },
// @ts-expect-error-next-line // TODO Fix this - moduleResolution 16 breaks our declare module
selectProps: { customProps: { onSave, setDrawerIsOpen } = {} } = {},
selectProps: { customProps: { onDocumentDrawerOpen } = {} } = {},
} = props
const [showTooltip, setShowTooltip] = useState(false)
@@ -28,17 +34,6 @@ export const SingleValue: React.FC<SingleValueProps<Option>> = (props) => {
const { permissions } = useAuth()
const hasReadPermission = Boolean(permissions?.collections?.[relationTo]?.read?.permission)
const [DocumentDrawer, DocumentDrawerToggler, { isDrawerOpen }] = useDocumentDrawer({
id: value.toString(),
collectionSlug: relationTo,
})
useEffect(() => {
if (typeof setDrawerIsOpen === 'function') {
setDrawerIsOpen(isDrawerOpen)
}
}, [isDrawerOpen, setDrawerIsOpen])
return (
<React.Fragment>
<SelectComponents.SingleValue {...props} className={baseClass}>
@@ -47,10 +42,17 @@ export const SingleValue: React.FC<SingleValueProps<Option>> = (props) => {
<div className={`${baseClass}__text`}>{children}</div>
{relationTo && hasReadPermission && (
<Fragment>
<DocumentDrawerToggler
<button
aria-label={t('general:editLabel', { label })}
className={`${baseClass}__drawer-toggler`}
onClick={() => setShowTooltip(false)}
onClick={() => {
setShowTooltip(false)
onDocumentDrawerOpen({
id: value,
collectionSlug: relationTo,
hasReadPermission,
})
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.stopPropagation()
@@ -60,17 +62,17 @@ export const SingleValue: React.FC<SingleValueProps<Option>> = (props) => {
onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)}
onTouchEnd={(e) => e.stopPropagation()} // prevents react-select dropdown from opening
type="button"
>
<Tooltip className={`${baseClass}__tooltip`} show={showTooltip}>
{t('general:edit')}
</Tooltip>
<EditIcon />
</DocumentDrawerToggler>
</button>
</Fragment>
)}
</div>
</div>
{relationTo && hasReadPermission && <DocumentDrawer onSave={onSave} />}
</SelectComponents.SingleValue>
</React.Fragment>
)

View File

@@ -1,5 +1,5 @@
import type { I18nClient } from '@payloadcms/translations'
import type { ClientCollectionConfig, SanitizedConfig } from 'payload'
import type { ClientCollectionConfig, FilterOptionsResult, SanitizedConfig } from 'payload'
export type Option = {
label: string
@@ -21,6 +21,7 @@ export type ValueWithRelation = {
export type Value = number | string | ValueWithRelation
type CLEAR = {
exemptValues?: Value | Value[]
type: 'CLEAR'
}
@@ -45,6 +46,7 @@ type ADD = {
export type Action = ADD | CLEAR | UPDATE
export type GetResults = (args: {
filterOptions?: FilterOptionsResult
lastFullyLoadedRelation?: number
lastLoadedPage: Record<string, number>
onSuccess?: () => void

View File

@@ -0,0 +1,41 @@
import { dequal } from 'dequal/lite'
import { useEffect, useRef } from 'react'
/**
* Allows for a `useEffect` hook to be precisely triggered based on whether a only _subset_ of its dependencies have changed, as opposed to all of them. This is useful if you have a list of dependencies that change often, but need to scope your effect's logic to only explicit dependencies within that list.
* @constructor
* @param {React.EffectCallback} effect - The effect to run
* @param {React.DependencyList} deps - Dependencies that should trigger the effect
* @param {React.DependencyList} ignoredDeps - Dependencies that should _not_ trigger the effect
* @param {Object} options - Additional options to configure the hook
* @param {boolean} options.runOnFirstRender - Whether the effect should run on the first render
* @example
* useIgnoredEffect(() => {
* console.log('This will run when `foo` changes, but not when `bar` changes')
* }, [foo], [bar])
*/
export function useIgnoredEffect(
effect: React.EffectCallback,
deps: React.DependencyList,
ignoredDeps: React.DependencyList,
options?: { runOnFirstRender?: boolean },
) {
const hasInitialized = useRef(
typeof options?.runOnFirstRender !== 'undefined' ? Boolean(!options?.runOnFirstRender) : false,
)
const prevDeps = useRef(deps)
useEffect(() => {
const depsHaveChanged = deps.some(
(dep, index) => !ignoredDeps.includes(dep) && !dequal(dep, prevDeps.current[index]),
)
if (depsHaveChanged || !hasInitialized.current) {
effect()
}
prevDeps.current = deps
hasInitialized.current = true
}, deps)
}

View File

@@ -976,18 +976,14 @@ describe('admin1', () => {
await page.locator('#field-title').fill(title)
await saveDocAndAssert(page)
await page
.locator(
'.field-type.relationship .relationship--single-value__drawer-toggler.doc-drawer__toggler',
)
.locator('.field-type.relationship .relationship--single-value__drawer-toggler')
.click()
await wait(500)
const drawer1Content = page.locator('[id^=doc-drawer_posts_1_] .drawer__content')
await expect(drawer1Content).toBeVisible()
const drawerLeft = await drawer1Content.boundingBox().then((box) => box.x)
await drawer1Content
.locator(
'.field-type.relationship .relationship--single-value__drawer-toggler.doc-drawer__toggler',
)
.locator('.field-type.relationship .relationship--single-value__drawer-toggler')
.click()
const drawer2Content = page.locator('[id^=doc-drawer_posts_2_] .drawer__content')
await expect(drawer2Content).toBeVisible()

View File

@@ -10,6 +10,7 @@ import {
IndentFeature,
InlineCodeFeature,
ItalicFeature,
lexicalEditor,
LinkFeature,
OrderedListFeature,
ParagraphFeature,
@@ -21,7 +22,6 @@ import {
UnderlineFeature,
UnorderedListFeature,
UploadFeature,
lexicalEditor,
} from '@payloadcms/richtext-lexical'
// import { slateEditor } from '@payloadcms/richtext-slate'
import { buildConfig } from 'payload'
@@ -164,7 +164,9 @@ export async function buildConfigWithDefaults(
}
if (process.env.PAYLOAD_DISABLE_ADMIN === 'true') {
if (typeof config.admin !== 'object') config.admin = {}
if (typeof config.admin !== 'object') {
config.admin = {}
}
config.admin.disable = true
}

View File

@@ -6,6 +6,7 @@ import type { CollectionConfig, FilterOptionsProps } from 'payload'
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import { devUser } from '../credentials.js'
import { VersionedRelationshipFieldCollection } from './collections/VersionedRelationshipField/index.js'
import {
collection1Slug,
collection2Slug,
@@ -21,7 +22,6 @@ import {
slug,
videoCollectionSlug,
} from './collectionSlugs.js'
import { VersionedRelationshipFieldCollection } from './collections/VersionedRelationshipField/index.js'
export interface FieldsRelationship {
createdAt: Date

View File

@@ -26,6 +26,7 @@ import {
saveDocAndAssert,
} from '../helpers.js'
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
import { trackNetworkRequests } from '../helpers/e2e/trackNetworkRequests.js'
import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT_LONG } from '../playwright.config.js'
import {
@@ -169,6 +170,22 @@ describe('fields - relationship', () => {
await saveDocAndAssert(page)
})
test('should only make a single request for relationship values', async () => {
await page.goto(url.create)
const field = page.locator('#field-relationship')
await expect(field.locator('input')).toBeEnabled()
await field.click({ delay: 100 })
const options = page.locator('.rs__option')
await expect(options).toHaveCount(2) // two docs
await options.nth(0).click()
await expect(field).toContainText(relationOneDoc.id)
await saveDocAndAssert(page)
await wait(200)
await trackNetworkRequests(page, `/api/${relationOneSlug}`, {
beforePoll: async () => await page.reload(),
})
})
// TODO: Flaky test in CI - fix this. https://github.com/payloadcms/payload/actions/runs/8559547748/job/23456806365
test.skip('should create relations to multiple collections', async () => {
await page.goto(url.create)

View File

@@ -194,6 +194,33 @@ const GroupFields: CollectionConfig = {
},
],
},
{
name: 'camelCaseGroup',
type: 'group',
fields: [
{
name: 'array',
type: 'array',
fields: [
{
type: 'text',
name: 'text',
localized: true,
},
{
type: 'array',
name: 'array',
fields: [
{
type: 'text',
name: 'text',
},
],
},
],
},
],
},
],
}

View File

@@ -79,6 +79,33 @@ const NumberFields: CollectionConfig = {
hasMany: true,
minRows: 2,
},
{
name: 'array',
type: 'array',
fields: [
{
name: 'numbers',
type: 'number',
hasMany: true,
},
],
},
{
name: 'blocks',
type: 'blocks',
blocks: [
{
slug: 'block',
fields: [
{
name: 'numbers',
type: 'number',
hasMany: true,
},
],
},
],
},
],
}

View File

@@ -251,6 +251,32 @@ const TabsFields: CollectionConfig = {
},
],
},
{
name: 'camelCaseTab',
fields: [
{
name: 'array',
type: 'array',
fields: [
{
type: 'text',
name: 'text',
localized: true,
},
{
type: 'array',
name: 'array',
fields: [
{
type: 'text',
name: 'text',
},
],
},
],
},
],
},
],
},
{

View File

@@ -134,6 +134,33 @@ const TextFields: CollectionConfig = {
disableListFilter: true,
},
},
{
name: 'array',
type: 'array',
fields: [
{
name: 'texts',
type: 'text',
hasMany: true,
},
],
},
{
name: 'blocks',
type: 'blocks',
blocks: [
{
slug: 'block',
fields: [
{
name: 'texts',
type: 'text',
hasMany: true,
},
],
},
],
},
],
}

View File

@@ -151,6 +151,180 @@ describe('Fields', () => {
expect(hitResult).toBeDefined()
expect(missResult).toBeFalsy()
})
it('should query hasMany within an array', async () => {
const docFirst = await payload.create({
collection: 'text-fields',
data: {
text: 'required',
array: [
{
texts: ['text_1', 'text_2'],
},
],
},
})
const docSecond = await payload.create({
collection: 'text-fields',
data: {
text: 'required',
array: [
{
texts: ['text_other', 'text_2'],
},
],
},
})
const resEqualsFull = await payload.find({
collection: 'text-fields',
where: {
'array.texts': {
equals: 'text_2',
},
},
sort: '-createdAt',
})
expect(resEqualsFull.docs.find((res) => res.id === docFirst.id)).toBeDefined()
expect(resEqualsFull.docs.find((res) => res.id === docSecond.id)).toBeDefined()
expect(resEqualsFull.totalDocs).toBe(2)
const resEqualsFirst = await payload.find({
collection: 'text-fields',
where: {
'array.texts': {
equals: 'text_1',
},
},
sort: '-createdAt',
})
expect(resEqualsFirst.docs.find((res) => res.id === docFirst.id)).toBeDefined()
expect(resEqualsFirst.docs.find((res) => res.id === docSecond.id)).toBeUndefined()
expect(resEqualsFirst.totalDocs).toBe(1)
const resContainsSecond = await payload.find({
collection: 'text-fields',
where: {
'array.texts': {
contains: 'text_other',
},
},
sort: '-createdAt',
})
expect(resContainsSecond.docs.find((res) => res.id === docFirst.id)).toBeUndefined()
expect(resContainsSecond.docs.find((res) => res.id === docSecond.id)).toBeDefined()
expect(resContainsSecond.totalDocs).toBe(1)
const resInSecond = await payload.find({
collection: 'text-fields',
where: {
'array.texts': {
in: ['text_other'],
},
},
sort: '-createdAt',
})
expect(resInSecond.docs.find((res) => res.id === docFirst.id)).toBeUndefined()
expect(resInSecond.docs.find((res) => res.id === docSecond.id)).toBeDefined()
expect(resInSecond.totalDocs).toBe(1)
})
it('should query hasMany within blocks', async () => {
const docFirst = await payload.create({
collection: 'text-fields',
data: {
text: 'required',
blocks: [
{
blockType: 'block',
texts: ['text_1', 'text_2'],
},
],
},
})
const docSecond = await payload.create({
collection: 'text-fields',
data: {
text: 'required',
blocks: [
{
blockType: 'block',
texts: ['text_other', 'text_2'],
},
],
},
})
const resEqualsFull = await payload.find({
collection: 'text-fields',
where: {
'blocks.texts': {
equals: 'text_2',
},
},
sort: '-createdAt',
})
expect(resEqualsFull.docs.find((res) => res.id === docFirst.id)).toBeDefined()
expect(resEqualsFull.docs.find((res) => res.id === docSecond.id)).toBeDefined()
expect(resEqualsFull.totalDocs).toBe(2)
const resEqualsFirst = await payload.find({
collection: 'text-fields',
where: {
'blocks.texts': {
equals: 'text_1',
},
},
sort: '-createdAt',
})
expect(resEqualsFirst.docs.find((res) => res.id === docFirst.id)).toBeDefined()
expect(resEqualsFirst.docs.find((res) => res.id === docSecond.id)).toBeUndefined()
expect(resEqualsFirst.totalDocs).toBe(1)
const resContainsSecond = await payload.find({
collection: 'text-fields',
where: {
'blocks.texts': {
contains: 'text_other',
},
},
sort: '-createdAt',
})
expect(resContainsSecond.docs.find((res) => res.id === docFirst.id)).toBeUndefined()
expect(resContainsSecond.docs.find((res) => res.id === docSecond.id)).toBeDefined()
expect(resContainsSecond.totalDocs).toBe(1)
const resInSecond = await payload.find({
collection: 'text-fields',
where: {
'blocks.texts': {
in: ['text_other'],
},
},
sort: '-createdAt',
})
expect(resInSecond.docs.find((res) => res.id === docFirst.id)).toBeUndefined()
expect(resInSecond.docs.find((res) => res.id === docSecond.id)).toBeDefined()
expect(resInSecond.totalDocs).toBe(1)
})
})
describe('relationship', () => {
@@ -515,6 +689,140 @@ describe('Fields', () => {
})
})
it('should query hasMany within an array', async () => {
const docFirst = await payload.create({
collection: 'number-fields',
data: {
array: [
{
numbers: [10, 30],
},
],
},
})
const docSecond = await payload.create({
collection: 'number-fields',
data: {
array: [
{
numbers: [10, 40],
},
],
},
})
const resEqualsFull = await payload.find({
collection: 'number-fields',
where: {
'array.numbers': {
equals: 10,
},
},
})
expect(resEqualsFull.docs.find((res) => res.id === docFirst.id)).toBeDefined()
expect(resEqualsFull.docs.find((res) => res.id === docSecond.id)).toBeDefined()
expect(resEqualsFull.totalDocs).toBe(2)
const resEqualsFirst = await payload.find({
collection: 'number-fields',
where: {
'array.numbers': {
equals: 30,
},
},
})
expect(resEqualsFirst.docs.find((res) => res.id === docFirst.id)).toBeDefined()
expect(resEqualsFirst.docs.find((res) => res.id === docSecond.id)).toBeUndefined()
expect(resEqualsFirst.totalDocs).toBe(1)
const resInSecond = await payload.find({
collection: 'number-fields',
where: {
'array.numbers': {
in: [40],
},
},
})
expect(resInSecond.docs.find((res) => res.id === docFirst.id)).toBeUndefined()
expect(resInSecond.docs.find((res) => res.id === docSecond.id)).toBeDefined()
expect(resInSecond.totalDocs).toBe(1)
})
it('should query hasMany within blocks', async () => {
const docFirst = await payload.create({
collection: 'number-fields',
data: {
blocks: [
{
blockType: 'block',
numbers: [10, 30],
},
],
},
})
const docSecond = await payload.create({
collection: 'number-fields',
data: {
blocks: [
{
blockType: 'block',
numbers: [10, 40],
},
],
},
})
const resEqualsFull = await payload.find({
collection: 'number-fields',
where: {
'blocks.numbers': {
equals: 10,
},
},
})
expect(resEqualsFull.docs.find((res) => res.id === docFirst.id)).toBeDefined()
expect(resEqualsFull.docs.find((res) => res.id === docSecond.id)).toBeDefined()
expect(resEqualsFull.totalDocs).toBe(2)
const resEqualsFirst = await payload.find({
collection: 'number-fields',
where: {
'blocks.numbers': {
equals: 30,
},
},
})
expect(resEqualsFirst.docs.find((res) => res.id === docFirst.id)).toBeDefined()
expect(resEqualsFirst.docs.find((res) => res.id === docSecond.id)).toBeUndefined()
expect(resEqualsFirst.totalDocs).toBe(1)
const resInSecond = await payload.find({
collection: 'number-fields',
where: {
'blocks.numbers': {
in: [40],
},
},
})
expect(resInSecond.docs.find((res) => res.id === docFirst.id)).toBeUndefined()
expect(resInSecond.docs.find((res) => res.id === docSecond.id)).toBeDefined()
expect(resInSecond.totalDocs).toBe(1)
})
if (isMongoose(payload)) {
describe('indexes', () => {
let indexes
@@ -949,6 +1257,30 @@ describe('Fields', () => {
expect(resultIDs).toContain(hit.id)
expect(resultIDs).not.toContain(miss.id)
})
it('should insert/read camelCase group with nested arrays + localized', async () => {
const res = await payload.create({
collection: 'group-fields',
data: {
group: { text: 'required' },
camelCaseGroup: {
array: [
{
text: 'text',
array: [
{
text: 'nested',
},
],
},
],
},
},
})
expect(res.camelCaseGroup.array[0].text).toBe('text')
expect(res.camelCaseGroup.array[0].array[0].text).toBe('nested')
})
})
describe('tabs', () => {
@@ -1026,6 +1358,37 @@ describe('Fields', () => {
expect(doc.potentiallyEmptyGroup).toBeDefined()
})
it('should insert/read camelCase tab with nested arrays + localized', async () => {
const res = await payload.create({
collection: 'tabs-fields',
data: {
anotherText: 'req',
array: [{ text: 'req' }],
blocks: [{ blockType: 'content', text: 'req' }],
group: { number: 1 },
numberInRow: 1,
textInRow: 'req',
tab: { array: [{ text: 'req' }] },
camelCaseTab: {
array: [
{
text: 'text',
array: [
{
text: 'nested',
},
],
},
],
},
},
})
expect(res.camelCaseTab.array[0].text).toBe('text')
expect(res.camelCaseTab.array[0].array[0].text).toBe('nested')
})
})
describe('blocks', () => {

View File

@@ -728,6 +728,20 @@ export interface TextField {
withMaxRows?: string[] | null;
disableListColumnText?: string | null;
disableListFilterText?: string | null;
array?:
| {
texts?: string[] | null;
id?: string | null;
}[]
| null;
blocks?:
| {
texts?: string[] | null;
id?: string | null;
blockName?: string | null;
blockType: 'block';
}[]
| null;
updatedAt: string;
createdAt: string;
}
@@ -917,6 +931,20 @@ export interface GroupField {
};
};
};
camelCaseGroup?: {
array?:
| {
text?: string | null;
array?:
| {
text?: string | null;
id?: string | null;
}[]
| null;
id?: string | null;
}[]
| null;
};
updatedAt: string;
createdAt: string;
}
@@ -1006,6 +1034,20 @@ export interface NumberField {
validatesHasMany?: number[] | null;
localizedHasMany?: number[] | null;
withMinRows?: number[] | null;
array?:
| {
numbers?: number[] | null;
id?: string | null;
}[]
| null;
blocks?:
| {
numbers?: number[] | null;
id?: string | null;
blockName?: string | null;
blockType: 'block';
}[]
| null;
updatedAt: string;
createdAt: string;
}
@@ -1339,6 +1381,20 @@ export interface TabsField {
afterChange?: boolean | null;
afterRead?: boolean | null;
};
camelCaseTab?: {
array?:
| {
text?: string | null;
array?:
| {
text?: string | null;
id?: string | null;
}[]
| null;
id?: string | null;
}[]
| null;
};
textarea?: string | null;
anotherText: string;
nestedTab?: {

View File

@@ -0,0 +1,49 @@
import type { Page, Request } from '@playwright/test'
import { expect } from '@playwright/test'
// Allows you to test the number of network requests triggered by an action
// This can be used to ensure various actions do not trigger unnecessary requests
// For example, an effect within a component might fetch data multiple times unnecessarily
export const trackNetworkRequests = async (
page: Page,
url: string,
options?: {
allowedNumberOfRequests?: number
beforePoll?: () => Promise<any> | void
interval?: number
timeout?: number
},
): Promise<Array<Request>> => {
const { beforePoll, allowedNumberOfRequests = 1, timeout = 5000, interval = 1000 } = options || {}
const matchedRequests = []
// begin tracking network requests
page.on('request', (request) => {
if (request.url().includes(url)) {
matchedRequests.push(request)
}
})
if (typeof beforePoll === 'function') {
await beforePoll()
}
const startTime = Date.now()
// continuously poll even after a request has been matched
// this will ensure no subsequent requests are made
// such as a result of a `useEffect` within a component
while (Date.now() - startTime < timeout) {
if (matchedRequests.length > 0) {
expect(matchedRequests.length).toBeLessThanOrEqual(allowedNumberOfRequests)
}
await new Promise((resolve) => setTimeout(resolve, interval))
}
expect(matchedRequests.length).toBe(allowedNumberOfRequests)
return matchedRequests
}