Compare commits
14 Commits
v3.0.0-bet
...
v3.0.0-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b82196f01 | ||
|
|
ead12c8a49 | ||
|
|
6253ec5d1a | ||
|
|
f9ae56ec88 | ||
|
|
0688c2b79d | ||
|
|
c6246618ba | ||
|
|
b69826a81e | ||
|
|
e80da7cb75 | ||
|
|
6f512b6ca8 | ||
|
|
22ee8bf383 | ||
|
|
308fad8a7a | ||
|
|
6427b7eb29 | ||
|
|
3a657847f2 | ||
|
|
8212c0d65f |
11
.vscode/settings.json
vendored
11
.vscode/settings.json
vendored
@@ -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"],
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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). |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "payload-monorepo",
|
||||
"version": "3.0.0-beta.98",
|
||||
"version": "3.0.0-beta.100",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
`)
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -80,6 +80,7 @@ export const buildFindManyArgs = ({
|
||||
depth,
|
||||
fields,
|
||||
path: '',
|
||||
tablePath: '',
|
||||
topLevelArgs: result,
|
||||
topLevelTableName: tableName,
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ export const LogoutView: React.FC<
|
||||
} = initPageResult
|
||||
|
||||
return (
|
||||
<div className={`${baseClass}__wrap`}>
|
||||
<div className={`${baseClass}`}>
|
||||
<LogoutClient
|
||||
adminRoute={adminRoute}
|
||||
inactivity={inactivity}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
'use client'
|
||||
import type { ClientTranslationKeys, TFunction } from '@payloadcms/translations'
|
||||
|
||||
import * as React from 'react'
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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': {
|
||||
|
||||
@@ -21,6 +21,10 @@
|
||||
}
|
||||
|
||||
&__drawer-toggler {
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -21,6 +21,10 @@
|
||||
}
|
||||
|
||||
&__drawer-toggler {
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
41
packages/ui/src/hooks/useIgnoredEffect.ts
Normal file
41
packages/ui/src/hooks/useIgnoredEffect.ts
Normal 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)
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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?: {
|
||||
|
||||
49
test/helpers/e2e/trackNetworkRequests.ts
Normal file
49
test/helpers/e2e/trackNetworkRequests.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user