feat(next,ui): improves loading states (#6434)

This commit is contained in:
Jacob Fletcher
2024-05-29 14:01:13 -04:00
committed by GitHub
parent 043a91d719
commit 92f458dad2
94 changed files with 987 additions and 885 deletions

View File

@@ -17,7 +17,7 @@ export default withBundleAnalyzer(
ignoreBuildErrors: true,
},
experimental: {
reactCompiler: false
reactCompiler: false,
},
async redirects() {
return [

View File

@@ -57,11 +57,13 @@ export const RootLayout = async ({
})
const payload = await getPayloadHMR({ config })
const i18n: I18nClient = await initI18n({
config: config.i18n,
context: 'client',
language: languageCode,
})
const clientConfig = await createClientConfig({ config, t: i18n.t })
const dir = (rtlLanguages as unknown as AcceptedLanguages[]).includes(languageCode)

View File

@@ -47,6 +47,20 @@ export const initPage = async ({
language,
})
const languageOptions = Object.entries(payload.config.i18n.supportedLanguages || {}).reduce(
(acc, [language, languageConfig]) => {
if (Object.keys(payload.config.i18n.supportedLanguages).includes(language)) {
acc.push({
label: languageConfig.translations.general.thisLanguage,
value: language,
})
}
return acc
},
[],
)
const req = await createLocalReq(
{
fallbackLocale: null,
@@ -98,6 +112,7 @@ export const initPage = async ({
cookies,
docID,
globalConfig,
languageOptions,
locale,
permissions,
req,

View File

@@ -1,7 +1,6 @@
import type { SanitizedConfig } from 'payload/types'
const authRouteKeys: (keyof SanitizedConfig['admin']['routes'])[] = [
'account',
'createFirstUser',
'forgot',
'login',

View File

@@ -0,0 +1,23 @@
import { Select } from '@payloadcms/ui/fields/Select'
import { useTranslation } from '@payloadcms/ui/providers/Translation'
import React from 'react'
export const LocaleSelector: React.FC<{
localeOptions: {
label: Record<string, string> | string
value: string
}[]
onChange: (value: string) => void
}> = ({ localeOptions, onChange }) => {
const { t } = useTranslation()
return (
<Select
label={t('general:locale')}
name="locale"
onChange={(value) => onChange(value)}
options={localeOptions}
path="locale"
/>
)
}

View File

@@ -4,7 +4,6 @@ import { CopyToClipboard } from '@payloadcms/ui/elements/CopyToClipboard'
import { Gutter } from '@payloadcms/ui/elements/Gutter'
import { Checkbox } from '@payloadcms/ui/fields/Checkbox'
import { NumberField as NumberInput } from '@payloadcms/ui/fields/Number'
import { Select } from '@payloadcms/ui/fields/Select'
import { Form } from '@payloadcms/ui/forms/Form'
import { MinimizeMaximize } from '@payloadcms/ui/icons/MinimizeMaximize'
import { SetViewActions } from '@payloadcms/ui/providers/Actions'
@@ -19,6 +18,7 @@ import * as React from 'react'
import { toast } from 'react-toastify'
import { SetDocumentStepNav } from '../Edit/Default/SetDocumentStepNav/index.js'
import { LocaleSelector } from './LocaleSelector/index.js'
import { RenderJSON } from './RenderJSON/index.js'
import './index.scss'
@@ -173,15 +173,7 @@ export const APIViewClient: React.FC = () => {
path="authenticated"
/>
</div>
{localeOptions && (
<Select
label={t('general:locale')}
name="locale"
onChange={(value) => setLocale(value)}
options={localeOptions}
path="locale"
/>
)}
{localeOptions && <LocaleSelector localeOptions={localeOptions} onChange={setLocale} />}
<NumberInput
label={t('general:depth')}
max={10}

View File

@@ -0,0 +1,25 @@
'use client'
import type { LanguageOptions } from 'payload/types'
import { ReactSelect } from '@payloadcms/ui/elements/ReactSelect'
import { useTranslation } from '@payloadcms/ui/providers/Translation'
import React from 'react'
export const LanguageSelector: React.FC<{
languageOptions: LanguageOptions
}> = (props) => {
const { languageOptions } = props
const { i18n, switchLanguage } = useTranslation()
return (
<ReactSelect
inputId="language-select"
onChange={async ({ value }) => {
await switchLanguage(value)
}}
options={languageOptions}
value={languageOptions.find((language) => language.value === i18n.language)}
/>
)
}

View File

@@ -1,34 +1,28 @@
'use client'
import { ReactSelect } from '@payloadcms/ui/elements/ReactSelect'
import type { I18n } from '@payloadcms/translations'
import type { LanguageOptions } from 'payload/types'
import { FieldLabel } from '@payloadcms/ui/forms/FieldLabel'
import { useTranslation } from '@payloadcms/ui/providers/Translation'
import React from 'react'
import { ToggleTheme } from '../ToggleTheme/index.js'
import { LanguageSelector } from './LanguageSelector.js'
import './index.scss'
const baseClass = 'payload-settings'
export const Settings: React.FC<{
className?: string
i18n: I18n
languageOptions: LanguageOptions
}> = (props) => {
const { className } = props
const { i18n, languageOptions, switchLanguage, t } = useTranslation()
const { className, i18n, languageOptions } = props
return (
<div className={[baseClass, className].filter(Boolean).join(' ')}>
<h3>{t('general:payloadSettings')}</h3>
<h3>{i18n.t('general:payloadSettings')}</h3>
<div className={`${baseClass}__language`}>
<FieldLabel htmlFor="language-select" label={t('general:language')} />
<ReactSelect
inputId="language-select"
onChange={async ({ value }) => {
await switchLanguage(value)
}}
options={languageOptions}
value={languageOptions.find((language) => language.value === i18n.language)}
/>
<FieldLabel htmlFor="language-select" label={i18n.t('general:language')} />
<LanguageSelector languageOptions={languageOptions} />
</div>
<ToggleTheme />
</div>

View File

@@ -9,6 +9,7 @@ import { FormQueryParamsProvider } from '@payloadcms/ui/providers/FormQueryParam
import { notFound } from 'next/navigation.js'
import React from 'react'
import { getDocumentData } from '../Document/getDocumentData.js'
import { getDocumentPermissions } from '../Document/getDocumentPermissions.js'
import { EditView } from '../Edit/index.js'
import { Settings } from './Settings/index.js'
@@ -21,6 +22,7 @@ export const Account: React.FC<AdminViewProps> = async ({
searchParams,
}) => {
const {
languageOptions,
locale,
permissions,
req,
@@ -49,6 +51,13 @@ export const Account: React.FC<AdminViewProps> = async ({
req,
})
const { data, formState } = await getDocumentData({
id: user.id,
collectionConfig,
locale,
req,
})
const viewComponentProps: ServerSideEditViewProps = {
initPageResult,
params,
@@ -58,7 +67,7 @@ export const Account: React.FC<AdminViewProps> = async ({
return (
<DocumentInfoProvider
AfterFields={<Settings />}
AfterFields={<Settings i18n={i18n} languageOptions={languageOptions} />}
action={`${serverURL}${api}/${userSlug}${user?.id ? `/${user.id}` : ''}`}
apiURL={`${serverURL}${api}/${userSlug}${user?.id ? `/${user.id}` : ''}`}
collectionSlug={userSlug}
@@ -66,6 +75,8 @@ export const Account: React.FC<AdminViewProps> = async ({
hasPublishPermission={hasPublishPermission}
hasSavePermission={hasSavePermission}
id={user?.id.toString()}
initialData={data}
initialState={formState}
isEditing
>
<DocumentHeader

View File

@@ -1,144 +0,0 @@
'use client'
import type { EntityToGroup, Group } from '@payloadcms/ui/utilities/groupNavItems'
import type { Permissions } from 'payload/auth'
import type { VisibleEntities } from 'payload/types'
import { getTranslation } from '@payloadcms/translations'
import { Button } from '@payloadcms/ui/elements/Button'
import { Card } from '@payloadcms/ui/elements/Card'
import { SetViewActions } from '@payloadcms/ui/providers/Actions'
import { useAuth } from '@payloadcms/ui/providers/Auth'
import { useConfig } from '@payloadcms/ui/providers/Config'
import { useTranslation } from '@payloadcms/ui/providers/Translation'
import { EntityType, groupNavItems } from '@payloadcms/ui/utilities/groupNavItems'
import React, { Fragment, useEffect, useState } from 'react'
import './index.scss'
const baseClass = 'dashboard'
export const DefaultDashboardClient: React.FC<{
Link: React.ComponentType
permissions: Permissions
visibleEntities: VisibleEntities
}> = ({ Link, permissions, visibleEntities }) => {
const config = useConfig()
const {
collections: collectionsConfig,
globals: globalsConfig,
routes: { admin },
} = config
const { user } = useAuth()
const { i18n, t } = useTranslation()
const [groups, setGroups] = useState<Group[]>([])
useEffect(() => {
const collections = collectionsConfig.filter(
(collection) =>
permissions?.collections?.[collection.slug]?.read?.permission &&
visibleEntities.collections.includes(collection.slug),
)
const globals = globalsConfig.filter(
(global) =>
permissions?.globals?.[global.slug]?.read?.permission &&
visibleEntities.globals.includes(global.slug),
)
setGroups(
groupNavItems(
[
...(collections.map((collection) => {
const entityToGroup: EntityToGroup = {
type: EntityType.collection,
entity: collection,
}
return entityToGroup
}) ?? []),
...(globals.map((global) => {
const entityToGroup: EntityToGroup = {
type: EntityType.global,
entity: global,
}
return entityToGroup
}) ?? []),
],
permissions,
i18n,
),
)
}, [permissions, user, i18n, visibleEntities, collectionsConfig, globalsConfig])
return (
<Fragment>
<SetViewActions actions={[]} />
{groups.map(({ entities, label }, groupIndex) => {
return (
<div className={`${baseClass}__group`} key={groupIndex}>
<h2 className={`${baseClass}__label`}>{label}</h2>
<ul className={`${baseClass}__card-list`}>
{entities.map(({ type, entity }, entityIndex) => {
let title: string
let buttonAriaLabel: string
let createHREF: string
let href: string
let hasCreatePermission: boolean
if (type === EntityType.collection) {
title = getTranslation(entity.labels.plural, i18n)
buttonAriaLabel = t('general:showAllLabel', { label: title })
href = `${admin}/collections/${entity.slug}`
createHREF = `${admin}/collections/${entity.slug}/create`
hasCreatePermission = permissions?.collections?.[entity.slug]?.create?.permission
}
if (type === EntityType.global) {
title = getTranslation(entity.label, i18n)
buttonAriaLabel = t('general:editLabel', {
label: getTranslation(entity.label, i18n),
})
href = `${admin}/globals/${entity.slug}`
}
return (
<li key={entityIndex}>
<Card
Link={Link}
actions={
hasCreatePermission && type === EntityType.collection ? (
<Button
Link={Link}
aria-label={t('general:createNewLabel', {
label: getTranslation(entity.labels.singular, i18n),
})}
buttonStyle="icon-label"
el="link"
icon="plus"
iconStyle="with-border"
round
to={createHREF}
/>
) : undefined
}
buttonAriaLabel={buttonAriaLabel}
href={href}
id={`card-${entity.slug}`}
title={title}
titleAs="h3"
/>
</li>
)
})}
</ul>
</div>
)
})}
</Fragment>
)
}

View File

@@ -2,20 +2,23 @@ import type { Permissions } from 'payload/auth'
import type { ServerProps } from 'payload/config'
import type { VisibleEntities } from 'payload/types'
import { getTranslation } from '@payloadcms/translations'
import { Button } from '@payloadcms/ui/elements/Button'
import { Card } from '@payloadcms/ui/elements/Card'
import { Gutter } from '@payloadcms/ui/elements/Gutter'
import { SetStepNav } from '@payloadcms/ui/elements/StepNav'
import { WithServerSideProps } from '@payloadcms/ui/elements/WithServerSideProps'
import { SetViewActions } from '@payloadcms/ui/providers/Actions'
import React from 'react'
import { EntityType, type groupNavItems } from '@payloadcms/ui/utilities/groupNavItems'
import React, { Fragment } from 'react'
import { DefaultDashboardClient } from './index.client.js'
import './index.scss'
const baseClass = 'dashboard'
export type DashboardProps = ServerProps & {
Link: React.ComponentType<any>
navGroups?: ReturnType<typeof groupNavItems>
permissions: Permissions
visibleEntities: VisibleEntities
}
@@ -24,20 +27,22 @@ export const DefaultDashboard: React.FC<DashboardProps> = (props) => {
const {
Link,
i18n,
i18n: { t },
locale,
navGroups,
params,
payload: {
config: {
admin: {
components: { afterDashboard, beforeDashboard },
},
routes: { admin: adminRoute },
},
},
payload,
permissions,
searchParams,
user,
visibleEntities,
} = props
const BeforeDashboards = Array.isArray(beforeDashboard)
@@ -82,12 +87,75 @@ export const DefaultDashboard: React.FC<DashboardProps> = (props) => {
<SetViewActions actions={[]} />
<Gutter className={`${baseClass}__wrap`}>
{Array.isArray(BeforeDashboards) && BeforeDashboards.map((Component) => Component)}
<Fragment>
<SetViewActions actions={[]} />
{!navGroups || navGroups?.length === 0 ? (
<p>no nav groups....</p>
) : (
navGroups.map(({ entities, label }, groupIndex) => {
return (
<div className={`${baseClass}__group`} key={groupIndex}>
<h2 className={`${baseClass}__label`}>{label}</h2>
<ul className={`${baseClass}__card-list`}>
{entities.map(({ type, entity }, entityIndex) => {
let title: string
let buttonAriaLabel: string
let createHREF: string
let href: string
let hasCreatePermission: boolean
<DefaultDashboardClient
Link={Link}
permissions={permissions}
visibleEntities={visibleEntities}
/>
if (type === EntityType.collection) {
title = getTranslation(entity.labels.plural, i18n)
buttonAriaLabel = t('general:showAllLabel', { label: title })
href = `${adminRoute}/collections/${entity.slug}`
createHREF = `${adminRoute}/collections/${entity.slug}/create`
hasCreatePermission =
permissions?.collections?.[entity.slug]?.create?.permission
}
if (type === EntityType.global) {
title = getTranslation(entity.label, i18n)
buttonAriaLabel = t('general:editLabel', {
label: getTranslation(entity.label, i18n),
})
href = `${adminRoute}/globals/${entity.slug}`
}
return (
<li key={entityIndex}>
<Card
Link={Link}
actions={
hasCreatePermission && type === EntityType.collection ? (
<Button
Link={Link}
aria-label={t('general:createNewLabel', {
label: getTranslation(entity.labels.singular, i18n),
})}
buttonStyle="icon-label"
el="link"
icon="plus"
iconStyle="with-border"
round
to={createHREF}
/>
) : undefined
}
buttonAriaLabel={buttonAriaLabel}
href={href}
id={`card-${entity.slug}`}
title={title}
titleAs="h3"
/>
</li>
)
})}
</ul>
</div>
)
})
)}
</Fragment>
{Array.isArray(AfterDashboards) && AfterDashboards.map((Component) => Component)}
</Gutter>
</div>

View File

@@ -1,7 +1,9 @@
import type { EntityToGroup } from '@payloadcms/ui/utilities/groupNavItems'
import type { AdminViewProps } from 'payload/types'
import { HydrateClientUser } from '@payloadcms/ui/elements/HydrateClientUser'
import { RenderCustomComponent } from '@payloadcms/ui/elements/RenderCustomComponent'
import { EntityType, groupNavItems } from '@payloadcms/ui/utilities/groupNavItems'
import LinkImport from 'next/link.js'
import React, { Fragment } from 'react'
@@ -28,10 +30,46 @@ export const Dashboard: React.FC<AdminViewProps> = ({ initPageResult, params, se
const CustomDashboardComponent = config.admin.components?.views?.Dashboard
const collections = config.collections.filter(
(collection) =>
permissions?.collections?.[collection.slug]?.read?.permission &&
visibleEntities.collections.includes(collection.slug),
)
const globals = config.globals.filter(
(global) =>
permissions?.globals?.[global.slug]?.read?.permission &&
visibleEntities.globals.includes(global.slug),
)
const navGroups = groupNavItems(
[
...(collections.map((collection) => {
const entityToGroup: EntityToGroup = {
type: EntityType.collection,
entity: collection,
}
return entityToGroup
}) ?? []),
...(globals.map((global) => {
const entityToGroup: EntityToGroup = {
type: EntityType.global,
entity: global,
}
return entityToGroup
}) ?? []),
],
permissions,
i18n,
)
const viewComponentProps: DashboardProps = {
Link,
i18n,
locale,
navGroups,
params,
payload,
permissions,

View File

@@ -1,41 +1,45 @@
import type {
Data,
Payload,
PayloadRequest,
PayloadRequestWithData,
SanitizedCollectionConfig,
SanitizedGlobalConfig,
} from 'payload/types'
import { buildFormState } from '@payloadcms/ui/utilities/buildFormState'
import { reduceFieldsToValues } from '@payloadcms/ui/utilities/reduceFieldsToValues'
export const getDocumentData = async (args: {
collectionConfig?: SanitizedCollectionConfig
globalConfig?: SanitizedGlobalConfig
id?: number | string
locale: Locale
payload: Payload
req: PayloadRequest
req: PayloadRequestWithData
}): Promise<Data> => {
const { id, collectionConfig, globalConfig, locale, payload, req } = args
const { id, collectionConfig, globalConfig, locale, req } = args
let data: Data
if (collectionConfig && id !== undefined && id !== null) {
data = await payload.findByID({
id,
collection: collectionConfig.slug,
depth: 0,
locale: locale.code,
req,
try {
const formState = await buildFormState({
req: {
...req,
data: {
id,
collectionSlug: collectionConfig?.slug,
globalSlug: globalConfig?.slug,
locale: locale.code,
operation: (collectionConfig && id) || globalConfig ? 'update' : 'create',
schemaPath: collectionConfig?.slug || globalConfig?.slug,
},
},
})
}
if (globalConfig) {
data = await payload.findGlobal({
slug: globalConfig.slug,
depth: 0,
locale: locale.code,
req,
})
}
const data = reduceFieldsToValues(formState, true)
return data
return {
data,
formState,
}
} catch (error) {
console.error('Error getting document data', error) // eslint-disable-line no-console
return {}
}
}

View File

@@ -7,6 +7,8 @@ import type {
SanitizedGlobalConfig,
} from 'payload/types'
import { notFound } from 'next/navigation.js'
import { APIView as DefaultAPIView } from '../API/index.js'
import { EditView as DefaultEditView } from '../Edit/index.js'
import { LivePreviewView as DefaultLivePreviewView } from '../LivePreview/index.js'
@@ -68,63 +70,91 @@ export const getViewsFromConfig = ({
const [collectionEntity, collectionSlug, segment3, segment4, segment5, ...remainingSegments] =
routeSegments
// `../:id`, or `../create`
switch (routeSegments.length) {
case 3: {
switch (segment3) {
case 'create': {
if ('create' in docPermissions && docPermissions?.create?.permission) {
CustomView = getCustomViewByKey(views, 'Default')
DefaultView = DefaultEditView
} else {
ErrorView = UnauthorizedView
if (!docPermissions?.read?.permission) {
notFound()
} else {
// `../:id`, or `../create`
switch (routeSegments.length) {
case 3: {
switch (segment3) {
case 'create': {
if ('create' in docPermissions && docPermissions?.create?.permission) {
CustomView = getCustomViewByKey(views, 'Default')
DefaultView = DefaultEditView
} else {
ErrorView = UnauthorizedView
}
break
}
break
}
default: {
if (docPermissions?.read?.permission) {
default: {
CustomView = getCustomViewByKey(views, 'Default')
DefaultView = DefaultEditView
} else {
ErrorView = UnauthorizedView
break
}
break
}
break
}
break
}
// `../:id/api`, `../:id/preview`, `../:id/versions`, etc
case 4: {
switch (segment4) {
case 'api': {
if (collectionConfig?.admin?.hideAPIURL !== true) {
CustomView = getCustomViewByKey(views, 'API')
DefaultView = DefaultAPIView
// `../:id/api`, `../:id/preview`, `../:id/versions`, etc
case 4: {
switch (segment4) {
case 'api': {
if (collectionConfig?.admin?.hideAPIURL !== true) {
CustomView = getCustomViewByKey(views, 'API')
DefaultView = DefaultAPIView
}
break
}
break
}
case 'preview': {
if (livePreviewEnabled) {
DefaultView = DefaultLivePreviewView
case 'preview': {
if (livePreviewEnabled) {
DefaultView = DefaultLivePreviewView
}
break
}
break
}
case 'versions': {
case 'versions': {
if (docPermissions?.readVersions?.permission) {
CustomView = getCustomViewByKey(views, 'Versions')
DefaultView = DefaultVersionsView
} else {
ErrorView = UnauthorizedView
}
break
}
default: {
const baseRoute = [adminRoute, 'collections', collectionSlug, segment3]
.filter(Boolean)
.join('/')
const currentRoute = [baseRoute, segment4, segment5, ...remainingSegments]
.filter(Boolean)
.join('/')
CustomView = getCustomViewByRoute({
baseRoute,
currentRoute,
views,
})
break
}
}
break
}
// `../:id/versions/:version`, etc
default: {
if (segment4 === 'versions') {
if (docPermissions?.readVersions?.permission) {
CustomView = getCustomViewByKey(views, 'Versions')
DefaultView = DefaultVersionsView
CustomView = getCustomViewByKey(views, 'Version')
DefaultView = DefaultVersionView
} else {
ErrorView = UnauthorizedView
}
break
}
default: {
const baseRoute = [adminRoute, 'collections', collectionSlug, segment3]
} else {
const baseRoute = [adminRoute, collectionEntity, collectionSlug, segment3]
.filter(Boolean)
.join('/')
@@ -137,37 +167,9 @@ export const getViewsFromConfig = ({
currentRoute,
views,
})
break
}
break
}
break
}
// `../:id/versions/:version`, etc
default: {
if (segment4 === 'versions') {
if (docPermissions?.readVersions?.permission) {
CustomView = getCustomViewByKey(views, 'Version')
DefaultView = DefaultVersionView
} else {
ErrorView = UnauthorizedView
}
} else {
const baseRoute = [adminRoute, collectionEntity, collectionSlug, segment3]
.filter(Boolean)
.join('/')
const currentRoute = [baseRoute, segment4, segment5, ...remainingSegments]
.filter(Boolean)
.join('/')
CustomView = getCustomViewByRoute({
baseRoute,
currentRoute,
views,
})
}
break
}
}
}
@@ -185,81 +187,81 @@ export const getViewsFromConfig = ({
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [globalEntity, globalSlug, segment3, ...remainingSegments] = routeSegments
switch (routeSegments.length) {
case 2: {
if (docPermissions?.read?.permission) {
if (!docPermissions?.read?.permission) {
notFound()
} else {
switch (routeSegments.length) {
case 2: {
CustomView = getCustomViewByKey(views, 'Default')
DefaultView = DefaultEditView
} else {
ErrorView = UnauthorizedView
break
}
break
}
case 3: {
// `../:slug/api`, `../:slug/preview`, `../:slug/versions`, etc
switch (segment3) {
case 'api': {
if (globalConfig?.admin?.hideAPIURL !== true) {
CustomView = getCustomViewByKey(views, 'API')
DefaultView = DefaultAPIView
case 3: {
// `../:slug/api`, `../:slug/preview`, `../:slug/versions`, etc
switch (segment3) {
case 'api': {
if (globalConfig?.admin?.hideAPIURL !== true) {
CustomView = getCustomViewByKey(views, 'API')
DefaultView = DefaultAPIView
}
break
}
break
}
case 'preview': {
if (livePreviewEnabled) {
DefaultView = DefaultLivePreviewView
case 'preview': {
if (livePreviewEnabled) {
DefaultView = DefaultLivePreviewView
}
break
}
break
}
case 'versions': {
case 'versions': {
if (docPermissions?.readVersions?.permission) {
CustomView = getCustomViewByKey(views, 'Versions')
DefaultView = DefaultVersionsView
} else {
ErrorView = UnauthorizedView
}
break
}
default: {
if (docPermissions?.read?.permission) {
CustomView = getCustomViewByKey(views, 'Default')
DefaultView = DefaultEditView
} else {
ErrorView = UnauthorizedView
}
break
}
}
break
}
default: {
// `../:slug/versions/:version`, etc
if (segment3 === 'versions') {
if (docPermissions?.readVersions?.permission) {
CustomView = getCustomViewByKey(views, 'Versions')
DefaultView = DefaultVersionsView
CustomView = getCustomViewByKey(views, 'Version')
DefaultView = DefaultVersionView
} else {
ErrorView = UnauthorizedView
}
break
}
default: {
if (docPermissions?.read?.permission) {
CustomView = getCustomViewByKey(views, 'Default')
DefaultView = DefaultEditView
} else {
ErrorView = UnauthorizedView
}
break
}
}
break
}
default: {
// `../:slug/versions/:version`, etc
if (segment3 === 'versions') {
if (docPermissions?.readVersions?.permission) {
CustomView = getCustomViewByKey(views, 'Version')
DefaultView = DefaultVersionView
} else {
ErrorView = UnauthorizedView
const baseRoute = [adminRoute, 'globals', globalSlug].filter(Boolean).join('/')
const currentRoute = [baseRoute, segment3, ...remainingSegments]
.filter(Boolean)
.join('/')
CustomView = getCustomViewByRoute({
baseRoute,
currentRoute,
views,
})
}
} else {
const baseRoute = [adminRoute, 'globals', globalSlug].filter(Boolean).join('/')
const currentRoute = [baseRoute, segment3, ...remainingSegments]
.filter(Boolean)
.join('/')
CustomView = getCustomViewByRoute({
baseRoute,
currentRoute,
views,
})
break
}
break
}
}
}

View File

@@ -63,12 +63,11 @@ export const Document: React.FC<AdminViewProps> = async ({
let apiURL: string
let action: string
const data = await getDocumentData({
const { data, formState } = await getDocumentData({
id,
collectionConfig,
globalConfig,
locale,
payload,
req,
})
@@ -191,6 +190,8 @@ export const Document: React.FC<AdminViewProps> = async ({
hasPublishPermission={hasPublishPermission}
hasSavePermission={hasSavePermission}
id={id}
initialData={data}
initialState={formState}
isEditing={isEditing}
>
{!ViewOverride && (

View File

@@ -25,7 +25,7 @@ export const APIKey: React.FC<{ enabled: boolean; readOnly?: boolean }> = ({
const { t } = useTranslation()
const config = useConfig()
const apiKey = useFormFields(([fields]) => fields[path])
const apiKey = useFormFields(([fields]) => (fields && fields[path]) || null)
const validate = (val) =>
text(val, {

View File

@@ -7,6 +7,7 @@ import { Email } from '@payloadcms/ui/fields/Email'
import { Password } from '@payloadcms/ui/fields/Password'
import { useFormFields, useFormModified } from '@payloadcms/ui/forms/Form'
import { useConfig } from '@payloadcms/ui/providers/Config'
import { useDocumentInfo } from '@payloadcms/ui/providers/DocumentInfo'
import { useTranslation } from '@payloadcms/ui/providers/Translation'
import React, { useCallback, useEffect, useState } from 'react'
import { toast } from 'react-toastify'
@@ -32,10 +33,11 @@ export const Auth: React.FC<Props> = (props) => {
} = props
const [changingPassword, setChangingPassword] = useState(requirePassword)
const enableAPIKey = useFormFields(([fields]) => fields.enableAPIKey)
const enableAPIKey = useFormFields(([fields]) => (fields && fields?.enableAPIKey) || null)
const dispatchFields = useFormFields((reducer) => reducer[1])
const modified = useFormModified()
const { i18n, t } = useTranslation()
const { isInitializing } = useDocumentInfo()
const {
routes: { api },
@@ -85,12 +87,15 @@ export const Auth: React.FC<Props> = (props) => {
return null
}
const disabled = readOnly || isInitializing
return (
<div className={[baseClass, className].filter(Boolean).join(' ')}>
{!disableLocalStrategy && (
<React.Fragment>
<Email
autoComplete="email"
disabled={disabled}
label={t('general:email')}
name="email"
readOnly={readOnly}
@@ -100,7 +105,7 @@ export const Auth: React.FC<Props> = (props) => {
<div className={`${baseClass}__changing-password`}>
<Password
autoComplete="off"
disabled={readOnly}
disabled={disabled}
label={t('authentication:newPassword')}
name="password"
required
@@ -108,12 +113,11 @@ export const Auth: React.FC<Props> = (props) => {
<ConfirmPassword disabled={readOnly} />
</div>
)}
<div className={`${baseClass}__controls`}>
{changingPassword && !requirePassword && (
<Button
buttonStyle="secondary"
disabled={readOnly}
disabled={disabled}
onClick={() => handleChangePassword(false)}
size="small"
>
@@ -123,7 +127,7 @@ export const Auth: React.FC<Props> = (props) => {
{!changingPassword && !requirePassword && (
<Button
buttonStyle="secondary"
disabled={readOnly}
disabled={disabled}
id="change-password"
onClick={() => handleChangePassword(true)}
size="small"
@@ -134,7 +138,7 @@ export const Auth: React.FC<Props> = (props) => {
{operation === 'update' && (
<Button
buttonStyle="secondary"
disabled={readOnly}
disabled={disabled}
onClick={() => unlock()}
size="small"
>
@@ -147,6 +151,7 @@ export const Auth: React.FC<Props> = (props) => {
{useAPIKey && (
<div className={`${baseClass}__api-key`}>
<Checkbox
disabled={disabled}
label={t('authentication:enableAPIKey')}
name="enableAPIKey"
readOnly={readOnly}
@@ -155,7 +160,12 @@ export const Auth: React.FC<Props> = (props) => {
</div>
)}
{verify && (
<Checkbox label={t('authentication:verified')} name="_verified" readOnly={readOnly} />
<Checkbox
disabled={disabled}
label={t('authentication:verified')}
name="_verified"
readOnly={readOnly}
/>
)}
</div>
)

View File

@@ -24,7 +24,7 @@ export const SetDocumentStepNav: React.FC<{
const view: string | undefined = props?.view || undefined
const { isEditing, title } = useDocumentInfo()
const { isEditing, isInitializing, title } = useDocumentInfo()
const { isEntityVisible } = useEntityVisibility()
const isVisible = isEntityVisible({ collectionSlug, globalSlug })
@@ -41,38 +41,41 @@ export const SetDocumentStepNav: React.FC<{
useEffect(() => {
const nav: StepNavItem[] = []
if (collectionSlug) {
nav.push({
label: getTranslation(pluralLabel, i18n),
url: isVisible ? `${admin}/collections/${collectionSlug}` : undefined,
})
if (isEditing) {
if (!isInitializing) {
if (collectionSlug) {
nav.push({
label: (useAsTitle && useAsTitle !== 'id' && title) || `${id}`,
url: isVisible ? `${admin}/collections/${collectionSlug}/${id}` : undefined,
label: getTranslation(pluralLabel, i18n),
url: isVisible ? `${admin}/collections/${collectionSlug}` : undefined,
})
} else {
if (isEditing) {
nav.push({
label: (useAsTitle && useAsTitle !== 'id' && title) || `${id}`,
url: isVisible ? `${admin}/collections/${collectionSlug}/${id}` : undefined,
})
} else {
nav.push({
label: t('general:createNew'),
})
}
} else if (globalSlug) {
nav.push({
label: t('general:createNew'),
label: title,
url: isVisible ? `${admin}/globals/${globalSlug}` : undefined,
})
}
} else if (globalSlug) {
nav.push({
label: title,
url: isVisible ? `${admin}/globals/${globalSlug}` : undefined,
})
}
if (view) {
nav.push({
label: view,
})
}
if (view) {
nav.push({
label: view,
})
}
if (drawerDepth <= 1) setStepNav(nav)
if (drawerDepth <= 1) setStepNav(nav)
}
}, [
setStepNav,
isInitializing,
isEditing,
pluralLabel,
id,

View File

@@ -3,7 +3,6 @@ import type { FormProps } from '@payloadcms/ui/forms/Form'
import { DocumentControls } from '@payloadcms/ui/elements/DocumentControls'
import { DocumentFields } from '@payloadcms/ui/elements/DocumentFields'
import { FormLoadingOverlayToggle } from '@payloadcms/ui/elements/Loading'
import { Upload } from '@payloadcms/ui/elements/Upload'
import { Form } from '@payloadcms/ui/forms/Form'
import { useAuth } from '@payloadcms/ui/providers/Auth'
@@ -14,7 +13,6 @@ import { useDocumentInfo } from '@payloadcms/ui/providers/DocumentInfo'
import { useEditDepth } from '@payloadcms/ui/providers/EditDepth'
import { useFormQueryParams } from '@payloadcms/ui/providers/FormQueryParams'
import { OperationProvider } from '@payloadcms/ui/providers/Operation'
import { useTranslation } from '@payloadcms/ui/providers/Translation'
import { getFormState } from '@payloadcms/ui/utilities/getFormState'
import { useRouter } from 'next/navigation.js'
import { useSearchParams } from 'next/navigation.js'
@@ -52,6 +50,7 @@ export const DefaultEditView: React.FC = () => {
initialData: data,
initialState,
isEditing,
isInitializing,
onSave: onSaveFromContext,
} = useDocumentInfo()
@@ -64,8 +63,6 @@ export const DefaultEditView: React.FC = () => {
const depth = useEditDepth()
const { reportUpdate } = useDocumentEvents()
const { i18n } = useTranslation()
const {
admin: { user: userSlug },
collections,
@@ -183,23 +180,13 @@ export const DefaultEditView: React.FC = () => {
action={action}
className={`${baseClass}__form`}
disableValidationOnSubmit
disabled={!hasSavePermission}
initialState={initialState}
disabled={isInitializing || !hasSavePermission}
initialState={!isInitializing && initialState}
isInitializing={isInitializing}
method={id ? 'PATCH' : 'POST'}
onChange={[onChange]}
onSuccess={onSave}
>
<FormLoadingOverlayToggle
action={operation}
// formIsLoading={isLoading}
// loadingSuffix={getTranslation(collectionConfig.labels.singular, i18n)}
name={`collection-edit--${
typeof collectionConfig?.labels?.singular === 'string'
? collectionConfig.labels.singular
: i18n.t('general:document')
}`}
type="withoutNav"
/>
{BeforeDocument}
{preventLeaveWithoutSaving && <LeaveWithoutSaving />}
<SetDocumentStepNav

View File

@@ -1,6 +1,5 @@
'use client'
import { LoadingOverlay } from '@payloadcms/ui/elements/Loading'
import { SetViewActions } from '@payloadcms/ui/providers/Actions'
import { useComponentMap } from '@payloadcms/ui/providers/ComponentMap'
import { useDocumentInfo } from '@payloadcms/ui/providers/DocumentInfo'
@@ -16,9 +15,8 @@ export const EditViewClient: React.FC = () => {
globalSlug,
})
// Allow the `DocumentInfoProvider` to hydrate
if (!Edit || (!collectionSlug && !globalSlug)) {
return <LoadingOverlay />
if (!Edit) {
return null
}
return (

View File

@@ -13,7 +13,6 @@ import React, { Fragment } from 'react'
import type { DefaultListViewProps, ListPreferences } from './Default/types.js'
import { UnauthorizedView } from '../Unauthorized/index.js'
import { DefaultListView } from './Default/index.js'
export { generateListMetadata } from './meta.js'
@@ -41,7 +40,7 @@ export const ListView: React.FC<AdminViewProps> = async ({
const collectionSlug = collectionConfig?.slug
if (!permissions?.collections?.[collectionSlug]?.read?.permission) {
return <UnauthorizedView initPageResult={initPageResult} searchParams={searchParams} />
notFound()
}
let listPreferences: ListPreferences

View File

@@ -6,7 +6,6 @@ import type { ClientCollectionConfig, ClientConfig, ClientGlobalConfig, Data } f
import { DocumentControls } from '@payloadcms/ui/elements/DocumentControls'
import { DocumentFields } from '@payloadcms/ui/elements/DocumentFields'
import { LoadingOverlay } from '@payloadcms/ui/elements/Loading'
import { Form } from '@payloadcms/ui/forms/Form'
import { SetViewActions } from '@payloadcms/ui/providers/Actions'
import { useComponentMap } from '@payloadcms/ui/providers/ComponentMap'
@@ -66,6 +65,7 @@ const PreviewView: React.FC<Props> = ({
initialData,
initialState,
isEditing,
isInitializing,
onSave: onSaveFromProps,
} = useDocumentInfo()
@@ -120,11 +120,6 @@ const PreviewView: React.FC<Props> = ({
[serverURL, apiRoute, id, operation, schemaPath, getDocPreferences],
)
// Allow the `DocumentInfoProvider` to hydrate
if (!collectionSlug && !globalSlug) {
return <LoadingOverlay />
}
return (
<Fragment>
<OperationProvider operation={operation}>
@@ -133,6 +128,7 @@ const PreviewView: React.FC<Props> = ({
className={`${baseClass}__form`}
disabled={!hasSavePermission}
initialState={initialState}
isInitializing={isInitializing}
method={id ? 'PATCH' : 'POST'}
onChange={[onChange]}
onSuccess={onSave}

View File

@@ -8,7 +8,6 @@ const Link = (LinkImport.default || LinkImport) as unknown as typeof LinkImport.
import type { FormState, PayloadRequestWithData } from 'payload/types'
import { FormLoadingOverlayToggle } from '@payloadcms/ui/elements/Loading'
import { Email } from '@payloadcms/ui/fields/Email'
import { Password } from '@payloadcms/ui/fields/Password'
import { Form } from '@payloadcms/ui/forms/Form'
@@ -60,7 +59,6 @@ export const LoginForm: React.FC<{
redirect={typeof searchParams?.redirect === 'string' ? searchParams.redirect : admin}
waitForAutocomplete
>
<FormLoadingOverlayToggle action="loading" name="login-form" />
<div className={`${baseClass}__inputWrap`}>
<Email
autoComplete="email"

View File

@@ -1,6 +1,5 @@
import type { AdminViewProps } from 'payload/types'
import { MinimalTemplate } from '@payloadcms/ui/templates/Minimal'
import React from 'react'
import { LogoutClient } from './LogoutClient.js'
@@ -26,15 +25,13 @@ export const LogoutView: React.FC<
} = initPageResult
return (
<MinimalTemplate className={baseClass}>
<div className={`${baseClass}__wrap`}>
<LogoutClient
adminRoute={admin}
inactivity={inactivity}
redirect={searchParams.redirect as string}
/>
</div>
</MinimalTemplate>
<div className={`${baseClass}__wrap`}>
<LogoutClient
adminRoute={admin}
inactivity={inactivity}
redirect={searchParams.redirect as string}
/>
</div>
)
}

View File

@@ -72,9 +72,9 @@ export const ResetPasswordClient: React.FC<Args> = ({ token }) => {
const PasswordToConfirm = () => {
const { t } = useTranslation()
const { value: confirmValue } = useFormFields(([fields]) => {
return fields['confirm-password']
})
const { value: confirmValue } = useFormFields(
([fields]) => (fields && fields?.['confirm-password']) || null,
)
const validate = React.useCallback(
(value: string) => {

View File

@@ -1,3 +1,4 @@
'use client'
import { ReactSelect } from '@payloadcms/ui/elements/ReactSelect'
import { useLocale } from '@payloadcms/ui/providers/Locale'
import { useTranslation } from '@payloadcms/ui/providers/Translation'

View File

@@ -4,6 +4,12 @@
* @returns {import('next').NextConfig}
* */
export const withPayload = (nextConfig = {}) => {
if (nextConfig.experimental?.staleTimes?.dynamic) {
console.warn(
'Payload detected a non-zero value for the `staleTimes.dynamic` option in your Next.js config. This may cause stale data to load in the Admin Panel. To clear this warning, remove the `staleTimes.dynamic` option from your Next.js config or set it to 0. In the future, Next.js may support scoping this option to specific routes.',
)
}
return {
...nextConfig,
experimental: {

View File

@@ -0,0 +1,4 @@
export type LanguageOptions = {
label: string
value: string
}[]

View File

@@ -1,3 +1,4 @@
export type { LanguageOptions } from './LanguageOptions.js'
export type { RichTextAdapter, RichTextAdapterProvider, RichTextFieldProps } from './RichText.js'
export type { CellComponentProps, DefaultCellComponentProps } from './elements/Cell.js'
export type { ConditionalDateProps } from './elements/DatePicker.js'
@@ -26,6 +27,7 @@ export type {
} from './forms/FieldDescription.js'
export type { Data, FilterOptionsResult, FormField, FormState, Row } from './forms/Form.js'
export type { LabelProps, SanitizedLabelProps } from './forms/Label.js'
export type { RowLabel, RowLabelComponent } from './forms/RowLabel.js'
export type {

View File

@@ -5,6 +5,7 @@ import type { SanitizedCollectionConfig } from '../../collections/config/types.j
import type { Locale } from '../../config/types.js'
import type { SanitizedGlobalConfig } from '../../globals/config/types.js'
import type { PayloadRequestWithData } from '../../types/index.js'
import type { LanguageOptions } from '../LanguageOptions.js'
export type AdminViewConfig = {
Component: AdminViewComponent
@@ -40,6 +41,7 @@ export type InitPageResult = {
cookies: Map<string, string>
docID?: string
globalConfig?: SanitizedGlobalConfig
languageOptions: LanguageOptions
locale: Locale
permissions: Permissions
req: PayloadRequestWithData

View File

@@ -11,7 +11,7 @@ export const LinkToDoc: CustomComponent<UIField> = () => {
const { custom } = useFieldProps()
const { isTestKey, nameOfIDField, stripeResourceType } = custom
const field = useFormFields(([fields]) => fields[nameOfIDField])
const field = useFormFields(([fields]) => (fields && fields?.[nameOfIDField]) || null)
const { value: stripeID } = field || {}
const stripeEnv = isTestKey ? 'test/' : ''

View File

@@ -73,7 +73,7 @@ const RichTextField: React.FC<
path: pathFromProps,
placeholder,
plugins,
readOnly,
readOnly: readOnlyFromProps,
required,
style,
validate = richTextValidate,
@@ -102,12 +102,16 @@ const RichTextField: React.FC<
[validate, required, i18n],
)
const { path: pathFromContext } = useFieldProps()
const { path: pathFromContext, readOnly: readOnlyFromContext } = useFieldProps()
const { initialValue, path, schemaPath, setValue, showError, value } = useField({
path: pathFromContext || pathFromProps || name,
validate: memoizedValidate,
})
const { formInitializing, initialValue, path, schemaPath, setValue, showError, value } = useField(
{
path: pathFromContext || pathFromProps || name,
validate: memoizedValidate,
},
)
const disabled = readOnlyFromProps || readOnlyFromContext || formInitializing
const editor = useMemo(() => {
let CreatedEditor = withEnterBreakOut(withHistory(withReact(createEditor())))
@@ -241,12 +245,12 @@ const RichTextField: React.FC<
})
if (ops && Array.isArray(ops) && ops.length > 0) {
if (!readOnly && val !== defaultRichTextValue && val !== value) {
if (!disabled && val !== defaultRichTextValue && val !== value) {
setValue(val)
}
}
},
[editor?.operations, readOnly, setValue, value],
[editor?.operations, disabled, setValue, value],
)
useEffect(() => {
@@ -263,16 +267,16 @@ const RichTextField: React.FC<
})
}
if (readOnly) {
if (disabled) {
setClickableState('disabled')
}
return () => {
if (readOnly) {
if (disabled) {
setClickableState('enabled')
}
}
}, [readOnly])
}, [disabled])
// useEffect(() => {
// // If there is a change to the initial value, we need to reset Slate history
@@ -289,7 +293,7 @@ const RichTextField: React.FC<
'field-type',
className,
showError && 'error',
readOnly && `${baseClass}--read-only`,
disabled && `${baseClass}--read-only`,
]
.filter(Boolean)
.join(' ')
@@ -344,6 +348,7 @@ const RichTextField: React.FC<
if (Button) {
return (
<ElementButtonProvider
disabled={disabled}
fieldProps={props}
key={element.name}
path={path}
@@ -444,7 +449,7 @@ const RichTextField: React.FC<
})
}}
placeholder={getTranslation(placeholder, i18n)}
readOnly={readOnly}
readOnly={disabled}
renderElement={renderElement}
renderLeaf={renderLeaf}
spellCheck

View File

@@ -8,15 +8,26 @@ import { useSlate } from 'slate-react'
import type { ButtonProps } from './types.js'
import '../buttons.scss'
import { useElementButton } from '../providers/ElementButtonProvider.js'
import { isElementActive } from './isActive.js'
import { toggleElement } from './toggle.js'
export const baseClass = 'rich-text__button'
export const ElementButton: React.FC<ButtonProps> = (props) => {
const { type = 'type', children, className, el = 'button', format, onClick, tooltip } = props
const {
type = 'type',
children,
className,
disabled: disabledFromProps,
el = 'button',
format,
onClick,
tooltip,
} = props
const editor = useSlate()
const { disabled: disabledFromContext } = useElementButton()
const [showTooltip, setShowTooltip] = useState(false)
const defaultOnClick = useCallback(
@@ -30,9 +41,11 @@ export const ElementButton: React.FC<ButtonProps> = (props) => {
const Tag: ElementType = el
const disabled = disabledFromProps || disabledFromContext
return (
<Tag
{...(el === 'button' && { type: 'button' })}
{...(el === 'button' && { type: 'button', disabled })}
className={[
baseClass,
className,

View File

@@ -3,6 +3,7 @@ import type { ElementType } from 'react'
export type ButtonProps = {
children?: React.ReactNode
className?: string
disabled?: boolean
el?: ElementType
format: string
onClick?: (e: React.MouseEvent) => void

View File

@@ -5,6 +5,7 @@ import type { FormFieldBase } from '@payloadcms/ui/fields/shared'
import React from 'react'
type ElementButtonContextType = {
disabled?: boolean
fieldProps: FormFieldBase & {
name: string
richTextComponentMap: Map<string, React.ReactNode>

View File

@@ -22,16 +22,19 @@ export const heTranslations: DefaultTranslationsObject = {
failedToUnlock: 'ביטול נעילה נכשל',
forceUnlock: 'אלץ ביטול נעילה',
forgotPassword: 'שכחתי סיסמה',
forgotPasswordEmailInstructions: 'אנא הזן את כתובת הדוא"ל שלך למטה. תקבל הודעה עם הוראות לאיפוס הסיסמה שלך.',
forgotPasswordEmailInstructions:
'אנא הזן את כתובת הדוא"ל שלך למטה. תקבל הודעה עם הוראות לאיפוס הסיסמה שלך.',
forgotPasswordQuestion: 'שכחת סיסמה?',
generate: 'יצירה',
generateNewAPIKey: 'יצירת מפתח API חדש',
generatingNewAPIKeyWillInvalidate: 'יצירת מפתח API חדש תבטל את המפתח הקודם. האם אתה בטוח שברצונך להמשיך?',
generatingNewAPIKeyWillInvalidate:
'יצירת מפתח API חדש תבטל את המפתח הקודם. האם אתה בטוח שברצונך להמשיך?',
lockUntil: 'נעילה עד',
logBackIn: 'התחברות מחדש',
logOut: 'התנתקות',
loggedIn: 'כדי להתחבר עם משתמש אחר, יש להתנתק תחילה.',
loggedInChangePassword: 'כדי לשנות את הסיסמה שלך, יש לעבור ל<a href="{{serverURL}}">חשבון</a> שלך ולערוך את הסיסמה שם.',
loggedInChangePassword:
'כדי לשנות את הסיסמה שלך, יש לעבור ל<a href="{{serverURL}}">חשבון</a> שלך ולערוך את הסיסמה שם.',
loggedOutInactivity: 'התנתקת בשל חוסר פעילות.',
loggedOutSuccessfully: 'התנתקת בהצלחה.',
loggingOut: 'מתנתק...',
@@ -43,7 +46,8 @@ export const heTranslations: DefaultTranslationsObject = {
logoutSuccessful: 'התנתקות הצליחה.',
logoutUser: 'התנתקות משתמש',
newAPIKeyGenerated: 'נוצר מפתח API חדש.',
newAccountCreated: 'נוצר חשבון חדש עבורך כדי לגשת אל <a href="{{serverURL}}">{{serverURL}}</a>. אנא לחץ על הקישור הבא או הדבק את ה-URL בדפדפן שלך כדי לאמת את הדוא"ל שלך: <a href="{{verificationURL}}">{{verificationURL}}</a>.<br> לאחר אימות כתובת הדוא"ל, תוכל להתחבר בהצלחה.',
newAccountCreated:
'נוצר חשבון חדש עבורך כדי לגשת אל <a href="{{serverURL}}">{{serverURL}}</a>. אנא לחץ על הקישור הבא או הדבק את ה-URL בדפדפן שלך כדי לאמת את הדוא"ל שלך: <a href="{{verificationURL}}">{{verificationURL}}</a>.<br> לאחר אימות כתובת הדוא"ל, תוכל להתחבר בהצלחה.',
newPassword: 'סיסמה חדשה',
passed: 'אימות הצליח',
passwordResetSuccessfully: 'איפוס הסיסמה הצליח.',
@@ -61,9 +65,11 @@ export const heTranslations: DefaultTranslationsObject = {
verify: 'אמת',
verifyUser: 'אמת משתמש',
verifyYourEmail: 'אמת את כתובת הדוא"ל שלך',
youAreInactive: 'לא היית פעיל לזמן קצר ובקרוב תתנתק אוטומטית כדי לשמור על האבטחה של חשבונך. האם ברצונך להישאר מחובר?',
youAreReceivingResetPassword: 'קיבלת הודעה זו מכיוון שאתה (או מישהו אחר) ביקשת לאפס את הסיסמה של החשבון שלך. אנא לחץ על הקישור הבא או הדבק אותו בשורת הכתובת בדפדפן שלך כדי להשלים את התהליך:',
youDidNotRequestPassword: 'אם לא ביקשת זאת, אנא התעלם מההודעה והסיסמה שלך תישאר ללא שינוי.'
youAreInactive:
'לא היית פעיל לזמן קצר ובקרוב תתנתק אוטומטית כדי לשמור על האבטחה של חשבונך. האם ברצונך להישאר מחובר?',
youAreReceivingResetPassword:
'קיבלת הודעה זו מכיוון שאתה (או מישהו אחר) ביקשת לאפס את הסיסמה של החשבון שלך. אנא לחץ על הקישור הבא או הדבק אותו בשורת הכתובת בדפדפן שלך כדי להשלים את התהליך:',
youDidNotRequestPassword: 'אם לא ביקשת זאת, אנא התעלם מההודעה והסיסמה שלך תישאר ללא שינוי.',
},
error: {
accountAlreadyActivated: 'חשבון זה כבר הופעל.',
@@ -339,7 +345,8 @@ export const heTranslations: DefaultTranslationsObject = {
type: 'סוג',
aboutToPublishSelection: 'אתה עומד לפרסם את כל ה{{label}} שנבחרו. האם אתה בטוח?',
aboutToRestore: 'אתה עומד לשחזר את מסמך {{label}} למצב שהיה בו בתאריך {{versionDate}}.',
aboutToRestoreGlobal: 'אתה עומד לשחזר את {{label}} הגלובלי למצב שהיה בו בתאריך {{versionDate}}.',
aboutToRestoreGlobal:
'אתה עומד לשחזר את {{label}} הגלובלי למצב שהיה בו בתאריך {{versionDate}}.',
aboutToRevertToPublished: 'אתה עומד להחזיר את השינויים במסמך הזה לגרסה שפורסמה. האם אתה בטוח?',
aboutToUnpublish: 'אתה עומד לבטל את הפרסום של מסמך זה. האם אתה בטוח?',
aboutToUnpublishSelection: 'אתה עומד לבטל את הפרסום של כל ה{{label}} שנבחרו. האם אתה בטוח?',

View File

@@ -62,6 +62,7 @@ export const DocumentFields: React.FC<Args> = ({
<RenderFields
className={`${baseClass}__fields`}
fieldMap={mainFields}
forceRender={10}
path=""
permissions={docPermissions?.fields}
readOnly={readOnly}
@@ -76,6 +77,7 @@ export const DocumentFields: React.FC<Args> = ({
<div className={`${baseClass}__sidebar-fields`}>
<RenderFields
fieldMap={sidebarFields}
forceRender={10}
path=""
permissions={docPermissions?.fields}
readOnly={readOnly}

View File

@@ -82,7 +82,9 @@
}
&__count {
padding: 0px 7px;
min-width: 22px;
text-align: center;
padding: 2px 7px;
background-color: var(--theme-elevation-100);
border-radius: 1px;
}

View File

@@ -1,5 +1,5 @@
'use client'
import React from 'react'
import React, { Fragment } from 'react'
import { useDocumentInfo } from '../../../../../providers/DocumentInfo/index.js'
import { baseClass } from '../../Tab/index.js'
@@ -7,9 +7,18 @@ import { baseClass } from '../../Tab/index.js'
export const VersionsPill: React.FC = () => {
const { versions } = useDocumentInfo()
if (versions?.totalDocs > 0) {
return <span className={`${baseClass}__count`}>{versions?.totalDocs?.toString()}</span>
}
// To prevent CLS (versions are currently loaded client-side), render non-breaking space if there are no versions
// The pill is already conditionally rendered to begin with based on whether the document is version-enabled
// documents that are version enabled _always_ have at least one version
const hasVersions = versions?.totalDocs > 0
return null
return (
<span
className={[`${baseClass}__count`, hasVersions ? `${baseClass}__count--has-count` : '']
.filter(Boolean)
.join(' ')}
>
{hasVersions ? versions.totalDocs.toString() : <Fragment>&nbsp;</Fragment>}
</span>
)
}

View File

@@ -3,7 +3,7 @@ import type { KeyboardEventHandler } from 'react'
import { arrayMove } from '@dnd-kit/sortable'
import { getTranslation } from '@payloadcms/translations'
import React from 'react'
import React, { useEffect, useId } from 'react'
import Select from 'react-select'
import CreatableSelect from 'react-select/creatable'
@@ -13,6 +13,7 @@ export type { Option } from './types.js'
import { useTranslation } from '../../providers/Translation/index.js'
import { DraggableSortable } from '../DraggableSortable/index.js'
import { ShimmerEffect } from '../ShimmerEffect/index.js'
import { ClearIndicator } from './ClearIndicator/index.js'
import { Control } from './Control/index.js'
import { DropdownIndicator } from './DropdownIndicator/index.js'
@@ -31,6 +32,12 @@ const createOption = (label: string) => ({
const SelectAdapter: React.FC<ReactSelectAdapterProps> = (props) => {
const { i18n, t } = useTranslation()
const [inputValue, setInputValue] = React.useState('') // for creatable select
const uuid = useId()
const [hasMounted, setHasMounted] = React.useState(false)
useEffect(() => {
setHasMounted(true)
}, [])
const {
className,
@@ -60,6 +67,10 @@ const SelectAdapter: React.FC<ReactSelectAdapterProps> = (props) => {
.filter(Boolean)
.join(' ')
if (!hasMounted) {
return <ShimmerEffect height="calc(var(--base) * 2 + 2px)" />
}
if (!isCreatable) {
return (
<Select
@@ -83,6 +94,7 @@ const SelectAdapter: React.FC<ReactSelectAdapterProps> = (props) => {
}}
filterOption={filterOption}
getOptionValue={getOptionValue}
instanceId={uuid}
isClearable={isClearable}
isDisabled={disabled}
isSearchable={isSearchable}
@@ -154,6 +166,7 @@ const SelectAdapter: React.FC<ReactSelectAdapterProps> = (props) => {
}}
filterOption={filterOption}
inputValue={inputValue}
instanceId={uuid}
isClearable={isClearable}
isDisabled={disabled}
isSearchable={isSearchable}

View File

@@ -1,5 +1,5 @@
'use client'
import React from 'react'
import React, { Fragment } from 'react'
import { useDocumentInfo } from '../../providers/DocumentInfo/index.js'
import { IDLabel } from '../IDLabel/index.js'
@@ -18,9 +18,7 @@ export type RenderTitleProps = {
export const RenderTitle: React.FC<RenderTitleProps> = (props) => {
const { className, element = 'h1', fallback, title: titleFromProps } = props
const documentInfo = useDocumentInfo()
const { id, title: titleFromContext } = documentInfo
const { id, isInitializing, title: titleFromContext } = useDocumentInfo()
const title = titleFromProps || titleFromContext || fallback
@@ -28,6 +26,9 @@ export const RenderTitle: React.FC<RenderTitleProps> = (props) => {
const Tag = element
// Render and invisible character to prevent layout shift when the title populates from context
const EmptySpace = <Fragment>&nbsp;</Fragment>
return (
<Tag
className={[className, baseClass, idAsTitle && `${baseClass}--has-id`]
@@ -35,7 +36,13 @@ export const RenderTitle: React.FC<RenderTitleProps> = (props) => {
.join(' ')}
title={title}
>
{idAsTitle ? <IDLabel className={`${baseClass}__id`} id={id} /> : title || null}
{isInitializing ? (
EmptySpace
) : (
<Fragment>
{idAsTitle ? <IDLabel className={`${baseClass}__id`} id={id} /> : title || EmptySpace}
</Fragment>
)}
</Tag>
)
}

View File

@@ -71,7 +71,6 @@ export const _ArrayField: React.FC<ArrayFieldProps> = (props) => {
} = props
const { indexPath, readOnly: readOnlyFromContext } = useFieldProps()
const readOnly = readOnlyFromProps || readOnlyFromContext
const minRows = minRowsProp ?? required ? 1 : 0
const { setDocFieldPreferences } = useDocumentInfo()
@@ -116,6 +115,8 @@ export const _ArrayField: React.FC<ArrayFieldProps> = (props) => {
const {
errorPaths,
formInitializing,
formProcessing,
path,
rows = [],
schemaPath,
@@ -128,6 +129,8 @@ export const _ArrayField: React.FC<ArrayFieldProps> = (props) => {
validate: memoizedValidate,
})
const disabled = readOnlyFromProps || readOnlyFromContext || formProcessing || formInitializing
const addRow = useCallback(
async (rowIndex: number) => {
await addFieldRow({ path, rowIndex, schemaPath })
@@ -187,7 +190,7 @@ export const _ArrayField: React.FC<ArrayFieldProps> = (props) => {
const fieldErrorCount = errorPaths.length
const fieldHasErrors = submitted && errorPaths.length > 0
const showRequired = readOnly && rows.length === 0
const showRequired = disabled && rows.length === 0
const showMinRows = rows.length < minRows || (required && rows.length === 0)
return (
@@ -257,7 +260,7 @@ export const _ArrayField: React.FC<ArrayFieldProps> = (props) => {
errorPath.startsWith(`${path}.${i}.`),
).length
return (
<DraggableSortableItem disabled={readOnly || !isSortable} id={row.id} key={row.id}>
<DraggableSortableItem disabled={disabled || !isSortable} id={row.id} key={row.id}>
{(draggableSortableItemProps) => (
<ArrayRow
{...draggableSortableItemProps}
@@ -274,7 +277,7 @@ export const _ArrayField: React.FC<ArrayFieldProps> = (props) => {
moveRow={moveRow}
path={path}
permissions={permissions}
readOnly={readOnly}
readOnly={disabled}
removeRow={removeRow}
row={row}
rowCount={rows.length}
@@ -307,7 +310,7 @@ export const _ArrayField: React.FC<ArrayFieldProps> = (props) => {
)}
</DraggableSortable>
)}
{!readOnly && !hasMaxRows && (
{!disabled && !hasMaxRows && (
<Button
buttonStyle="icon-label"
className={`${baseClass}__add-row`}

View File

@@ -76,7 +76,6 @@ const _BlocksField: React.FC<BlocksFieldProps> = (props) => {
} = props
const { indexPath, readOnly: readOnlyFromContext } = useFieldProps()
const readOnly = readOnlyFromProps || readOnlyFromContext
const minRows = minRowsProp ?? required ? 1 : 0
const { setDocFieldPreferences } = useDocumentInfo()
@@ -118,6 +117,8 @@ const _BlocksField: React.FC<BlocksFieldProps> = (props) => {
const {
errorPaths,
formInitializing,
formProcessing,
path,
permissions,
rows = [],
@@ -131,6 +132,8 @@ const _BlocksField: React.FC<BlocksFieldProps> = (props) => {
validate: memoizedValidate,
})
const disabled = readOnlyFromProps || readOnlyFromContext || formProcessing || formInitializing
const addRow = useCallback(
async (rowIndex: number, blockType: string) => {
await addFieldRow({
@@ -201,7 +204,7 @@ const _BlocksField: React.FC<BlocksFieldProps> = (props) => {
const fieldHasErrors = submitted && fieldErrorCount + (valid ? 0 : 1) > 0
const showMinRows = rows.length < minRows || (required && rows.length === 0)
const showRequired = readOnly && rows.length === 0
const showRequired = disabled && rows.length === 0
return (
<div
@@ -274,7 +277,7 @@ const _BlocksField: React.FC<BlocksFieldProps> = (props) => {
errorPath.startsWith(`${path}.${i}`),
).length
return (
<DraggableSortableItem disabled={readOnly || !isSortable} id={row.id} key={row.id}>
<DraggableSortableItem disabled={disabled || !isSortable} id={row.id} key={row.id}>
{(draggableSortableItemProps) => (
<BlockRow
{...draggableSortableItemProps}
@@ -291,7 +294,7 @@ const _BlocksField: React.FC<BlocksFieldProps> = (props) => {
moveRow={moveRow}
path={path}
permissions={permissions}
readOnly={readOnly}
readOnly={disabled}
removeRow={removeRow}
row={row}
rowCount={rows.length}
@@ -327,7 +330,7 @@ const _BlocksField: React.FC<BlocksFieldProps> = (props) => {
)}
</DraggableSortable>
)}
{!readOnly && !hasMaxRows && (
{!disabled && !hasMaxRows && (
<Fragment>
<DrawerToggler className={`${baseClass}__drawer-toggler`} slug={drawerSlug}>
<Button

View File

@@ -62,20 +62,21 @@ const CheckboxField: React.FC<CheckboxFieldProps> = (props) => {
)
const { path: pathFromContext, readOnly: readOnlyFromContext } = useFieldProps()
const readOnly = readOnlyFromProps || readOnlyFromContext
const { path, setValue, showError, value } = useField({
const { formInitializing, formProcessing, path, setValue, showError, value } = useField({
disableFormData,
path: pathFromContext || pathFromProps || name,
validate: memoizedValidate,
})
const disabled = readOnlyFromProps || readOnlyFromContext || formProcessing || formInitializing
const onToggle = useCallback(() => {
if (!readOnly) {
if (!disabled) {
setValue(!value)
if (typeof onChangeFromProps === 'function') onChangeFromProps(!value)
}
}, [onChangeFromProps, readOnly, setValue, value])
}, [onChangeFromProps, disabled, setValue, value])
const checked = checkedFromProps || Boolean(value)
@@ -89,7 +90,7 @@ const CheckboxField: React.FC<CheckboxFieldProps> = (props) => {
showError && 'error',
className,
value && `${baseClass}--checked`,
readOnly && `${baseClass}--read-only`,
disabled && `${baseClass}--read-only`,
]
.filter(Boolean)
.join(' ')}
@@ -111,7 +112,7 @@ const CheckboxField: React.FC<CheckboxFieldProps> = (props) => {
name={path}
onToggle={onToggle}
partialChecked={partialChecked}
readOnly={readOnly}
readOnly={disabled}
required={required}
/>
{CustomDescription !== undefined ? (

View File

@@ -64,13 +64,14 @@ const CodeField: React.FC<CodeFieldProps> = (props) => {
)
const { path: pathFromContext, readOnly: readOnlyFromContext } = useFieldProps()
const readOnly = readOnlyFromProps || readOnlyFromContext
const { path, setValue, showError, value } = useField({
const { formInitializing, formProcessing, path, setValue, showError, value } = useField({
path: pathFromContext || pathFromProps || name,
validate: memoizedValidate,
})
const disabled = readOnlyFromProps || readOnlyFromContext || formProcessing || formInitializing
return (
<div
className={[
@@ -78,7 +79,7 @@ const CodeField: React.FC<CodeFieldProps> = (props) => {
baseClass,
className,
showError && 'error',
readOnly && 'read-only',
disabled && 'read-only',
]
.filter(Boolean)
.join(' ')}
@@ -98,9 +99,9 @@ const CodeField: React.FC<CodeFieldProps> = (props) => {
{BeforeInput}
<CodeEditor
defaultLanguage={prismToMonacoLanguageMap[language] || language}
onChange={readOnly ? () => null : (val) => setValue(val)}
onChange={disabled ? () => null : (val) => setValue(val)}
options={editorOptions}
readOnly={readOnly}
readOnly={disabled}
value={(value as string) || ''}
/>
{AfterInput}

View File

@@ -24,6 +24,7 @@ import type { FieldMap } from '../../providers/ComponentMap/buildComponentMap/ty
import type { FormFieldBase } from '../shared/index.js'
import { FieldDescription } from '../../forms/FieldDescription/index.js'
import { useFormInitializing, useFormProcessing } from '../../forms/Form/context.js'
export type CollapsibleFieldProps = FormFieldBase & {
fieldMap: FieldMap
@@ -52,6 +53,10 @@ const CollapsibleField: React.FC<CollapsibleFieldProps> = (props) => {
schemaPath,
siblingPermissions,
} = useFieldProps()
const formInitializing = useFormInitializing()
const formProcessing = useFormProcessing()
const path = pathFromContext || pathFromProps
const { i18n } = useTranslation()
@@ -117,7 +122,7 @@ const CollapsibleField: React.FC<CollapsibleFieldProps> = (props) => {
if (typeof collapsedOnMount !== 'boolean') return null
const readOnly = readOnlyFromProps || readOnlyFromContext
const disabled = readOnlyFromProps || readOnlyFromContext || formProcessing || formInitializing
return (
<Fragment>
@@ -152,7 +157,7 @@ const CollapsibleField: React.FC<CollapsibleFieldProps> = (props) => {
margins="small"
path={path}
permissions={siblingPermissions}
readOnly={readOnly}
readOnly={disabled}
schemaPath={schemaPath}
/>
</CollapsibleElement>

View File

@@ -67,12 +67,12 @@ const DateTimeField: React.FC<DateFieldProps> = (props) => {
const { path: pathFromContext, readOnly: readOnlyFromContext } = useFieldProps()
const { path, setValue, showError, value } = useField<Date>({
const { formInitializing, formProcessing, path, setValue, showError, value } = useField<Date>({
path: pathFromContext || pathFromProps || name,
validate: memoizedValidate,
})
const readOnly = readOnlyFromProps || readOnlyFromContext
const disabled = readOnlyFromProps || readOnlyFromContext || formProcessing || formInitializing
return (
<div
@@ -81,7 +81,7 @@ const DateTimeField: React.FC<DateFieldProps> = (props) => {
baseClass,
className,
showError && `${baseClass}--has-error`,
readOnly && 'read-only',
disabled && 'read-only',
]
.filter(Boolean)
.join(' ')}
@@ -102,10 +102,10 @@ const DateTimeField: React.FC<DateFieldProps> = (props) => {
<DatePickerField
{...datePickerProps}
onChange={(incomingDate) => {
if (!readOnly) setValue(incomingDate?.toISOString() || null)
if (!disabled) setValue(incomingDate?.toISOString() || null)
}}
placeholder={getTranslation(placeholder, i18n)}
readOnly={readOnly}
readOnly={disabled}
value={value}
/>
{AfterInput}

View File

@@ -60,16 +60,17 @@ const EmailField: React.FC<EmailFieldProps> = (props) => {
)
const { path: pathFromContext, readOnly: readOnlyFromContext } = useFieldProps()
const readOnly = readOnlyFromProps || readOnlyFromContext
const { path, setValue, showError, value } = useField({
const { formInitializing, formProcessing, path, setValue, showError, value } = useField({
path: pathFromContext || pathFromProps || name,
validate: memoizedValidate,
})
const disabled = readOnlyFromProps || readOnlyFromContext || formProcessing || formInitializing
return (
<div
className={[fieldBaseClass, 'email', className, showError && 'error', readOnly && 'read-only']
className={[fieldBaseClass, 'email', className, showError && 'error', disabled && 'read-only']
.filter(Boolean)
.join(' ')}
style={{
@@ -88,7 +89,7 @@ const EmailField: React.FC<EmailFieldProps> = (props) => {
{BeforeInput}
<input
autoComplete={autoComplete}
disabled={readOnly}
disabled={disabled}
id={`field-${path.replace(/\./g, '__')}`}
name={path}
onChange={setValue}

View File

@@ -11,7 +11,11 @@ import { useCollapsible } from '../../elements/Collapsible/provider.js'
import { ErrorPill } from '../../elements/ErrorPill/index.js'
import { FieldDescription } from '../../forms/FieldDescription/index.js'
import { useFieldProps } from '../../forms/FieldPropsProvider/index.js'
import { useFormSubmitted } from '../../forms/Form/context.js'
import {
useFormInitializing,
useFormProcessing,
useFormSubmitted,
} from '../../forms/Form/context.js'
import { RenderFields } from '../../forms/RenderFields/index.js'
import { useField } from '../../forms/useField/index.js'
import { withCondition } from '../../forms/withCondition/index.js'
@@ -54,10 +58,12 @@ const GroupField: React.FC<GroupFieldProps> = (props) => {
const isWithinRow = useRow()
const isWithinTab = useTabs()
const { errorPaths } = useField({ path })
const formInitializing = useFormInitializing()
const formProcessing = useFormProcessing()
const submitted = useFormSubmitted()
const errorCount = errorPaths.length
const fieldHasErrors = submitted && errorCount > 0
const readOnly = readOnlyFromProps || readOnlyFromContext
const disabled = readOnlyFromProps || readOnlyFromContext || formProcessing || formInitializing
const isTopLevel = !(isWithinCollapsible || isWithinGroup || isWithinRow)
@@ -108,7 +114,7 @@ const GroupField: React.FC<GroupFieldProps> = (props) => {
margins="small"
path={path}
permissions={permissions?.fields}
readOnly={readOnly}
readOnly={disabled}
schemaPath={schemaPath}
/>
</div>

View File

@@ -64,12 +64,14 @@ const JSONFieldComponent: React.FC<JSONFieldProps> = (props) => {
)
const { path: pathFromContext, readOnly: readOnlyFromContext } = useFieldProps()
const readOnly = readOnlyFromProps || readOnlyFromContext
const { initialValue, path, setValue, showError, value } = useField<string>({
path: pathFromContext || pathFromProps || name,
validate: memoizedValidate,
})
const { formInitializing, formProcessing, initialValue, path, setValue, showError, value } =
useField<string>({
path: pathFromContext || pathFromProps || name,
validate: memoizedValidate,
})
const disabled = readOnlyFromProps || readOnlyFromContext || formProcessing || formInitializing
const handleMount = useCallback(
(editor, monaco) => {
@@ -92,7 +94,7 @@ const JSONFieldComponent: React.FC<JSONFieldProps> = (props) => {
const handleChange = useCallback(
(val) => {
if (readOnly) return
if (disabled) return
setStringValue(val)
try {
@@ -103,14 +105,16 @@ const JSONFieldComponent: React.FC<JSONFieldProps> = (props) => {
setJsonError(e)
}
},
[readOnly, setValue, setStringValue],
[disabled, setValue, setStringValue],
)
useEffect(() => {
if (hasLoadedValue) return
if (hasLoadedValue || value === undefined) return
setStringValue(
value || initialValue ? JSON.stringify(value ? value : initialValue, null, 2) : '',
)
setHasLoadedValue(true)
}, [initialValue, value, hasLoadedValue])
@@ -121,7 +125,7 @@ const JSONFieldComponent: React.FC<JSONFieldProps> = (props) => {
baseClass,
className,
showError && 'error',
readOnly && 'read-only',
disabled && 'read-only',
]
.filter(Boolean)
.join(' ')}
@@ -145,7 +149,7 @@ const JSONFieldComponent: React.FC<JSONFieldProps> = (props) => {
onChange={handleChange}
onMount={handleMount}
options={editorOptions}
readOnly={readOnly}
readOnly={disabled}
value={stringValue}
/>
{AfterInput}

View File

@@ -73,13 +73,16 @@ const NumberFieldComponent: React.FC<NumberFieldProps> = (props) => {
)
const { path: pathFromContext, readOnly: readOnlyFromContext } = useFieldProps()
const readOnly = readOnlyFromProps || readOnlyFromContext
const { path, setValue, showError, value } = useField<number | number[]>({
const { formInitializing, formProcessing, path, setValue, showError, value } = useField<
number | number[]
>({
path: pathFromContext || pathFromProps || name,
validate: memoizedValidate,
})
const disabled = readOnlyFromProps || readOnlyFromContext || formProcessing || formInitializing
const handleChange = useCallback(
(e) => {
const val = parseFloat(e.target.value)
@@ -104,7 +107,7 @@ const NumberFieldComponent: React.FC<NumberFieldProps> = (props) => {
const handleHasManyChange = useCallback(
(selectedOption) => {
if (!readOnly) {
if (!disabled) {
let newValue
if (!selectedOption) {
newValue = []
@@ -117,7 +120,7 @@ const NumberFieldComponent: React.FC<NumberFieldProps> = (props) => {
setValue(newValue)
}
},
[readOnly, setValue],
[disabled, setValue],
)
// useEffect update valueToRender:
@@ -145,7 +148,7 @@ const NumberFieldComponent: React.FC<NumberFieldProps> = (props) => {
'number',
className,
showError && 'error',
readOnly && 'read-only',
disabled && 'read-only',
hasMany && 'has-many',
]
.filter(Boolean)
@@ -166,7 +169,7 @@ const NumberFieldComponent: React.FC<NumberFieldProps> = (props) => {
{hasMany ? (
<ReactSelect
className={`field-${path.replace(/\./g, '__')}`}
disabled={readOnly}
disabled={disabled}
filterOption={(_, rawInput) => {
// eslint-disable-next-line no-restricted-globals
const isOverHasMany = Array.isArray(value) && value.length >= maxRows
@@ -194,7 +197,7 @@ const NumberFieldComponent: React.FC<NumberFieldProps> = (props) => {
<div>
{BeforeInput}
<input
disabled={readOnly}
disabled={disabled}
id={`field-${path.replace(/\./g, '__')}`}
max={max}
min={min}

View File

@@ -32,7 +32,7 @@ const PasswordField: React.FC<PasswordFieldProps> = (props) => {
CustomLabel,
autoComplete,
className,
disabled,
disabled: disabledFromProps,
errorProps,
label,
labelProps,
@@ -52,14 +52,22 @@ const PasswordField: React.FC<PasswordFieldProps> = (props) => {
[validate, required],
)
const { formProcessing, path, setValue, showError, value } = useField({
const { formInitializing, formProcessing, path, setValue, showError, value } = useField({
path: pathFromProps || name,
validate: memoizedValidate,
})
const disabled = disabledFromProps || formInitializing || formProcessing
return (
<div
className={[fieldBaseClass, 'password', className, showError && 'error']
className={[
fieldBaseClass,
'password',
className,
showError && 'error',
disabled && 'read-only',
]
.filter(Boolean)
.join(' ')}
style={{
@@ -75,10 +83,9 @@ const PasswordField: React.FC<PasswordFieldProps> = (props) => {
/>
<div className={`${fieldBaseClass}__wrap`}>
<FieldError CustomError={CustomError} path={path} {...(errorProps || {})} />
<input
autoComplete={autoComplete}
disabled={formProcessing || disabled}
disabled={disabled}
id={`field-${path.replace(/\./g, '__')}`}
name={path}
onChange={setValue}

View File

@@ -68,9 +68,10 @@ const RadioGroupField: React.FC<RadioFieldProps> = (props) => {
)
const { path: pathFromContext, readOnly: readOnlyFromContext } = useFieldProps()
const readOnly = readOnlyFromProps || readOnlyFromContext
const {
formInitializing,
formProcessing,
path,
setValue,
showError,
@@ -80,6 +81,8 @@ const RadioGroupField: React.FC<RadioFieldProps> = (props) => {
validate: memoizedValidate,
})
const disabled = readOnlyFromProps || readOnlyFromContext || formProcessing || formInitializing
const value = valueFromContext || valueFromProps
return (
@@ -90,7 +93,7 @@ const RadioGroupField: React.FC<RadioFieldProps> = (props) => {
className,
`${baseClass}--layout-${layout}`,
showError && 'error',
readOnly && `${baseClass}--read-only`,
disabled && `${baseClass}--read-only`,
]
.filter(Boolean)
.join(' ')}
@@ -132,13 +135,13 @@ const RadioGroupField: React.FC<RadioFieldProps> = (props) => {
onChangeFromProps(optionValue)
}
if (!readOnly) {
if (!disabled) {
setValue(optionValue)
}
}}
option={optionIsObject(option) ? option : { label: option, value: option }}
path={path}
readOnly={readOnly}
readOnly={disabled}
uuid={uuid}
/>
</li>

View File

@@ -14,7 +14,6 @@ import { FieldDescription } from '../../forms/FieldDescription/index.js'
import { FieldError } from '../../forms/FieldError/index.js'
import { FieldLabel } from '../../forms/FieldLabel/index.js'
import { useFieldProps } from '../../forms/FieldPropsProvider/index.js'
import { useFormProcessing } from '../../forms/Form/context.js'
import { useField } from '../../forms/useField/index.js'
import { withCondition } from '../../forms/withCondition/index.js'
import { useDebouncedCallback } from '../../hooks/useDebouncedCallback.js'
@@ -72,7 +71,6 @@ const RelationshipField: React.FC<RelationshipFieldProps> = (props) => {
const { i18n, t } = useTranslation()
const { permissions } = useAuth()
const { code: locale } = useLocale()
const formProcessing = useFormProcessing()
const hasMultipleRelations = Array.isArray(relationTo)
const [options, dispatchOptions] = useReducer(optionsReducer, [])
const [lastFullyLoadedRelation, setLastFullyLoadedRelation] = useState(-1)
@@ -93,15 +91,23 @@ const RelationshipField: React.FC<RelationshipFieldProps> = (props) => {
[validate, required],
)
const { path: pathFromContext, readOnly: readOnlyFromContext } = useFieldProps()
const readOnly = readOnlyFromProps || readOnlyFromContext
const { filterOptions, initialValue, path, setValue, showError, value } = useField<
Value | Value[]
>({
const {
filterOptions,
formInitializing,
formProcessing,
initialValue,
path,
setValue,
showError,
value,
} = useField<Value | Value[]>({
path: pathFromContext || pathFromProps || name,
validate: memoizedValidate,
})
const readOnly = readOnlyFromProps || readOnlyFromContext || formInitializing
const valueRef = useRef(value)
valueRef.current = value

View File

@@ -73,16 +73,17 @@ const SelectField: React.FC<SelectFieldProps> = (props) => {
)
const { path: pathFromContext, readOnly: readOnlyFromContext } = useFieldProps()
const readOnly = readOnlyFromProps || readOnlyFromContext
const { path, setValue, showError, value } = useField({
const { formInitializing, formProcessing, path, setValue, showError, value } = useField({
path: pathFromContext || pathFromProps || name,
validate: memoizedValidate,
})
const disabled = readOnlyFromProps || readOnlyFromContext || formProcessing || formInitializing
const onChange = useCallback(
(selectedOption) => {
if (!readOnly) {
if (!disabled) {
let newValue
if (!selectedOption) {
newValue = null
@@ -103,7 +104,7 @@ const SelectField: React.FC<SelectFieldProps> = (props) => {
setValue(newValue)
}
},
[readOnly, hasMany, setValue, onChangeFromProps],
[disabled, hasMany, setValue, onChangeFromProps],
)
return (
@@ -125,7 +126,7 @@ const SelectField: React.FC<SelectFieldProps> = (props) => {
onChange={onChange}
options={options}
path={path}
readOnly={readOnly}
readOnly={disabled}
required={required}
showError={showError}
style={style}

View File

@@ -54,6 +54,7 @@ const TabsField: React.FC<TabsFieldProps> = (props) => {
readOnly: readOnlyFromContext,
schemaPath,
} = useFieldProps()
const readOnly = readOnlyFromProps || readOnlyFromContext
const path = pathFromContext || pathFromProps || name
const { getPreference, setPreference } = usePreferences()

View File

@@ -60,13 +60,14 @@ const TextField: React.FC<TextFieldProps> = (props) => {
)
const { path: pathFromContext, readOnly: readOnlyFromContext } = useFieldProps()
const readOnly = readOnlyFromProps || readOnlyFromContext
const { formProcessing, path, setValue, showError, value } = useField({
const { formInitializing, formProcessing, path, setValue, showError, value } = useField({
path: pathFromContext || pathFromProps || name,
validate: memoizedValidate,
})
const disabled = readOnlyFromProps || readOnlyFromContext || formProcessing || formInitializing
const renderRTL = isFieldRTL({
fieldLocalized: localized,
fieldRTL: rtl,
@@ -80,7 +81,7 @@ const TextField: React.FC<TextFieldProps> = (props) => {
const handleHasManyChange = useCallback(
(selectedOption) => {
if (!readOnly) {
if (!disabled) {
let newValue
if (!selectedOption) {
newValue = []
@@ -93,7 +94,7 @@ const TextField: React.FC<TextFieldProps> = (props) => {
setValue(newValue)
}
},
[readOnly, setValue],
[disabled, setValue],
)
// useEffect update valueToRender:
@@ -140,7 +141,7 @@ const TextField: React.FC<TextFieldProps> = (props) => {
}
path={path}
placeholder={placeholder}
readOnly={formProcessing || readOnly}
readOnly={disabled}
required={required}
rtl={renderRTL}
showError={showError}

View File

@@ -66,13 +66,14 @@ const TextareaField: React.FC<TextareaFieldProps> = (props) => {
)
const { path: pathFromContext, readOnly: readOnlyFromContext } = useFieldProps()
const readOnly = readOnlyFromProps || readOnlyFromContext
const { path, setValue, showError, value } = useField<string>({
const { formInitializing, formProcessing, path, setValue, showError, value } = useField<string>({
path: pathFromContext || pathFromProps || name,
validate: memoizedValidate,
})
const disabled = readOnlyFromProps || readOnlyFromContext || formProcessing || formInitializing
return (
<TextareaInput
AfterInput={AfterInput}
@@ -90,7 +91,7 @@ const TextareaField: React.FC<TextareaFieldProps> = (props) => {
}}
path={path}
placeholder={getTranslation(placeholder, i18n)}
readOnly={readOnly}
readOnly={disabled}
required={required}
rows={rows}
rtl={isRTL}

View File

@@ -52,12 +52,14 @@ const _Upload: React.FC<UploadFieldProps> = (props) => {
)
const { path: pathFromContext, readOnly: readOnlyFromContext } = useFieldProps()
const readOnly = readOnlyFromProps || readOnlyFromContext
const { filterOptions, path, setValue, showError, value } = useField<string>({
path: pathFromContext || pathFromProps,
validate: memoizedValidate,
})
const { filterOptions, formInitializing, formProcessing, path, setValue, showError, value } =
useField<string>({
path: pathFromContext || pathFromProps,
validate: memoizedValidate,
})
const disabled = readOnlyFromProps || readOnlyFromContext || formProcessing || formInitializing
const onChange = useCallback(
(incomingValue) => {
@@ -83,7 +85,7 @@ const _Upload: React.FC<UploadFieldProps> = (props) => {
labelProps={labelProps}
onChange={onChange}
path={path}
readOnly={readOnly}
readOnly={disabled}
relationTo={relationTo}
required={required}
serverURL={serverURL}

View File

@@ -32,6 +32,7 @@ export type Props = {
children: React.ReactNode
custom?: Record<any, string>
indexPath?: string
isForceRendered?: boolean
path: string
permissions?: FieldPermissions
readOnly: boolean

View File

@@ -13,6 +13,7 @@ const FormWatchContext = createContext({} as Context)
const SubmittedContext = createContext(false)
const ProcessingContext = createContext(false)
const ModifiedContext = createContext(false)
const InitializingContext = createContext(false)
const FormFieldsContext = createSelectorContext<FormFieldsContextType>([{}, () => null])
/**
@@ -25,6 +26,7 @@ const useWatchForm = (): Context => useContext(FormWatchContext)
const useFormSubmitted = (): boolean => useContext(SubmittedContext)
const useFormProcessing = (): boolean => useContext(ProcessingContext)
const useFormModified = (): boolean => useContext(ModifiedContext)
const useFormInitializing = (): boolean => useContext(InitializingContext)
/**
* Get and set the value of a form field based on a selector
@@ -46,12 +48,14 @@ export {
FormContext,
FormFieldsContext,
FormWatchContext,
InitializingContext,
ModifiedContext,
ProcessingContext,
SubmittedContext,
useAllFormFields,
useForm,
useFormFields,
useFormInitializing,
useFormModified,
useFormProcessing,
useFormSubmitted,

View File

@@ -6,9 +6,12 @@ const { unflatten } = flatleyImport
import { reduceFieldsToValues } from '../../utilities/reduceFieldsToValues.js'
export const getSiblingData = (fields: FormState, path: string): Data => {
if (!fields) return null
if (path.indexOf('.') === -1) {
return reduceFieldsToValues(fields, true)
}
const siblingFields = {}
// Determine if the last segment of the path is an array-based row

View File

@@ -33,6 +33,7 @@ import {
FormContext,
FormFieldsContext,
FormWatchContext,
InitializingContext,
ModifiedContext,
ProcessingContext,
SubmittedContext,
@@ -60,6 +61,7 @@ export const Form: React.FC<FormProps> = (props) => {
// fields: fieldsFromProps = collection?.fields || global?.fields,
handleResponse,
initialState, // fully formed initial field state
isInitializing: initializingFromProps,
onChange,
onSubmit,
onSuccess,
@@ -86,13 +88,16 @@ export const Form: React.FC<FormProps> = (props) => {
} = config
const [disabled, setDisabled] = useState(disabledFromProps || false)
const [isMounted, setIsMounted] = useState(false)
const [modified, setModified] = useState(false)
const [initializing, setInitializing] = useState(initializingFromProps)
const [processing, setProcessing] = useState(false)
const [submitted, setSubmitted] = useState(false)
const formRef = useRef<HTMLFormElement>(null)
const contextRef = useRef({} as FormContextType)
const fieldsReducer = useReducer(fieldReducer, {}, () => initialState)
/**
* `fields` is the current, up-to-date state/data of all fields in the form. It can be modified by using dispatchFields,
* which calls the fieldReducer, which then updates the state.
@@ -489,8 +494,10 @@ export const Form: React.FC<FormProps> = (props) => {
)
useEffect(() => {
if (typeof disabledFromProps === 'boolean') setDisabled(disabledFromProps)
}, [disabledFromProps])
if (initializingFromProps !== undefined) {
setInitializing(initializingFromProps)
}
}, [initializingFromProps])
contextRef.current.submit = submit
contextRef.current.getFields = getFields
@@ -513,6 +520,15 @@ export const Form: React.FC<FormProps> = (props) => {
contextRef.current.removeFieldRow = removeFieldRow
contextRef.current.replaceFieldRow = replaceFieldRow
contextRef.current.uuid = uuid
contextRef.current.initializing = initializing
useEffect(() => {
setIsMounted(true)
}, [])
useEffect(() => {
if (typeof disabledFromProps === 'boolean') setDisabled(disabledFromProps)
}, [disabledFromProps])
useEffect(() => {
if (typeof submittedFromProps === 'boolean') setSubmitted(submittedFromProps)
@@ -521,7 +537,7 @@ export const Form: React.FC<FormProps> = (props) => {
useEffect(() => {
if (initialState) {
contextRef.current = { ...initContextState } as FormContextType
dispatchFields({ type: 'REPLACE_STATE', state: initialState })
dispatchFields({ type: 'REPLACE_STATE', optimize: false, state: initialState })
}
}, [initialState, dispatchFields])
@@ -597,13 +613,15 @@ export const Form: React.FC<FormProps> = (props) => {
}}
>
<SubmittedContext.Provider value={submitted}>
<ProcessingContext.Provider value={processing}>
<ModifiedContext.Provider value={modified}>
<FormFieldsContext.Provider value={fieldsReducer}>
{children}
</FormFieldsContext.Provider>
</ModifiedContext.Provider>
</ProcessingContext.Provider>
<InitializingContext.Provider value={!isMounted || (isMounted && initializing)}>
<ProcessingContext.Provider value={processing}>
<ModifiedContext.Provider value={modified}>
<FormFieldsContext.Provider value={fieldsReducer}>
{children}
</FormFieldsContext.Provider>
</ModifiedContext.Provider>
</ProcessingContext.Provider>
</InitializingContext.Provider>
</SubmittedContext.Provider>
</FormWatchContext.Provider>
</FormContext.Provider>

View File

@@ -37,6 +37,7 @@ export const initContextState: Context = {
getField: (): FormField => undefined,
getFields: (): FormState => ({}),
getSiblingData,
initializing: undefined,
removeFieldRow: () => undefined,
replaceFieldRow: () => undefined,
replaceState: () => undefined,

View File

@@ -35,6 +35,7 @@ export type FormProps = (
fields?: Field[]
handleResponse?: (res: Response) => void
initialState?: FormState
isInitializing?: boolean
log?: boolean
onChange?: ((args: { formState: FormState }) => Promise<FormState>)[]
onSubmit?: (fields: FormState, data: Data) => void
@@ -197,6 +198,7 @@ export type Context = {
getField: GetField
getFields: GetFields
getSiblingData: GetSiblingData
initializing: boolean
removeFieldRow: ({ path, rowIndex }: { path: string; rowIndex: number }) => void
replaceFieldRow: ({
data,

View File

@@ -22,8 +22,9 @@ export const RenderFields: React.FC<Props> = (props) => {
{
rootMargin: '1000px',
},
forceRender,
Boolean(forceRender),
)
const isIntersecting = Boolean(entry?.isIntersecting)
const isAboveViewport = entry?.boundingClientRect?.top < 0
const shouldRender = forceRender || isIntersecting || isAboveViewport
@@ -67,6 +68,9 @@ export const RenderFields: React.FC<Props> = (props) => {
isHidden,
} = f
const forceRenderChildren =
(typeof forceRender === 'number' && fieldIndex <= forceRender) || true
const name = 'name' in f ? f.name : undefined
return (
@@ -74,7 +78,7 @@ export const RenderFields: React.FC<Props> = (props) => {
CustomField={CustomField}
custom={custom}
disabled={disabled}
fieldComponentProps={fieldComponentProps}
fieldComponentProps={{ ...fieldComponentProps, forceRender: forceRenderChildren }}
indexPath={indexPath !== undefined ? `${indexPath}.${fieldIndex}` : `${fieldIndex}`}
isHidden={isHidden}
key={fieldIndex}

View File

@@ -5,7 +5,14 @@ import type { FieldMap } from '../../providers/ComponentMap/buildComponentMap/ty
export type Props = {
className?: string
fieldMap: FieldMap
forceRender?: boolean
/**
* Controls the rendering behavior of the fields, i.e. defers rendering until they intersect with the viewport using the Intersection Observer API.
*
* If true, the fields will be rendered immediately, rather than waiting for them to intersect with the viewport.
*
* If a number is provided, will immediately render fields _up to that index_.
*/
forceRender?: boolean | number
indexPath?: string
margins?: 'small' | false
operation?: Operation

View File

@@ -4,7 +4,7 @@ import React, { forwardRef } from 'react'
import type { Props } from '../../elements/Button/types.js'
import { Button } from '../../elements/Button/index.js'
import { useForm, useFormProcessing } from '../Form/context.js'
import { useForm, useFormInitializing, useFormProcessing } from '../Form/context.js'
import './index.scss'
const baseClass = 'form-submit'
@@ -12,9 +12,10 @@ const baseClass = 'form-submit'
export const FormSubmit = forwardRef<HTMLButtonElement, Props>((props, ref) => {
const { type = 'submit', buttonId: id, children, disabled: disabledFromProps } = props
const processing = useFormProcessing()
const initializing = useFormInitializing()
const { disabled } = useForm()
const canSave = !(disabledFromProps || processing || disabled)
const canSave = !(disabledFromProps || initializing || processing || disabled)
return (
<div className={baseClass}>

View File

@@ -1,4 +1,5 @@
import type { Data, Field as FieldSchema, PayloadRequestWithData } from 'payload/types'
import type { User } from 'payload/auth'
import type { Data, Field as FieldSchema } from 'payload/types'
import { iterateFields } from './iterateFields.js'
@@ -6,17 +7,25 @@ type Args = {
data: Data
fields: FieldSchema[]
id?: number | string
req: PayloadRequestWithData
locale: string | undefined
siblingData: Data
user: User
}
export const calculateDefaultValues = async ({ id, data, fields, req }: Args): Promise<Data> => {
export const calculateDefaultValues = async ({
id,
data,
fields,
locale,
user,
}: Args): Promise<Data> => {
await iterateFields({
id,
data,
fields,
req,
locale,
siblingData: data,
user,
})
return data

View File

@@ -1,4 +1,5 @@
import type { Data, Field, PayloadRequestWithData, TabAsField } from 'payload/types'
import type { User } from 'payload/auth'
import type { Data, Field, TabAsField } from 'payload/types'
import { defaultValuePromise } from './promise.js'
@@ -6,16 +7,18 @@ type Args<T> = {
data: T
fields: (Field | TabAsField)[]
id?: number | string
req: PayloadRequestWithData
locale: string | undefined
siblingData: Data
user: User
}
export const iterateFields = async <T>({
id,
data,
fields,
req,
locale,
siblingData,
user,
}: Args<T>): Promise<void> => {
const promises = []
fields.forEach((field) => {
@@ -24,8 +27,9 @@ export const iterateFields = async <T>({
id,
data,
field,
req,
locale,
siblingData,
user,
}),
)
})

View File

@@ -1,12 +1,7 @@
import type { User } from 'payload/auth'
import type { Data } from 'payload/types'
import {
type Field,
type PayloadRequestWithData,
type TabAsField,
fieldAffectsData,
tabHasName,
} from 'payload/types'
import { type Field, type TabAsField, fieldAffectsData, tabHasName } from 'payload/types'
import { getDefaultValue } from 'payload/utilities'
import { iterateFields } from './iterateFields.js'
@@ -15,8 +10,9 @@ type Args<T> = {
data: T
field: Field | TabAsField
id?: number | string
req: PayloadRequestWithData
locale: string | undefined
siblingData: Data
user: User
}
// TODO: Make this works for rich text subfields
@@ -24,8 +20,9 @@ export const defaultValuePromise = async <T>({
id,
data,
field,
req,
locale,
siblingData,
user,
}: Args<T>): Promise<void> => {
if (fieldAffectsData(field)) {
if (
@@ -34,8 +31,8 @@ export const defaultValuePromise = async <T>({
) {
siblingData[field.name] = await getDefaultValue({
defaultValue: field.defaultValue,
locale: req.locale,
user: req.user,
locale,
user,
value: siblingData[field.name],
})
}
@@ -52,8 +49,9 @@ export const defaultValuePromise = async <T>({
id,
data,
fields: field.fields,
req,
locale,
siblingData: groupData,
user,
})
break
@@ -70,8 +68,9 @@ export const defaultValuePromise = async <T>({
id,
data,
fields: field.fields,
req,
locale,
siblingData: row,
user,
}),
)
})
@@ -97,8 +96,9 @@ export const defaultValuePromise = async <T>({
id,
data,
fields: block.fields,
req,
locale,
siblingData: row,
user,
}),
)
}
@@ -115,8 +115,9 @@ export const defaultValuePromise = async <T>({
id,
data,
fields: field.fields,
req,
locale,
siblingData,
user,
})
break
@@ -136,8 +137,9 @@ export const defaultValuePromise = async <T>({
id,
data,
fields: field.fields,
req,
locale,
siblingData: tabSiblingData,
user,
})
break
@@ -148,8 +150,9 @@ export const defaultValuePromise = async <T>({
id,
data,
fields: field.tabs.map((tab) => ({ ...tab, type: 'tab' })),
req,
locale,
siblingData,
user,
})
break

View File

@@ -41,8 +41,9 @@ export const buildStateFromSchema = async (args: Args): Promise<FormState> => {
id,
data: fullData,
fields: fieldSchema,
req,
locale: req.locale,
siblingData: fullData,
user: req.user,
})
await iterateFields({

View File

@@ -16,6 +16,7 @@ import { useFieldProps } from '../FieldPropsProvider/index.js'
import {
useForm,
useFormFields,
useFormInitializing,
useFormModified,
useFormProcessing,
useFormSubmitted,
@@ -36,6 +37,7 @@ export const useField = <T,>(options: Options): FieldType<T> => {
const submitted = useFormSubmitted()
const processing = useFormProcessing()
const initializing = useFormInitializing()
const { user } = useAuth()
const { id } = useDocumentInfo()
const operation = useOperation()
@@ -108,6 +110,7 @@ export const useField = <T,>(options: Options): FieldType<T> => {
errorMessage: field?.errorMessage,
errorPaths: field?.errorPaths || [],
filterOptions,
formInitializing: initializing,
formProcessing: processing,
formSubmitted: submitted,
initialValue,
@@ -137,6 +140,7 @@ export const useField = <T,>(options: Options): FieldType<T> => {
readOnly,
permissions,
filterOptions,
initializing,
],
)

View File

@@ -14,6 +14,7 @@ export type FieldType<T> = {
errorMessage?: string
errorPaths?: string[]
filterOptions?: FilterOptionsResult
formInitializing: boolean
formProcessing: boolean
formSubmitted: boolean
initialValue?: T

View File

@@ -9,7 +9,6 @@ import React, { createContext, useCallback, useContext, useEffect, useRef, useSt
import type { DocumentInfoContext, DocumentInfoProps } from './types.js'
import { LoadingOverlay } from '../../elements/Loading/index.js'
import { formatDocTitle } from '../../utilities/formatDocTitle.js'
import { getFormState } from '../../utilities/getFormState.js'
import { hasSavePermission as getHasSavePermission } from '../../utilities/hasSavePermission.js'
@@ -39,29 +38,12 @@ export const DocumentInfoProvider: React.FC<
globalSlug,
hasPublishPermission: hasPublishPermissionFromProps,
hasSavePermission: hasSavePermissionFromProps,
initialData: initialDataFromProps,
initialState: initialStateFromProps,
onLoadError,
onSave: onSaveFromProps,
} = props
const [isLoading, setIsLoading] = useState(false)
const [isError, setIsError] = useState(false)
const [documentTitle, setDocumentTitle] = useState('')
const [data, setData] = useState<Data>()
const [initialState, setInitialState] = useState<FormState>()
const [publishedDoc, setPublishedDoc] = useState<TypeWithID & TypeWithTimestamps>(null)
const [versions, setVersions] = useState<PaginatedDocs<TypeWithVersion<any>>>(null)
const [docPermissions, setDocPermissions] = useState<DocumentPermissions>(null)
const [hasSavePermission, setHasSavePermission] = useState<boolean>(null)
const [hasPublishPermission, setHasPublishPermission] = useState<boolean>(null)
const hasInitializedDocPermissions = useRef(false)
const [unpublishedVersions, setUnpublishedVersions] =
useState<PaginatedDocs<TypeWithVersion<any>>>(null)
const { getPreference, setPreference } = usePreferences()
const { i18n } = useTranslation()
const { permissions } = useAuth()
const { code: locale } = useLocale()
const {
admin: { dateFormat },
collections,
@@ -73,6 +55,43 @@ export const DocumentInfoProvider: React.FC<
const collectionConfig = collections.find((c) => c.slug === collectionSlug)
const globalConfig = globals.find((g) => g.slug === globalSlug)
const docConfig = collectionConfig || globalConfig
const { i18n } = useTranslation()
const [documentTitle, setDocumentTitle] = useState(() => {
if (!initialDataFromProps) return ''
return formatDocTitle({
collectionConfig,
data: { ...initialDataFromProps, id },
dateFormat,
fallback: id?.toString(),
globalConfig,
i18n,
})
})
const [isLoading, setIsLoading] = useState(false)
const [isError, setIsError] = useState(false)
const [data, setData] = useState<Data>(initialDataFromProps)
const [initialState, setInitialState] = useState<FormState>(initialStateFromProps)
const [publishedDoc, setPublishedDoc] = useState<TypeWithID & TypeWithTimestamps>(null)
const [versions, setVersions] = useState<PaginatedDocs<TypeWithVersion<any>>>(null)
const [docPermissions, setDocPermissions] = useState<DocumentPermissions>(docPermissionsFromProps)
const [hasSavePermission, setHasSavePermission] = useState<boolean>(hasSavePermissionFromProps)
const [hasPublishPermission, setHasPublishPermission] = useState<boolean>(
hasPublishPermissionFromProps,
)
const isInitializing = initialState === undefined || data === undefined
const hasInitializedDocPermissions = useRef(false)
const [unpublishedVersions, setUnpublishedVersions] =
useState<PaginatedDocs<TypeWithVersion<any>>>(null)
const { getPreference, setPreference } = usePreferences()
const { permissions } = useAuth()
const { code: locale } = useLocale()
const prevLocale = useRef(locale)
const versionsConfig = docConfig?.versions
const baseURL = `${serverURL}${api}`
@@ -296,7 +315,7 @@ export const DocumentInfoProvider: React.FC<
)
}
},
[serverURL, api, permissions, i18n.language, locale, collectionSlug, globalSlug, isEditing],
[serverURL, api, permissions, i18n.language, locale, collectionSlug, globalSlug],
)
const getDocPreferences = useCallback(() => {
@@ -372,47 +391,67 @@ export const DocumentInfoProvider: React.FC<
useEffect(() => {
const abortController = new AbortController()
const localeChanged = locale !== prevLocale.current
const getInitialState = async () => {
setIsError(false)
setIsLoading(true)
if (
initialStateFromProps === undefined ||
initialDataFromProps === undefined ||
localeChanged
) {
if (localeChanged) prevLocale.current = locale
try {
const result = await getFormState({
apiRoute: api,
body: {
id,
collectionSlug,
globalSlug,
locale,
operation,
schemaPath: collectionSlug || globalSlug,
},
onError: onLoadError,
serverURL,
signal: abortController.signal,
})
const getInitialState = async () => {
setIsError(false)
setIsLoading(true)
setData(reduceFieldsToValues(result, true))
setInitialState(result)
} catch (err) {
if (!abortController.signal.aborted) {
if (typeof onLoadError === 'function') {
void onLoadError()
try {
const result = await getFormState({
apiRoute: api,
body: {
id,
collectionSlug,
globalSlug,
locale,
operation,
schemaPath: collectionSlug || globalSlug,
},
onError: onLoadError,
serverURL,
signal: abortController.signal,
})
setData(reduceFieldsToValues(result, true))
setInitialState(result)
} catch (err) {
if (!abortController.signal.aborted) {
if (typeof onLoadError === 'function') {
void onLoadError()
}
setIsError(true)
setIsLoading(false)
}
setIsError(true)
setIsLoading(false)
}
setIsLoading(false)
}
setIsLoading(false)
}
void getInitialState()
void getInitialState()
}
return () => {
abortController.abort()
}
}, [api, operation, collectionSlug, serverURL, id, globalSlug, locale, onLoadError])
}, [
api,
operation,
collectionSlug,
serverURL,
id,
globalSlug,
locale,
onLoadError,
initialDataFromProps,
initialStateFromProps,
])
useEffect(() => {
void getVersions()
@@ -445,10 +484,6 @@ export const DocumentInfoProvider: React.FC<
hasPublishPermission === null
) {
await getDocPermissions(data)
} else {
setDocPermissions(docPermissions)
setHasSavePermission(hasSavePermission)
setHasPublishPermission(hasPublishPermission)
}
}
@@ -469,10 +504,6 @@ export const DocumentInfoProvider: React.FC<
if (isError) notFound()
if (!initialState || isLoading) {
return <LoadingOverlay />
}
const value: DocumentInfoContext = {
...props,
docConfig,
@@ -484,6 +515,8 @@ export const DocumentInfoProvider: React.FC<
hasSavePermission,
initialData: data,
initialState,
isInitializing,
isLoading,
onSave,
publishedDoc,
setDocFieldPreferences,

View File

@@ -29,6 +29,8 @@ export type DocumentInfoProps = {
hasPublishPermission?: boolean
hasSavePermission?: boolean
id: null | number | string
initialData?: Data
initialState?: FormState
isEditing?: boolean
onLoadError?: (data?: any) => Promise<void> | void
onSave?: (data: Data) => Promise<void> | void
@@ -41,6 +43,8 @@ export type DocumentInfoContext = DocumentInfoProps & {
getVersions: () => Promise<void>
initialData: Data
initialState?: FormState
isInitializing: boolean
isLoading: boolean
preferencesKey?: string
publishedDoc?: TypeWithID & TypeWithTimestamps & { _status?: string }
setDocFieldPreferences: (

View File

@@ -1,6 +1,6 @@
'use client'
import type { I18nClient, Language } from '@payloadcms/translations'
import type { ClientConfig } from 'payload/types'
import type { ClientConfig, LanguageOptions } from 'payload/types'
import * as facelessUIImport from '@faceless-ui/modal'
import * as facelessUIImport3 from '@faceless-ui/scroll-info'
@@ -10,7 +10,6 @@ import { Slide, ToastContainer } from 'react-toastify'
import type { ComponentMap } from '../ComponentMap/buildComponentMap/types.js'
import type { Theme } from '../Theme/index.js'
import type { LanguageOptions } from '../Translation/index.js'
import { LoadingOverlayProvider } from '../../elements/LoadingOverlay/index.js'
import { NavProvider } from '../../elements/Nav/context.js'

View File

@@ -7,7 +7,7 @@ import type {
TFunction,
} from '@payloadcms/translations'
import type { Locale } from 'date-fns'
import type { ClientConfig } from 'payload/types'
import type { ClientConfig, LanguageOptions } from 'payload/types'
import { t } from '@payloadcms/translations'
import { importDateFNSLocale } from '@payloadcms/translations'
@@ -16,11 +16,6 @@ import React, { createContext, useContext, useEffect, useState } from 'react'
import { useRouteCache } from '../RouteCache/index.js'
export type LanguageOptions = {
label: string
value: string
}[]
type ContextType<
TAdditionalTranslations = {},
TAdditionalClientTranslationKeys extends string = never,

View File

@@ -8,14 +8,16 @@ export const getFormState = async (args: {
onError?: (data?: any) => Promise<void> | void
serverURL: SanitizedConfig['serverURL']
signal?: AbortSignal
token?: string
}): Promise<FormState> => {
const { apiRoute, body, onError, serverURL, signal } = args
const { apiRoute, body, onError, serverURL, signal, token } = args
const res = await fetch(`${serverURL}${apiRoute}/form-state`, {
body: JSON.stringify(body),
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `JWT ${token}` } : {}),
},
method: 'POST',
signal,

View File

@@ -16,6 +16,8 @@ export const reduceFieldsToValues = (
): Data => {
let data = {}
if (!fields) return data
Object.keys(fields).forEach((key) => {
if (ignoreDisableFormData === true || !fields[key]?.disableFormData) {
data[key] = fields[key]?.value

View File

@@ -170,12 +170,12 @@ describe('access control', () => {
test('should not have list url', async () => {
await page.goto(restrictedUrl.list)
await expect(page.locator('.unauthorized')).toBeVisible()
await expect(page.locator('.not-found')).toBeVisible()
})
test('should not have create url', async () => {
await page.goto(restrictedUrl.create)
await expect(page.locator('.unauthorized')).toBeVisible()
await expect(page.locator('.not-found')).toBeVisible()
})
test('should not have access to existing doc', async () => {
@@ -321,13 +321,12 @@ describe('access control', () => {
name: 'unrestricted-123',
},
})
await page.goto(unrestrictedURL.edit(unrestrictedDoc.id.toString()))
const field = page.locator('#field-userRestrictedDocs')
await expect(field.locator('input')).toBeEnabled()
const addDocButton = page.locator(
'#userRestrictedDocs-add-new button.relationship-add-new__add-button.doc-drawer__toggler',
)
await addDocButton.click()
const documentDrawer = page.locator('[id^=doc-drawer_user-restricted-collection_1_]')
await expect(documentDrawer).toBeVisible()

View File

@@ -7,7 +7,8 @@ import React from 'react'
export const FieldDescriptionComponent: DescriptionComponent = () => {
const { path } = useFieldProps()
const { value } = useFormFields(([fields]) => fields[path])
const field = useFormFields(([fields]) => (fields && fields?.[path]) || null)
const { value } = field || {}
return (
<div className={`field-description-${path}`}>

View File

@@ -23,16 +23,15 @@ export const ClientForm: React.FC = () => {
>
<CustomPassword />
<ConfirmPassword />
<FormSubmit>Submit</FormSubmit>
</Form>
)
}
const CustomPassword: React.FC = () => {
const confirmPassword = useFormFields(([fields]) => {
return fields['confirm-password']
})
const confirmPassword = useFormFields(
([fields]) => (fields && fields?.['confirm-password']) || null,
)
const confirmValue = confirmPassword.value

View File

@@ -114,7 +114,7 @@ describe('admin2', () => {
// prefill search with "a" from the query param
await page.goto(`${postsUrl.list}?search=dennis`)
await page.waitForURL(`${postsUrl.list}?search=dennis`)
await page.waitForURL(new RegExp(`${postsUrl.list}\\?search=dennis`))
// input should be filled out, list should filter
await expect(page.locator('.search-filter__input')).toHaveValue('dennis')
@@ -624,7 +624,7 @@ describe('admin2', () => {
test('should delete many', async () => {
await page.goto(postsUrl.list)
await page.waitForURL(postsUrl.list)
await page.waitForURL(new RegExp(postsUrl.list))
// delete should not appear without selection
await expect(page.locator('#confirm-delete')).toHaveCount(0)
// select one row

View File

@@ -119,22 +119,18 @@ describe('auth', () => {
await page.locator('#change-password').click()
await page.locator('#field-password').fill('password')
await page.locator('#field-confirm-password').fill('password')
await saveDocAndAssert(page)
await expect(page.locator('#field-email')).toHaveValue(emailBeforeSave)
})
test('should have up-to-date user in `useAuth` hook', async () => {
await page.goto(url.account)
await page.waitForURL(url.account)
await expect(page.locator('#users-api-result')).toHaveText('Hello, world!')
await expect(page.locator('#use-auth-result')).toHaveText('Hello, world!')
const field = page.locator('#field-custom')
await field.fill('Goodbye, world!')
await saveDocAndAssert(page)
await expect(page.locator('#users-api-result')).toHaveText('Goodbye, world!')
await expect(page.locator('#use-auth-result')).toHaveText('Goodbye, world!')
})

View File

@@ -18,6 +18,7 @@ import type {
import {
ensureAutoLoginAndCompilationIsDone,
initPageConsoleErrorCatch,
openCreateDocDrawer,
openDocControls,
openDocDrawer,
saveDocAndAssert,
@@ -141,19 +142,13 @@ describe('fields - relationship', () => {
test('should create relationship', 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
// Select a relationship
await options.nth(0).click()
await expect(field).toContainText(relationOneDoc.id)
await saveDocAndAssert(page)
})
@@ -186,30 +181,20 @@ describe('fields - relationship', () => {
test('should create hasMany relationship', async () => {
await page.goto(url.create)
const field = page.locator('#field-relationshipHasMany')
await expect(field.locator('input')).toBeEnabled()
await field.click({ delay: 100 })
const options = page.locator('.rs__option')
await expect(options).toHaveCount(2) // Two relationship options
const values = page.locator('#field-relationshipHasMany .relationship--multi-value-label__text')
// Add one relationship
await options.locator(`text=${relationOneDoc.id}`).click()
await expect(values).toHaveText([relationOneDoc.id])
await expect(values).not.toHaveText([anotherRelationOneDoc.id])
// Add second relationship
await field.click({ delay: 100 })
await options.locator(`text=${anotherRelationOneDoc.id}`).click()
await expect(values).toHaveText([relationOneDoc.id, anotherRelationOneDoc.id])
// No options left
await field.locator('.rs__input').click({ delay: 100 })
await expect(page.locator('.rs__menu')).toHaveText('No options')
await saveDocAndAssert(page)
await wait(200)
await expect(values).toHaveText([relationOneDoc.id, anotherRelationOneDoc.id])
@@ -257,49 +242,30 @@ describe('fields - relationship', () => {
async function runFilterOptionsTest(fieldName: string) {
await page.reload()
await page.goto(url.edit(docWithExistingRelations.id))
// fill the first relation field
const field = page.locator('#field-relationship')
await expect(field.locator('input')).toBeEnabled()
await field.click({ delay: 100 })
const options = page.locator('.rs__option')
await options.nth(0).click()
await expect(field).toContainText(relationOneDoc.id)
// then verify that the filtered field's options match
let filteredField = page.locator(`#field-${fieldName} .react-select`)
await filteredField.click({ delay: 100 })
let filteredOptions = filteredField.locator('.rs__option')
await expect(filteredOptions).toHaveCount(1) // one doc
await filteredOptions.nth(0).click()
await expect(filteredField).toContainText(relationOneDoc.id)
// change the first relation field
await field.click({ delay: 100 })
await options.nth(1).click()
await expect(field).toContainText(anotherRelationOneDoc.id)
// Need to wait form state to come back
// before clicking save
await wait(2000)
// Now, save the document. This should fail, as the filitered field doesn't match the selected relationship value
await wait(2000) // Need to wait form state to come back before clicking save
await page.locator('#action-save').click()
await expect(page.locator('.Toastify')).toContainText(`is invalid: ${fieldName}`)
// then verify that the filtered field's options match
filteredField = page.locator(`#field-${fieldName} .react-select`)
await filteredField.click({ delay: 100 })
filteredOptions = filteredField.locator('.rs__option')
await expect(filteredOptions).toHaveCount(2) // two options because the currently selected option is still there
await filteredOptions.nth(1).click()
await expect(filteredField).toContainText(anotherRelationOneDoc.id)
// Now, saving the document should succeed
await saveDocAndAssert(page)
}
@@ -433,30 +399,17 @@ describe('fields - relationship', () => {
test('should open document drawer and append newly created docs onto the parent field', async () => {
await page.goto(url.edit(docWithExistingRelations.id))
const field = page.locator('#field-relationshipHasMany')
// open the document drawer
const addNewButton = field.locator(
'button.relationship-add-new__add-button.doc-drawer__toggler',
)
await addNewButton.click()
await openCreateDocDrawer(page, '#field-relationshipHasMany')
const documentDrawer = page.locator('[id^=doc-drawer_relation-one_1_]')
await expect(documentDrawer).toBeVisible()
// fill in the field and save the document, keep the drawer open for further testing
const drawerField = documentDrawer.locator('#field-name')
await drawerField.fill('Newly created document')
const saveButton = documentDrawer.locator('#action-save')
await saveButton.click()
await expect(page.locator('.Toastify')).toContainText('successfully')
// count the number of values in the field to ensure only one was added
await expect(
page.locator('#field-relationshipHasMany .value-container .rs__multi-value'),
).toHaveCount(1)
// save the same document again to ensure the relationship field doesn't receive duplicative values
await drawerField.fill('Updated document')
await saveButton.click()
await expect(page.locator('.Toastify')).toContainText('Updated successfully')
@@ -469,12 +422,9 @@ describe('fields - relationship', () => {
describe('existing relationships', () => {
test('should highlight existing relationship', async () => {
await page.goto(url.edit(docWithExistingRelations.id))
const field = page.locator('#field-relationship')
// Check dropdown options
await expect(field.locator('input')).toBeEnabled()
await field.click({ delay: 100 })
await expect(page.locator('.rs__option--is-selected')).toHaveCount(1)
await expect(page.locator('.rs__option--is-selected')).toHaveText(relationOneDoc.id)
})

View File

@@ -12,7 +12,7 @@ import {
ensureAutoLoginAndCompilationIsDone,
exactText,
initPageConsoleErrorCatch,
openDocDrawer,
openCreateDocDrawer,
saveDocAndAssert,
saveDocHotkeyAndAssert,
} from '../../../helpers.js'
@@ -77,26 +77,20 @@ describe('relationship', () => {
test('should create inline relationship within field with many relations', async () => {
await page.goto(url.create)
await openDocDrawer(page, '#relationship-add-new .relationship-add-new__add-button')
await openCreateDocDrawer(page, '#field-relationship')
await page
.locator('#field-relationship .relationship-add-new__relation-button--text-fields')
.click()
const textField = page.locator('.drawer__content #field-text')
await expect(textField).toBeEnabled()
const textValue = 'hello'
await textField.fill(textValue)
await page.locator('[id^=doc-drawer_text-fields_1_] #action-save').click()
await expect(page.locator('.Toastify')).toContainText('successfully')
await page.locator('[id^=close-drawer__doc-drawer_text-fields_1_]').click()
await expect(
page.locator('#field-relationship .relationship--single-value__text'),
).toContainText(textValue)
await page.locator('#action-save').click()
await expect(page.locator('.Toastify')).toContainText('successfully')
})
@@ -105,7 +99,7 @@ describe('relationship', () => {
await page.goto(url.create)
await page.waitForURL(`**/${url.create}`)
// Open first modal
await openDocDrawer(page, '#relationToSelf-add-new .relationship-add-new__add-button')
await openCreateDocDrawer(page, '#field-relationToSelf')
// Fill first modal's required relationship field
await page.locator('[id^=doc-drawer_relationship-fields_1_] #field-relationship').click()
@@ -115,11 +109,10 @@ describe('relationship', () => {
)
.click()
// Open second modal
await openDocDrawer(
page,
const secondModalButton = page.locator(
'[id^=doc-drawer_relationship-fields_1_] #relationToSelf-add-new button',
)
await secondModalButton.click()
// Fill second modal's required relationship field
await page.locator('[id^=doc-drawer_relationship-fields_2_] #field-relationship').click()
@@ -249,7 +242,7 @@ describe('relationship', () => {
await page.goto(url.create)
await page.waitForURL(`**/${url.create}`)
// First fill out the relationship field, as it's required
await openDocDrawer(page, '#relationship-add-new .relationship-add-new__add-button')
await openCreateDocDrawer(page, '#field-relationship')
await page
.locator('#field-relationship .relationship-add-new__relation-button--text-fields')
.click()
@@ -264,7 +257,7 @@ describe('relationship', () => {
// Create a new doc for the `relationshipHasMany` field
await expect.poll(() => page.url(), { timeout: POLL_TOPASS_TIMEOUT }).not.toContain('create')
await openDocDrawer(page, '#field-relationshipHasMany .relationship-add-new__add-button')
await openCreateDocDrawer(page, '#field-relationshipHasMany')
const value = 'Hello, world!'
await page.locator('.drawer__content #field-text').fill(value)
@@ -313,7 +306,7 @@ describe('relationship', () => {
test('should save using hotkey in edit document drawer', async () => {
await page.goto(url.create)
// First fill out the relationship field, as it's required
await openDocDrawer(page, '#relationship-add-new .relationship-add-new__add-button')
await openCreateDocDrawer(page, '#field-relationship')
await page.locator('#field-relationship .value-container').click()
await wait(500)
// Select "Seeded text document" relationship
@@ -354,7 +347,7 @@ describe('relationship', () => {
test.skip('should bypass min rows validation when no rows present and field is not required', async () => {
await page.goto(url.create)
// First fill out the relationship field, as it's required
await openDocDrawer(page, '#relationship-add-new .relationship-add-new__add-button')
await openCreateDocDrawer(page, '#field-relationship')
await page.locator('#field-relationship .value-container').click()
await page.getByText('Seeded text document', { exact: true }).click()
@@ -366,7 +359,7 @@ describe('relationship', () => {
await page.goto(url.create)
await page.waitForURL(url.create)
// First fill out the relationship field, as it's required
await openDocDrawer(page, '#relationship-add-new .relationship-add-new__add-button')
await openCreateDocDrawer(page, '#field-relationship')
await page.locator('#field-relationship .value-container').click()
await page.getByText('Seeded text document', { exact: true }).click()
@@ -425,7 +418,7 @@ describe('relationship', () => {
await createRelationshipFieldDoc({ value: textDoc.id, relationTo: 'text-fields' })
await page.goto(url.list)
await page.waitForURL(url.list)
await page.waitForURL(new RegExp(url.list))
await wait(400)
await page.locator('.list-controls__toggle-columns').click()
@@ -439,6 +432,7 @@ describe('relationship', () => {
await wait(400)
const conditionField = page.locator('.condition__field')
await expect(conditionField.locator('input')).toBeEnabled()
await conditionField.click()
await wait(400)
@@ -447,6 +441,7 @@ describe('relationship', () => {
await wait(400)
const operatorField = page.locator('.condition__operator')
await expect(operatorField.locator('input')).toBeEnabled()
await operatorField.click()
await wait(400)
@@ -455,6 +450,7 @@ describe('relationship', () => {
await wait(400)
const valueField = page.locator('.condition__value')
await expect(valueField.locator('input')).toBeEnabled()
await valueField.click()
await wait(400)

View File

@@ -183,8 +183,6 @@ describe('Rich Text', () => {
test('should not create new url link when read only', async () => {
await navigateToRichTextFields()
// Attempt to open link popup
const modalTrigger = page.locator('.rich-text--read-only .rich-text__toolbar button .link')
await expect(modalTrigger).toBeDisabled()
})
@@ -421,19 +419,14 @@ describe('Rich Text', () => {
})
test('should not take value from previous block', async () => {
await navigateToRichTextFields()
// check first block value
const textField = page.locator('#field-blocks__0__text')
await expect(textField).toHaveValue('Regular text')
// remove the first block
await page.locator('#field-blocks').scrollIntoViewIfNeeded()
await expect(page.locator('#field-blocks__0__text')).toBeVisible()
await expect(page.locator('#field-blocks__0__text')).toHaveValue('Regular text')
const editBlock = page.locator('#blocks-row-0 .popup-button')
await editBlock.click()
const removeButton = page.locator('#blocks-row-0').getByRole('button', { name: 'Remove' })
await expect(removeButton).toBeVisible()
await removeButton.click()
// check new first block value
const richTextField = page.locator('#field-blocks__0__text')
const richTextValue = await richTextField.innerText()
expect(richTextValue).toContain('Rich text')

View File

@@ -146,12 +146,13 @@ describe('fields', () => {
test('should create', async () => {
const input = '{"foo": "bar"}'
await page.goto(url.create)
const json = page.locator('.json-field .inputarea')
await json.fill(input)
await saveDocAndAssert(page, '.form-submit button')
await page.waitForURL(url.create)
await expect(() => expect(page.locator('.json-field .code-editor')).toBeVisible()).toPass({
timeout: POLL_TOPASS_TIMEOUT,
})
await page.locator('.json-field .inputarea').fill(input)
await saveDocAndAssert(page)
await expect(page.locator('.json-field')).toContainText('"foo": "bar"')
})
})
@@ -256,13 +257,15 @@ describe('fields', () => {
test('should have disabled admin sorting', async () => {
await page.goto(url.create)
const field = page.locator('#field-disableSort .array-actions__action-chevron')
const field = page.locator('#field-disableSort > div > div > .array-actions__action-chevron')
expect(await field.count()).toEqual(0)
})
test('the drag handle should be hidden', async () => {
await page.goto(url.create)
const field = page.locator('#field-disableSort .collapsible__drag')
const field = page.locator(
'#field-disableSort > .blocks-field__rows > div > div > .collapsible__drag',
)
expect(await field.count()).toEqual(0)
})
})
@@ -275,13 +278,15 @@ describe('fields', () => {
test('should have disabled admin sorting', async () => {
await page.goto(url.create)
const field = page.locator('#field-disableSort .array-actions__action-chevron')
const field = page.locator('#field-disableSort > div > div > .array-actions__action-chevron')
expect(await field.count()).toEqual(0)
})
test('the drag handle should be hidden', async () => {
await page.goto(url.create)
const field = page.locator('#field-disableSort .collapsible__drag')
const field = page.locator(
'#field-disableSort > .blocks-field__rows > div > div > .collapsible__drag',
)
expect(await field.count()).toEqual(0)
})
})

View File

@@ -75,6 +75,10 @@ export async function ensureAutoLoginAndCompilationIsDone({
await page.goto(adminURL)
await page.waitForURL(adminURL)
await expect(() => expect(page.locator('.template-default')).toBeVisible()).toPass({
timeout: POLL_TOPASS_TIMEOUT,
})
await expect(() => expect(page.url()).not.toContain(`${adminRoute}${loginRoute}`)).toPass({
timeout: POLL_TOPASS_TIMEOUT,
})
@@ -85,7 +89,6 @@ export async function ensureAutoLoginAndCompilationIsDone({
timeout: POLL_TOPASS_TIMEOUT,
})
// Check if hero is there
await expect(page.locator('.dashboard__label').first()).toBeVisible()
}
@@ -200,6 +203,16 @@ export async function openDocDrawer(page: Page, selector: string): Promise<void>
await wait(500) // wait for drawer form state to initialize
}
export async function openCreateDocDrawer(page: Page, fieldSelector: string): Promise<void> {
await wait(500) // wait for parent form state to initialize
const relationshipField = page.locator(fieldSelector)
await expect(relationshipField.locator('input')).toBeEnabled()
const addNewButton = relationshipField.locator('.relationship-add-new__add-button')
await expect(addNewButton).toBeVisible()
await addNewButton.click()
await wait(500) // wait for drawer form state to initialize
}
export async function closeNav(page: Page): Promise<void> {
if (!(await page.locator('.template-default.template-default--nav-open').isVisible())) return
await page.locator('.nav-toggler >> visible=true').click()

View File

@@ -123,27 +123,15 @@ describe('Localization', () => {
test('create arabic post, add english', async () => {
await page.goto(url.create)
const newLocale = 'ar'
// Change to Arabic
await changeLocale(page, newLocale)
await fillValues({ description, title: arabicTitle })
await saveDocAndAssert(page)
// Change back to English
await changeLocale(page, defaultLocale)
// Localized field should not be populated
await expect(page.locator('#field-title')).toBeEmpty()
await expect(page.locator('#field-description')).toHaveValue(description)
// Add English
await fillValues({ description, title })
await saveDocAndAssert(page)
await expect(page.locator('#field-title')).toHaveValue(title)
await expect(page.locator('#field-description')).toHaveValue(description)
})
@@ -175,56 +163,45 @@ describe('Localization', () => {
await page.goto(url.edit(id))
await page.waitForURL(`**${url.edit(id)}`)
await openDocControls(page)
// duplicate document
await page.locator('#action-duplicate').click()
await expect(page.locator('.Toastify')).toContainText('successfully')
await expect.poll(() => page.url(), { timeout: POLL_TOPASS_TIMEOUT }).not.toContain(id)
// check fields
await expect(page.locator('#field-title')).toHaveValue(englishTitle)
await changeLocale(page, spanishLocale)
await expect(page.locator('#field-title')).toBeEnabled()
await expect(page.locator('#field-title')).toHaveValue(spanishTitle)
await expect(page.locator('#field-localizedCheckbox')).toBeEnabled()
await page.reload() // TODO: remove this line, the checkbox _is not_ checked, but Playwright is unable to detect it without a reload for some reason
await expect(page.locator('#field-localizedCheckbox')).not.toBeChecked()
})
test('should duplicate localized checkbox correctly', async () => {
await page.goto(url.create)
await page.waitForURL(url.create)
await changeLocale(page, defaultLocale)
await fillValues({ description, title: englishTitle })
await expect(page.locator('#field-localizedCheckbox')).toBeEnabled()
await page.locator('#field-localizedCheckbox').click()
await page.locator('#action-save').click()
// wait for navigation to update route
await expect.poll(() => page.url(), { timeout: POLL_TOPASS_TIMEOUT }).not.toContain('create')
const collectionUrl = page.url()
// ensure spanish is not checked
await changeLocale(page, spanishLocale)
await expect(page.locator('#field-localizedCheckbox')).toBeEnabled()
await page.reload() // TODO: remove this line, the checkbox _is not_ checked, but Playwright is unable to detect it without a reload for some reason
await expect(page.locator('#field-localizedCheckbox')).not.toBeChecked()
// duplicate doc
await changeLocale(page, defaultLocale)
await openDocControls(page)
await page.locator('#action-duplicate').click()
// wait for navigation to update route
await expect
.poll(() => page.url(), { timeout: POLL_TOPASS_TIMEOUT })
.not.toContain(collectionUrl)
// finally change locale to spanish
await changeLocale(page, spanishLocale)
await expect(page.locator('#field-localizedCheckbox')).toBeEnabled()
await page.reload() // TODO: remove this line, the checkbox _is not_ checked, but Playwright is unable to detect it without a reload for some reason
await expect(page.locator('#field-localizedCheckbox')).not.toBeChecked()
})
test('should duplicate even if missing some localized data', async () => {
// create a localized required doc
await page.goto(urlWithRequiredLocalizedFields.create)
await changeLocale(page, defaultLocale)
await page.locator('#field-title').fill(englishTitle)
@@ -233,21 +210,12 @@ describe('Localization', () => {
await page.fill('#field-layout__0__text', 'test')
await expect(page.locator('#field-layout__0__text')).toHaveValue('test')
await saveDocAndAssert(page)
const originalID = await page.locator('.id-label').innerText()
// duplicate
await openDocControls(page)
await page.locator('#action-duplicate').click()
await expect(page.locator('.id-label')).not.toContainText(originalID)
// verify that the locale did copy
await expect(page.locator('#field-title')).toHaveValue(englishTitle)
// await the success toast
await expect(page.locator('.Toastify')).toContainText('successfully duplicated')
// expect that the document has a new id
await expect(page.locator('.id-label')).not.toContainText(originalID)
})
})

View File

@@ -107,9 +107,6 @@ test.describe('Form Builder', () => {
})
test('can create form submission', async () => {
await page.goto(submissionsUrl.list)
await page.waitForURL(submissionsUrl.list)
const { docs } = await payload.find({
collection: 'forms',
})

View File

@@ -254,7 +254,7 @@ describe('uploads', () => {
)
})
test('Should render adminThumbnail when using a function', async () => {
test('should render adminThumbnail when using a function', async () => {
await page.reload() // Flakey test, it likely has to do with the test that comes before it. Trace viewer is not helpful when it fails.
await page.goto(adminThumbnailFunctionURL.list)
await page.waitForURL(adminThumbnailFunctionURL.list)
@@ -267,7 +267,7 @@ describe('uploads', () => {
)
})
test('Should render adminThumbnail when using a specific size', async () => {
test('should render adminThumbnail when using a specific size', async () => {
await page.goto(adminThumbnailSizeURL.list)
await page.waitForURL(adminThumbnailSizeURL.list)
@@ -280,7 +280,7 @@ describe('uploads', () => {
await expect(audioUploadImage).toBeVisible()
})
test('Should detect correct mimeType', async () => {
test('should detect correct mimeType', async () => {
await page.goto(mediaURL.create)
await page.waitForURL(mediaURL.create)
await page.setInputFiles('input[type="file"]', path.resolve(dirname, './image.png'))

View File

@@ -26,6 +26,7 @@ import type { Page } from '@playwright/test'
import { expect, test } from '@playwright/test'
import path from 'path'
import { wait } from 'payload/utilities'
import { fileURLToPath } from 'url'
import type { PayloadTestSDK } from '../helpers/sdk/index.js'
@@ -162,31 +163,19 @@ describe('versions', () => {
const title = 'autosave title'
const description = 'autosave description'
await page.goto(autosaveURL.create)
// fill the fields
// gets redirected from /create to /slug/id due to autosave
await page.waitForURL(new RegExp(`${autosaveURL.edit('')}`))
await wait(500)
await expect(page.locator('#field-title')).toBeEnabled()
await page.locator('#field-title').fill(title)
await expect(page.locator('#field-description')).toBeEnabled()
await page.locator('#field-description').fill(description)
// wait for autosave
await waitForAutoSaveToRunAndComplete(page)
// go to list
await page.goto(autosaveURL.list)
// expect the status to be draft
await expect(findTableCell(page, '_status', title)).toContainText('Draft')
// select the row
// await page.locator('.row-1 .select-row__checkbox').click()
await selectTableRow(page, title)
// click the publish many
await page.locator('.publish-many__toggle').click()
// confirm the dialog
await page.locator('#confirm-publish').click()
// expect the status to be published
await expect(findTableCell(page, '_status', title)).toContainText('Published')
})
@@ -278,21 +267,19 @@ describe('versions', () => {
test('collection — tab displays proper number of versions', async () => {
await page.goto(url.list)
const linkToDoc = page
.locator('tbody tr .cell-title a', {
hasText: exactText('Title With Many Versions 11'),
})
.first()
expect(linkToDoc).toBeTruthy()
await linkToDoc.click()
const versionsTab = page.locator('.doc-tab', {
hasText: 'Versions',
})
await versionsTab.waitFor({ state: 'visible' })
const versionsPill = versionsTab.locator('.doc-tab__count--has-count')
await versionsPill.waitFor({ state: 'visible' })
const versionCount = await versionsTab.locator('.doc-tab__count').first().textContent()
expect(versionCount).toBe('11')
})
@@ -322,32 +309,21 @@ describe('versions', () => {
test('should restore version with correct data', async () => {
await page.goto(url.create)
await page.waitForURL(url.create)
// publish a doc
await page.locator('#field-title').fill('v1')
await page.locator('#field-description').fill('hello')
await saveDocAndAssert(page)
// save a draft
await page.locator('#field-title').fill('v2')
await saveDocAndAssert(page, '#action-save-draft')
// go to versions list view
const savedDocURL = page.url()
await page.goto(`${savedDocURL}/versions`)
await page.waitForURL(`${savedDocURL}/versions`)
// select the first version (row 2)
await page.waitForURL(new RegExp(`${savedDocURL}/versions`))
const row2 = page.locator('tbody .row-2')
const versionID = await row2.locator('.cell-id').textContent()
await page.goto(`${savedDocURL}/versions/${versionID}`)
await page.waitForURL(`${savedDocURL}/versions/${versionID}`)
// restore doc
await page.waitForURL(new RegExp(`${savedDocURL}/versions/${versionID}`))
await page.locator('.pill.restore-version').click()
await page.locator('button:has-text("Confirm")').click()
await page.waitForURL(savedDocURL)
await page.waitForURL(new RegExp(savedDocURL))
await expect(page.locator('#field-title')).toHaveValue('v1')
})
@@ -385,16 +361,12 @@ describe('versions', () => {
test('global - should autosave', async () => {
const url = new AdminUrlUtil(serverURL, autoSaveGlobalSlug)
// fill out global title and wait for autosave
await page.goto(url.global(autoSaveGlobalSlug))
await page.waitForURL(`**/${autoSaveGlobalSlug}`)
const titleField = page.locator('#field-title')
await titleField.fill('global title')
await waitForAutoSaveToRunAndComplete(page)
await expect(titleField).toHaveValue('global title')
// refresh the page and ensure value autosaved
await page.goto(url.global(autoSaveGlobalSlug))
await expect(page.locator('#field-title')).toHaveValue('global title')
})
@@ -405,41 +377,25 @@ describe('versions', () => {
const englishTitle = 'english title'
const spanishTitle = 'spanish title'
const newDescription = 'new description'
await page.goto(autosaveURL.create)
// gets redirected from /create to /slug/id due to autosave
await page.waitForURL(`${autosaveURL.list}/**`)
await expect(() => expect(page.url()).not.toContain(`/create`)).toPass({
timeout: POLL_TOPASS_TIMEOUT,
})
await page.waitForURL(new RegExp(`${autosaveURL.edit('')}`))
await wait(500)
const titleField = page.locator('#field-title')
const descriptionField = page.locator('#field-description')
// fill out en doc
await expect(titleField).toBeEnabled()
await titleField.fill(englishTitle)
const descriptionField = page.locator('#field-description')
await expect(descriptionField).toBeEnabled()
await descriptionField.fill('description')
await waitForAutoSaveToRunAndComplete(page)
// change locale to spanish
await changeLocale(page, es)
// set localized title field
await titleField.fill(spanishTitle)
await waitForAutoSaveToRunAndComplete(page)
// change locale back to en
await changeLocale(page, en)
// verify en loads its own title
await expect(titleField).toHaveValue(englishTitle)
// change non-localized description field
await descriptionField.fill(newDescription)
await waitForAutoSaveToRunAndComplete(page)
// change locale to spanish
await changeLocale(page, es)
// reload page in spanish
// title should not be english title
// description should be new description
await page.reload()
await expect(titleField).toHaveValue(spanishTitle)
await expect(descriptionField).toHaveValue(newDescription)
@@ -487,41 +443,30 @@ describe('versions', () => {
})
test('collection — autosave should only update the current document', async () => {
// create and save first doc
await page.goto(autosaveURL.create)
// Should redirect from /create to /[collectionslug]/[new id] due to auto-save
await page.waitForURL(`${autosaveURL.list}/**`)
await expect(() => expect(page.url()).not.toContain(`/create`)).toPass({
timeout: POLL_TOPASS_TIMEOUT,
}) // Make sure this doesnt match for list view and /create view, but ONLY for the ID edit view
// gets redirected from /create to /slug/id due to autosave
await page.waitForURL(new RegExp(`${autosaveURL.edit('')}`))
await wait(500)
await expect(page.locator('#field-title')).toBeEnabled()
await page.locator('#field-title').fill('first post title')
await expect(page.locator('#field-description')).toBeEnabled()
await page.locator('#field-description').fill('first post description')
await saveDocAndAssert(page)
await waitForAutoSaveToComplete(page) // Make sure nothing is auto-saving before next steps
// create and save second doc
await page.goto(autosaveURL.create)
// Should redirect from /create to /[collectionslug]/[new id] due to auto-save
await page.waitForURL(`${autosaveURL.list}/**`)
await expect(() => expect(page.url()).not.toContain(`/create`)).toPass({
timeout: POLL_TOPASS_TIMEOUT,
}) // Make sure this doesnt match for list view and /create view, but only for the ID edit view
// gets redirected from /create to /slug/id due to autosave
await page.waitForURL(new RegExp(`${autosaveURL.edit('')}`))
await waitForAutoSaveToComplete(page) // Make sure nothing is auto-saving before next steps
await wait(500)
await expect(page.locator('#field-title')).toBeEnabled()
await page.locator('#field-title').fill('second post title')
await expect(page.locator('#field-description')).toBeEnabled()
await page.locator('#field-description').fill('second post description')
// publish changes
await saveDocAndAssert(page)
await waitForAutoSaveToComplete(page) // Make sure nothing is auto-saving before next steps
// update second doc and wait for autosave
await page.locator('#field-title').fill('updated second post title')
await page.locator('#field-description').fill('updated second post description')
await waitForAutoSaveToRunAndComplete(page)
// verify that the first doc is unchanged
await page.goto(autosaveURL.list)
const secondRowLink = page.locator('tbody tr:nth-child(2) .cell-title a')
const docURL = await secondRowLink.getAttribute('href')