From 92f458dad2b29af6632062b606a9de4167b49722 Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Wed, 29 May 2024 14:01:13 -0400 Subject: [PATCH] feat(next,ui): improves loading states (#6434) --- next.config.mjs | 2 +- packages/next/src/layouts/Root/index.tsx | 2 + packages/next/src/utilities/initPage/index.ts | 15 + .../next/src/utilities/initPage/shared.ts | 1 - .../src/views/API/LocaleSelector/index.tsx | 23 ++ packages/next/src/views/API/index.client.tsx | 12 +- .../Account/Settings/LanguageSelector.tsx | 25 ++ .../next/src/views/Account/Settings/index.tsx | 26 +- packages/next/src/views/Account/index.tsx | 13 +- .../views/Dashboard/Default/index.client.tsx | 144 ---------- .../src/views/Dashboard/Default/index.tsx | 86 +++++- packages/next/src/views/Dashboard/index.tsx | 38 +++ .../src/views/Document/getDocumentData.tsx | 52 ++-- .../src/views/Document/getViewsFromConfig.tsx | 262 +++++++++--------- packages/next/src/views/Document/index.tsx | 5 +- .../src/views/Edit/Default/Auth/APIKey.tsx | 2 +- .../src/views/Edit/Default/Auth/index.tsx | 24 +- .../Edit/Default/SetDocumentStepNav/index.tsx | 51 ++-- .../next/src/views/Edit/Default/index.tsx | 21 +- packages/next/src/views/Edit/index.client.tsx | 6 +- packages/next/src/views/List/index.tsx | 3 +- .../src/views/LivePreview/index.client.tsx | 8 +- .../next/src/views/Login/LoginForm/index.tsx | 2 - packages/next/src/views/Logout/index.tsx | 17 +- .../src/views/ResetPassword/index.client.tsx | 6 +- .../src/views/Version/SelectLocales/index.tsx | 1 + packages/next/src/withPayload.js | 6 + packages/payload/src/admin/LanguageOptions.ts | 4 + packages/payload/src/admin/types.ts | 2 + packages/payload/src/admin/views/types.ts | 2 + packages/plugin-stripe/src/ui/LinkToDoc.tsx | 2 +- .../richtext-slate/src/field/RichText.tsx | 31 ++- .../src/field/elements/Button.tsx | 17 +- .../src/field/elements/types.ts | 1 + .../field/providers/ElementButtonProvider.tsx | 1 + packages/translations/src/languages/he.ts | 23 +- .../ui/src/elements/DocumentFields/index.tsx | 2 + .../DocumentHeader/Tabs/Tab/index.scss | 4 +- .../Tabs/tabs/VersionsPill/index.tsx | 19 +- .../ui/src/elements/ReactSelect/index.tsx | 15 +- .../ui/src/elements/RenderTitle/index.tsx | 17 +- packages/ui/src/fields/Array/index.tsx | 13 +- packages/ui/src/fields/Blocks/index.tsx | 13 +- packages/ui/src/fields/Checkbox/index.tsx | 13 +- packages/ui/src/fields/Code/index.tsx | 11 +- packages/ui/src/fields/Collapsible/index.tsx | 9 +- packages/ui/src/fields/DateTime/index.tsx | 10 +- packages/ui/src/fields/Email/index.tsx | 9 +- packages/ui/src/fields/Group/index.tsx | 12 +- packages/ui/src/fields/JSON/index.tsx | 24 +- packages/ui/src/fields/Number/index.tsx | 17 +- packages/ui/src/fields/Password/index.tsx | 17 +- packages/ui/src/fields/RadioGroup/index.tsx | 11 +- packages/ui/src/fields/Relationship/index.tsx | 18 +- packages/ui/src/fields/Select/index.tsx | 11 +- packages/ui/src/fields/Tabs/index.tsx | 1 + packages/ui/src/fields/Text/index.tsx | 11 +- packages/ui/src/fields/Textarea/index.tsx | 7 +- packages/ui/src/fields/Upload/index.tsx | 14 +- .../ui/src/forms/FieldPropsProvider/index.tsx | 1 + packages/ui/src/forms/Form/context.ts | 4 + packages/ui/src/forms/Form/getSiblingData.ts | 3 + packages/ui/src/forms/Form/index.tsx | 38 ++- .../ui/src/forms/Form/initContextState.ts | 1 + packages/ui/src/forms/Form/types.ts | 2 + packages/ui/src/forms/RenderFields/index.tsx | 8 +- packages/ui/src/forms/RenderFields/types.ts | 9 +- packages/ui/src/forms/Submit/index.tsx | 5 +- .../calculateDefaultValues/index.ts | 17 +- .../calculateDefaultValues/iterateFields.ts | 12 +- .../calculateDefaultValues/promise.ts | 37 +-- .../src/forms/buildStateFromSchema/index.tsx | 3 +- packages/ui/src/forms/useField/index.tsx | 4 + packages/ui/src/forms/useField/types.ts | 1 + .../ui/src/providers/DocumentInfo/index.tsx | 151 ++++++---- .../ui/src/providers/DocumentInfo/types.ts | 4 + packages/ui/src/providers/Root/index.tsx | 3 +- .../ui/src/providers/Translation/index.tsx | 7 +- packages/ui/src/utilities/getFormState.ts | 4 +- .../ui/src/utilities/reduceFieldsToValues.ts | 2 + test/access-control/e2e.spec.ts | 9 +- .../components/FieldDescription/index.tsx | 3 +- .../views/CustomView/index.client.tsx | 7 +- test/admin/e2e/2/e2e.spec.ts | 4 +- test/auth/e2e.spec.ts | 6 +- test/fields-relationship/e2e.spec.ts | 64 +---- .../collections/Relationship/e2e.spec.ts | 34 +-- test/fields/collections/RichText/e2e.spec.ts | 13 +- test/fields/e2e.spec.ts | 23 +- test/helpers.ts | 15 +- test/localization/e2e.spec.ts | 48 +--- test/plugin-form-builder/e2e.spec.ts | 3 - test/uploads/e2e.spec.ts | 6 +- test/versions/e2e.spec.ts | 107 ++----- 94 files changed, 987 insertions(+), 885 deletions(-) create mode 100644 packages/next/src/views/API/LocaleSelector/index.tsx create mode 100644 packages/next/src/views/Account/Settings/LanguageSelector.tsx delete mode 100644 packages/next/src/views/Dashboard/Default/index.client.tsx create mode 100644 packages/payload/src/admin/LanguageOptions.ts diff --git a/next.config.mjs b/next.config.mjs index 29911443e4..dc192996d8 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -17,7 +17,7 @@ export default withBundleAnalyzer( ignoreBuildErrors: true, }, experimental: { - reactCompiler: false + reactCompiler: false, }, async redirects() { return [ diff --git a/packages/next/src/layouts/Root/index.tsx b/packages/next/src/layouts/Root/index.tsx index 54c8857ef7..118adb89e9 100644 --- a/packages/next/src/layouts/Root/index.tsx +++ b/packages/next/src/layouts/Root/index.tsx @@ -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) diff --git a/packages/next/src/utilities/initPage/index.ts b/packages/next/src/utilities/initPage/index.ts index ec2c5795df..13dfce3013 100644 --- a/packages/next/src/utilities/initPage/index.ts +++ b/packages/next/src/utilities/initPage/index.ts @@ -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, diff --git a/packages/next/src/utilities/initPage/shared.ts b/packages/next/src/utilities/initPage/shared.ts index 96d1004fc1..c311a3c91c 100644 --- a/packages/next/src/utilities/initPage/shared.ts +++ b/packages/next/src/utilities/initPage/shared.ts @@ -1,7 +1,6 @@ import type { SanitizedConfig } from 'payload/types' const authRouteKeys: (keyof SanitizedConfig['admin']['routes'])[] = [ - 'account', 'createFirstUser', 'forgot', 'login', diff --git a/packages/next/src/views/API/LocaleSelector/index.tsx b/packages/next/src/views/API/LocaleSelector/index.tsx new file mode 100644 index 0000000000..f451456dd7 --- /dev/null +++ b/packages/next/src/views/API/LocaleSelector/index.tsx @@ -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 + value: string + }[] + onChange: (value: string) => void +}> = ({ localeOptions, onChange }) => { + const { t } = useTranslation() + + return ( + setLocale(value)} - options={localeOptions} - path="locale" - /> - )} + {localeOptions && } = (props) => { + const { languageOptions } = props + + const { i18n, switchLanguage } = useTranslation() + + return ( + { + await switchLanguage(value) + }} + options={languageOptions} + value={languageOptions.find((language) => language.value === i18n.language)} + /> + ) +} diff --git a/packages/next/src/views/Account/Settings/index.tsx b/packages/next/src/views/Account/Settings/index.tsx index aaf9c3657e..6af4bc9101 100644 --- a/packages/next/src/views/Account/Settings/index.tsx +++ b/packages/next/src/views/Account/Settings/index.tsx @@ -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 (
-

{t('general:payloadSettings')}

+

{i18n.t('general:payloadSettings')}

- - { - await switchLanguage(value) - }} - options={languageOptions} - value={languageOptions.find((language) => language.value === i18n.language)} - /> + +
diff --git a/packages/next/src/views/Account/index.tsx b/packages/next/src/views/Account/index.tsx index a840a6118d..07eab970b5 100644 --- a/packages/next/src/views/Account/index.tsx +++ b/packages/next/src/views/Account/index.tsx @@ -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 = async ({ searchParams, }) => { const { + languageOptions, locale, permissions, req, @@ -49,6 +51,13 @@ export const Account: React.FC = 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 = async ({ return ( } + AfterFields={} 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 = async ({ hasPublishPermission={hasPublishPermission} hasSavePermission={hasSavePermission} id={user?.id.toString()} + initialData={data} + initialState={formState} isEditing > = ({ 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([]) - - 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 ( - - - {groups.map(({ entities, label }, groupIndex) => { - return ( -
-

{label}

-
    - {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 ( -
  • - - ) : undefined - } - buttonAriaLabel={buttonAriaLabel} - href={href} - id={`card-${entity.slug}`} - title={title} - titleAs="h3" - /> -
  • - ) - })} -
-
- ) - })} -
- ) -} diff --git a/packages/next/src/views/Dashboard/Default/index.tsx b/packages/next/src/views/Dashboard/Default/index.tsx index 8865dd4c5b..4c62412794 100644 --- a/packages/next/src/views/Dashboard/Default/index.tsx +++ b/packages/next/src/views/Dashboard/Default/index.tsx @@ -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 - + navGroups?: ReturnType permissions: Permissions visibleEntities: VisibleEntities } @@ -24,20 +27,22 @@ export const DefaultDashboard: React.FC = (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 = (props) => { {Array.isArray(BeforeDashboards) && BeforeDashboards.map((Component) => Component)} + + + {!navGroups || navGroups?.length === 0 ? ( +

no nav groups....

+ ) : ( + navGroups.map(({ entities, label }, groupIndex) => { + return ( +
+

{label}

+
    + {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 = `${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 ( +
  • + + ) : undefined + } + buttonAriaLabel={buttonAriaLabel} + href={href} + id={`card-${entity.slug}`} + title={title} + titleAs="h3" + /> +
  • + ) + })} +
+
+ ) + }) + )} +
{Array.isArray(AfterDashboards) && AfterDashboards.map((Component) => Component)}
diff --git a/packages/next/src/views/Dashboard/index.tsx b/packages/next/src/views/Dashboard/index.tsx index 6a3dce0f9a..109b35736a 100644 --- a/packages/next/src/views/Dashboard/index.tsx +++ b/packages/next/src/views/Dashboard/index.tsx @@ -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 = ({ 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, diff --git a/packages/next/src/views/Document/getDocumentData.tsx b/packages/next/src/views/Document/getDocumentData.tsx index 86618374df..4a2ad0b449 100644 --- a/packages/next/src/views/Document/getDocumentData.tsx +++ b/packages/next/src/views/Document/getDocumentData.tsx @@ -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 => { - 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 {} + } } diff --git a/packages/next/src/views/Document/getViewsFromConfig.tsx b/packages/next/src/views/Document/getViewsFromConfig.tsx index 4dafc9096e..e5fa589e9f 100644 --- a/packages/next/src/views/Document/getViewsFromConfig.tsx +++ b/packages/next/src/views/Document/getViewsFromConfig.tsx @@ -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 } } } diff --git a/packages/next/src/views/Document/index.tsx b/packages/next/src/views/Document/index.tsx index 4c57b3b052..9b8ab13494 100644 --- a/packages/next/src/views/Document/index.tsx +++ b/packages/next/src/views/Document/index.tsx @@ -63,12 +63,11 @@ export const Document: React.FC = 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 = async ({ hasPublishPermission={hasPublishPermission} hasSavePermission={hasSavePermission} id={id} + initialData={data} + initialState={formState} isEditing={isEditing} > {!ViewOverride && ( diff --git a/packages/next/src/views/Edit/Default/Auth/APIKey.tsx b/packages/next/src/views/Edit/Default/Auth/APIKey.tsx index 6e6483a4e4..d6607b0183 100644 --- a/packages/next/src/views/Edit/Default/Auth/APIKey.tsx +++ b/packages/next/src/views/Edit/Default/Auth/APIKey.tsx @@ -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, { diff --git a/packages/next/src/views/Edit/Default/Auth/index.tsx b/packages/next/src/views/Edit/Default/Auth/index.tsx index 208b2638d2..71fbaa9d25 100644 --- a/packages/next/src/views/Edit/Default/Auth/index.tsx +++ b/packages/next/src/views/Edit/Default/Auth/index.tsx @@ -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 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) => { return null } + const disabled = readOnly || isInitializing + return (
{!disableLocalStrategy && ( = (props) => {
= (props) => {
)} -
{changingPassword && !requirePassword && (
) diff --git a/packages/next/src/views/Edit/Default/SetDocumentStepNav/index.tsx b/packages/next/src/views/Edit/Default/SetDocumentStepNav/index.tsx index ed00d67dfb..6644bf0b19 100644 --- a/packages/next/src/views/Edit/Default/SetDocumentStepNav/index.tsx +++ b/packages/next/src/views/Edit/Default/SetDocumentStepNav/index.tsx @@ -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, diff --git a/packages/next/src/views/Edit/Default/index.tsx b/packages/next/src/views/Edit/Default/index.tsx index c1d7512db1..0d83d5bd50 100644 --- a/packages/next/src/views/Edit/Default/index.tsx +++ b/packages/next/src/views/Edit/Default/index.tsx @@ -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} > - {BeforeDocument} {preventLeaveWithoutSaving && } { globalSlug, }) - // Allow the `DocumentInfoProvider` to hydrate - if (!Edit || (!collectionSlug && !globalSlug)) { - return + if (!Edit) { + return null } return ( diff --git a/packages/next/src/views/List/index.tsx b/packages/next/src/views/List/index.tsx index d8127d2717..ae4883909f 100644 --- a/packages/next/src/views/List/index.tsx +++ b/packages/next/src/views/List/index.tsx @@ -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 = async ({ const collectionSlug = collectionConfig?.slug if (!permissions?.collections?.[collectionSlug]?.read?.permission) { - return + notFound() } let listPreferences: ListPreferences diff --git a/packages/next/src/views/LivePreview/index.client.tsx b/packages/next/src/views/LivePreview/index.client.tsx index eb9dd68245..f3ebd9e0c7 100644 --- a/packages/next/src/views/LivePreview/index.client.tsx +++ b/packages/next/src/views/LivePreview/index.client.tsx @@ -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 = ({ initialData, initialState, isEditing, + isInitializing, onSave: onSaveFromProps, } = useDocumentInfo() @@ -120,11 +120,6 @@ const PreviewView: React.FC = ({ [serverURL, apiRoute, id, operation, schemaPath, getDocPreferences], ) - // Allow the `DocumentInfoProvider` to hydrate - if (!collectionSlug && !globalSlug) { - return - } - return ( @@ -133,6 +128,7 @@ const PreviewView: React.FC = ({ className={`${baseClass}__form`} disabled={!hasSavePermission} initialState={initialState} + isInitializing={isInitializing} method={id ? 'PATCH' : 'POST'} onChange={[onChange]} onSuccess={onSave} diff --git a/packages/next/src/views/Login/LoginForm/index.tsx b/packages/next/src/views/Login/LoginForm/index.tsx index 3fd27dbb8e..b50dd22614 100644 --- a/packages/next/src/views/Login/LoginForm/index.tsx +++ b/packages/next/src/views/Login/LoginForm/index.tsx @@ -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 > -
-
- -
- +
+ +
) } diff --git a/packages/next/src/views/ResetPassword/index.client.tsx b/packages/next/src/views/ResetPassword/index.client.tsx index 936993d2b8..f538e3eab8 100644 --- a/packages/next/src/views/ResetPassword/index.client.tsx +++ b/packages/next/src/views/ResetPassword/index.client.tsx @@ -72,9 +72,9 @@ export const ResetPasswordClient: React.FC = ({ 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) => { diff --git a/packages/next/src/views/Version/SelectLocales/index.tsx b/packages/next/src/views/Version/SelectLocales/index.tsx index 56d74ba32e..896f520980 100644 --- a/packages/next/src/views/Version/SelectLocales/index.tsx +++ b/packages/next/src/views/Version/SelectLocales/index.tsx @@ -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' diff --git a/packages/next/src/withPayload.js b/packages/next/src/withPayload.js index 760874cb4d..65d0e80107 100644 --- a/packages/next/src/withPayload.js +++ b/packages/next/src/withPayload.js @@ -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: { diff --git a/packages/payload/src/admin/LanguageOptions.ts b/packages/payload/src/admin/LanguageOptions.ts new file mode 100644 index 0000000000..5566ed1698 --- /dev/null +++ b/packages/payload/src/admin/LanguageOptions.ts @@ -0,0 +1,4 @@ +export type LanguageOptions = { + label: string + value: string +}[] diff --git a/packages/payload/src/admin/types.ts b/packages/payload/src/admin/types.ts index 2dc3221f73..5e78ef4792 100644 --- a/packages/payload/src/admin/types.ts +++ b/packages/payload/src/admin/types.ts @@ -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 { diff --git a/packages/payload/src/admin/views/types.ts b/packages/payload/src/admin/views/types.ts index 2e44bd4b5c..56804910de 100644 --- a/packages/payload/src/admin/views/types.ts +++ b/packages/payload/src/admin/views/types.ts @@ -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 docID?: string globalConfig?: SanitizedGlobalConfig + languageOptions: LanguageOptions locale: Locale permissions: Permissions req: PayloadRequestWithData diff --git a/packages/plugin-stripe/src/ui/LinkToDoc.tsx b/packages/plugin-stripe/src/ui/LinkToDoc.tsx index 04ba99831b..fea33401b6 100644 --- a/packages/plugin-stripe/src/ui/LinkToDoc.tsx +++ b/packages/plugin-stripe/src/ui/LinkToDoc.tsx @@ -11,7 +11,7 @@ export const LinkToDoc: CustomComponent = () => { 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/' : '' diff --git a/packages/richtext-slate/src/field/RichText.tsx b/packages/richtext-slate/src/field/RichText.tsx index f11a5bab5f..b96ac8ce49 100644 --- a/packages/richtext-slate/src/field/RichText.tsx +++ b/packages/richtext-slate/src/field/RichText.tsx @@ -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 ( = (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 = (props) => { const Tag: ElementType = el + const disabled = disabledFromProps || disabledFromContext + return ( void diff --git a/packages/richtext-slate/src/field/providers/ElementButtonProvider.tsx b/packages/richtext-slate/src/field/providers/ElementButtonProvider.tsx index 99b93cfbc3..cc2847aed5 100644 --- a/packages/richtext-slate/src/field/providers/ElementButtonProvider.tsx +++ b/packages/richtext-slate/src/field/providers/ElementButtonProvider.tsx @@ -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 diff --git a/packages/translations/src/languages/he.ts b/packages/translations/src/languages/he.ts index 92db7f0937..ffc4f893c9 100644 --- a/packages/translations/src/languages/he.ts +++ b/packages/translations/src/languages/he.ts @@ -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: 'כדי לשנות את הסיסמה שלך, יש לעבור לחשבון שלך ולערוך את הסיסמה שם.', + loggedInChangePassword: + 'כדי לשנות את הסיסמה שלך, יש לעבור לחשבון שלך ולערוך את הסיסמה שם.', loggedOutInactivity: 'התנתקת בשל חוסר פעילות.', loggedOutSuccessfully: 'התנתקת בהצלחה.', loggingOut: 'מתנתק...', @@ -43,7 +46,8 @@ export const heTranslations: DefaultTranslationsObject = { logoutSuccessful: 'התנתקות הצליחה.', logoutUser: 'התנתקות משתמש', newAPIKeyGenerated: 'נוצר מפתח API חדש.', - newAccountCreated: 'נוצר חשבון חדש עבורך כדי לגשת אל {{serverURL}}. אנא לחץ על הקישור הבא או הדבק את ה-URL בדפדפן שלך כדי לאמת את הדוא"ל שלך: {{verificationURL}}.
לאחר אימות כתובת הדוא"ל, תוכל להתחבר בהצלחה.', + newAccountCreated: + 'נוצר חשבון חדש עבורך כדי לגשת אל {{serverURL}}. אנא לחץ על הקישור הבא או הדבק את ה-URL בדפדפן שלך כדי לאמת את הדוא"ל שלך: {{verificationURL}}.
לאחר אימות כתובת הדוא"ל, תוכל להתחבר בהצלחה.', 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}} שנבחרו. האם אתה בטוח?', diff --git a/packages/ui/src/elements/DocumentFields/index.tsx b/packages/ui/src/elements/DocumentFields/index.tsx index 7d6d46181d..da341d0327 100644 --- a/packages/ui/src/elements/DocumentFields/index.tsx +++ b/packages/ui/src/elements/DocumentFields/index.tsx @@ -62,6 +62,7 @@ export const DocumentFields: React.FC = ({ = ({
{ const { versions } = useDocumentInfo() - if (versions?.totalDocs > 0) { - return {versions?.totalDocs?.toString()} - } + // 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 ( + + {hasVersions ? versions.totalDocs.toString() :  } + + ) } diff --git a/packages/ui/src/elements/ReactSelect/index.tsx b/packages/ui/src/elements/ReactSelect/index.tsx index 7bebdf6899..56c05caeae 100644 --- a/packages/ui/src/elements/ReactSelect/index.tsx +++ b/packages/ui/src/elements/ReactSelect/index.tsx @@ -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 = (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 = (props) => { .filter(Boolean) .join(' ') + if (!hasMounted) { + return + } + if (!isCreatable) { return ( = (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 = (props) => { margins="small" path={path} permissions={permissions?.fields} - readOnly={readOnly} + readOnly={disabled} schemaPath={schemaPath} />
diff --git a/packages/ui/src/fields/JSON/index.tsx b/packages/ui/src/fields/JSON/index.tsx index 77f3cd4b88..8d8325b73b 100644 --- a/packages/ui/src/fields/JSON/index.tsx +++ b/packages/ui/src/fields/JSON/index.tsx @@ -64,12 +64,14 @@ const JSONFieldComponent: React.FC = (props) => { ) const { path: pathFromContext, readOnly: readOnlyFromContext } = useFieldProps() - const readOnly = readOnlyFromProps || readOnlyFromContext - const { initialValue, path, setValue, showError, value } = useField({ - path: pathFromContext || pathFromProps || name, - validate: memoizedValidate, - }) + const { formInitializing, formProcessing, initialValue, path, setValue, showError, value } = + useField({ + 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 = (props) => { const handleChange = useCallback( (val) => { - if (readOnly) return + if (disabled) return setStringValue(val) try { @@ -103,14 +105,16 @@ const JSONFieldComponent: React.FC = (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 = (props) => { baseClass, className, showError && 'error', - readOnly && 'read-only', + disabled && 'read-only', ] .filter(Boolean) .join(' ')} @@ -145,7 +149,7 @@ const JSONFieldComponent: React.FC = (props) => { onChange={handleChange} onMount={handleMount} options={editorOptions} - readOnly={readOnly} + readOnly={disabled} value={stringValue} /> {AfterInput} diff --git a/packages/ui/src/fields/Number/index.tsx b/packages/ui/src/fields/Number/index.tsx index 14223d0c1f..a052cd7649 100644 --- a/packages/ui/src/fields/Number/index.tsx +++ b/packages/ui/src/fields/Number/index.tsx @@ -73,13 +73,16 @@ const NumberFieldComponent: React.FC = (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< + 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 = (props) => { const handleHasManyChange = useCallback( (selectedOption) => { - if (!readOnly) { + if (!disabled) { let newValue if (!selectedOption) { newValue = [] @@ -117,7 +120,7 @@ const NumberFieldComponent: React.FC = (props) => { setValue(newValue) } }, - [readOnly, setValue], + [disabled, setValue], ) // useEffect update valueToRender: @@ -145,7 +148,7 @@ const NumberFieldComponent: React.FC = (props) => { 'number', className, showError && 'error', - readOnly && 'read-only', + disabled && 'read-only', hasMany && 'has-many', ] .filter(Boolean) @@ -166,7 +169,7 @@ const NumberFieldComponent: React.FC = (props) => { {hasMany ? ( { // eslint-disable-next-line no-restricted-globals const isOverHasMany = Array.isArray(value) && value.length >= maxRows @@ -194,7 +197,7 @@ const NumberFieldComponent: React.FC = (props) => {
{BeforeInput} = (props) => { CustomLabel, autoComplete, className, - disabled, + disabled: disabledFromProps, errorProps, label, labelProps, @@ -52,14 +52,22 @@ const PasswordField: React.FC = (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 (
= (props) => { />
- = (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 = (props) => { validate: memoizedValidate, }) + const disabled = readOnlyFromProps || readOnlyFromContext || formProcessing || formInitializing + const value = valueFromContext || valueFromProps return ( @@ -90,7 +93,7 @@ const RadioGroupField: React.FC = (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 = (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} /> diff --git a/packages/ui/src/fields/Relationship/index.tsx b/packages/ui/src/fields/Relationship/index.tsx index ebe8ba9d35..c7f9c3c9a2 100644 --- a/packages/ui/src/fields/Relationship/index.tsx +++ b/packages/ui/src/fields/Relationship/index.tsx @@ -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 = (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 = (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({ path: pathFromContext || pathFromProps || name, validate: memoizedValidate, }) + const readOnly = readOnlyFromProps || readOnlyFromContext || formInitializing + const valueRef = useRef(value) valueRef.current = value diff --git a/packages/ui/src/fields/Select/index.tsx b/packages/ui/src/fields/Select/index.tsx index a1fa9ce97b..ee5873b8b2 100644 --- a/packages/ui/src/fields/Select/index.tsx +++ b/packages/ui/src/fields/Select/index.tsx @@ -73,16 +73,17 @@ const SelectField: React.FC = (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 = (props) => { setValue(newValue) } }, - [readOnly, hasMany, setValue, onChangeFromProps], + [disabled, hasMany, setValue, onChangeFromProps], ) return ( @@ -125,7 +126,7 @@ const SelectField: React.FC = (props) => { onChange={onChange} options={options} path={path} - readOnly={readOnly} + readOnly={disabled} required={required} showError={showError} style={style} diff --git a/packages/ui/src/fields/Tabs/index.tsx b/packages/ui/src/fields/Tabs/index.tsx index 8a0d3b0211..dc4f77ca12 100644 --- a/packages/ui/src/fields/Tabs/index.tsx +++ b/packages/ui/src/fields/Tabs/index.tsx @@ -54,6 +54,7 @@ const TabsField: React.FC = (props) => { readOnly: readOnlyFromContext, schemaPath, } = useFieldProps() + const readOnly = readOnlyFromProps || readOnlyFromContext const path = pathFromContext || pathFromProps || name const { getPreference, setPreference } = usePreferences() diff --git a/packages/ui/src/fields/Text/index.tsx b/packages/ui/src/fields/Text/index.tsx index 89b08f1374..f3292f90fe 100644 --- a/packages/ui/src/fields/Text/index.tsx +++ b/packages/ui/src/fields/Text/index.tsx @@ -60,13 +60,14 @@ const TextField: React.FC = (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 = (props) => { const handleHasManyChange = useCallback( (selectedOption) => { - if (!readOnly) { + if (!disabled) { let newValue if (!selectedOption) { newValue = [] @@ -93,7 +94,7 @@ const TextField: React.FC = (props) => { setValue(newValue) } }, - [readOnly, setValue], + [disabled, setValue], ) // useEffect update valueToRender: @@ -140,7 +141,7 @@ const TextField: React.FC = (props) => { } path={path} placeholder={placeholder} - readOnly={formProcessing || readOnly} + readOnly={disabled} required={required} rtl={renderRTL} showError={showError} diff --git a/packages/ui/src/fields/Textarea/index.tsx b/packages/ui/src/fields/Textarea/index.tsx index 132e2dfaf1..7480482b65 100644 --- a/packages/ui/src/fields/Textarea/index.tsx +++ b/packages/ui/src/fields/Textarea/index.tsx @@ -66,13 +66,14 @@ const TextareaField: React.FC = (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 ( = (props) => { }} path={path} placeholder={getTranslation(placeholder, i18n)} - readOnly={readOnly} + readOnly={disabled} required={required} rows={rows} rtl={isRTL} diff --git a/packages/ui/src/fields/Upload/index.tsx b/packages/ui/src/fields/Upload/index.tsx index 0949b2e8a0..076a9fe7a6 100644 --- a/packages/ui/src/fields/Upload/index.tsx +++ b/packages/ui/src/fields/Upload/index.tsx @@ -52,12 +52,14 @@ const _Upload: React.FC = (props) => { ) const { path: pathFromContext, readOnly: readOnlyFromContext } = useFieldProps() - const readOnly = readOnlyFromProps || readOnlyFromContext - const { filterOptions, path, setValue, showError, value } = useField({ - path: pathFromContext || pathFromProps, - validate: memoizedValidate, - }) + const { filterOptions, formInitializing, formProcessing, path, setValue, showError, value } = + useField({ + path: pathFromContext || pathFromProps, + validate: memoizedValidate, + }) + + const disabled = readOnlyFromProps || readOnlyFromContext || formProcessing || formInitializing const onChange = useCallback( (incomingValue) => { @@ -83,7 +85,7 @@ const _Upload: React.FC = (props) => { labelProps={labelProps} onChange={onChange} path={path} - readOnly={readOnly} + readOnly={disabled} relationTo={relationTo} required={required} serverURL={serverURL} diff --git a/packages/ui/src/forms/FieldPropsProvider/index.tsx b/packages/ui/src/forms/FieldPropsProvider/index.tsx index 2619ed5f42..d47f92e7ad 100644 --- a/packages/ui/src/forms/FieldPropsProvider/index.tsx +++ b/packages/ui/src/forms/FieldPropsProvider/index.tsx @@ -32,6 +32,7 @@ export type Props = { children: React.ReactNode custom?: Record indexPath?: string + isForceRendered?: boolean path: string permissions?: FieldPermissions readOnly: boolean diff --git a/packages/ui/src/forms/Form/context.ts b/packages/ui/src/forms/Form/context.ts index 25d9a355dc..f0fb689499 100644 --- a/packages/ui/src/forms/Form/context.ts +++ b/packages/ui/src/forms/Form/context.ts @@ -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([{}, () => 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, diff --git a/packages/ui/src/forms/Form/getSiblingData.ts b/packages/ui/src/forms/Form/getSiblingData.ts index a36b47839c..736ef8768d 100644 --- a/packages/ui/src/forms/Form/getSiblingData.ts +++ b/packages/ui/src/forms/Form/getSiblingData.ts @@ -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 diff --git a/packages/ui/src/forms/Form/index.tsx b/packages/ui/src/forms/Form/index.tsx index 4443453221..bac74ad77d 100644 --- a/packages/ui/src/forms/Form/index.tsx +++ b/packages/ui/src/forms/Form/index.tsx @@ -33,6 +33,7 @@ import { FormContext, FormFieldsContext, FormWatchContext, + InitializingContext, ModifiedContext, ProcessingContext, SubmittedContext, @@ -60,6 +61,7 @@ export const Form: React.FC = (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 = (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(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 = (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 = (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 = (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 = (props) => { }} > - - - - {children} - - - + + + + + {children} + + + + diff --git a/packages/ui/src/forms/Form/initContextState.ts b/packages/ui/src/forms/Form/initContextState.ts index 81dcedb2cc..bad62c5d10 100644 --- a/packages/ui/src/forms/Form/initContextState.ts +++ b/packages/ui/src/forms/Form/initContextState.ts @@ -37,6 +37,7 @@ export const initContextState: Context = { getField: (): FormField => undefined, getFields: (): FormState => ({}), getSiblingData, + initializing: undefined, removeFieldRow: () => undefined, replaceFieldRow: () => undefined, replaceState: () => undefined, diff --git a/packages/ui/src/forms/Form/types.ts b/packages/ui/src/forms/Form/types.ts index fbaef43667..2e9681f9bf 100644 --- a/packages/ui/src/forms/Form/types.ts +++ b/packages/ui/src/forms/Form/types.ts @@ -35,6 +35,7 @@ export type FormProps = ( fields?: Field[] handleResponse?: (res: Response) => void initialState?: FormState + isInitializing?: boolean log?: boolean onChange?: ((args: { formState: FormState }) => Promise)[] 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, diff --git a/packages/ui/src/forms/RenderFields/index.tsx b/packages/ui/src/forms/RenderFields/index.tsx index 6a9cf93603..7b4c4cd634 100644 --- a/packages/ui/src/forms/RenderFields/index.tsx +++ b/packages/ui/src/forms/RenderFields/index.tsx @@ -22,8 +22,9 @@ export const RenderFields: React.FC = (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) => { 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) => { CustomField={CustomField} custom={custom} disabled={disabled} - fieldComponentProps={fieldComponentProps} + fieldComponentProps={{ ...fieldComponentProps, forceRender: forceRenderChildren }} indexPath={indexPath !== undefined ? `${indexPath}.${fieldIndex}` : `${fieldIndex}`} isHidden={isHidden} key={fieldIndex} diff --git a/packages/ui/src/forms/RenderFields/types.ts b/packages/ui/src/forms/RenderFields/types.ts index bd8f0e06e2..3dbe618fc0 100644 --- a/packages/ui/src/forms/RenderFields/types.ts +++ b/packages/ui/src/forms/RenderFields/types.ts @@ -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 diff --git a/packages/ui/src/forms/Submit/index.tsx b/packages/ui/src/forms/Submit/index.tsx index 0ae36d3f82..92e91c2385 100644 --- a/packages/ui/src/forms/Submit/index.tsx +++ b/packages/ui/src/forms/Submit/index.tsx @@ -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((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 (
diff --git a/packages/ui/src/forms/buildStateFromSchema/calculateDefaultValues/index.ts b/packages/ui/src/forms/buildStateFromSchema/calculateDefaultValues/index.ts index 2283611990..0382705adf 100644 --- a/packages/ui/src/forms/buildStateFromSchema/calculateDefaultValues/index.ts +++ b/packages/ui/src/forms/buildStateFromSchema/calculateDefaultValues/index.ts @@ -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 => { +export const calculateDefaultValues = async ({ + id, + data, + fields, + locale, + user, +}: Args): Promise => { await iterateFields({ id, data, fields, - req, + locale, siblingData: data, + user, }) return data diff --git a/packages/ui/src/forms/buildStateFromSchema/calculateDefaultValues/iterateFields.ts b/packages/ui/src/forms/buildStateFromSchema/calculateDefaultValues/iterateFields.ts index 8294c4b8a2..c3bc1c095a 100644 --- a/packages/ui/src/forms/buildStateFromSchema/calculateDefaultValues/iterateFields.ts +++ b/packages/ui/src/forms/buildStateFromSchema/calculateDefaultValues/iterateFields.ts @@ -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 = { data: T fields: (Field | TabAsField)[] id?: number | string - req: PayloadRequestWithData + locale: string | undefined siblingData: Data + user: User } export const iterateFields = async ({ id, data, fields, - req, + locale, siblingData, + user, }: Args): Promise => { const promises = [] fields.forEach((field) => { @@ -24,8 +27,9 @@ export const iterateFields = async ({ id, data, field, - req, + locale, siblingData, + user, }), ) }) diff --git a/packages/ui/src/forms/buildStateFromSchema/calculateDefaultValues/promise.ts b/packages/ui/src/forms/buildStateFromSchema/calculateDefaultValues/promise.ts index fbae14a396..14f8021f6a 100644 --- a/packages/ui/src/forms/buildStateFromSchema/calculateDefaultValues/promise.ts +++ b/packages/ui/src/forms/buildStateFromSchema/calculateDefaultValues/promise.ts @@ -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 = { 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 ({ id, data, field, - req, + locale, siblingData, + user, }: Args): Promise => { if (fieldAffectsData(field)) { if ( @@ -34,8 +31,8 @@ export const defaultValuePromise = async ({ ) { 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 ({ id, data, fields: field.fields, - req, + locale, siblingData: groupData, + user, }) break @@ -70,8 +68,9 @@ export const defaultValuePromise = async ({ id, data, fields: field.fields, - req, + locale, siblingData: row, + user, }), ) }) @@ -97,8 +96,9 @@ export const defaultValuePromise = async ({ id, data, fields: block.fields, - req, + locale, siblingData: row, + user, }), ) } @@ -115,8 +115,9 @@ export const defaultValuePromise = async ({ id, data, fields: field.fields, - req, + locale, siblingData, + user, }) break @@ -136,8 +137,9 @@ export const defaultValuePromise = async ({ id, data, fields: field.fields, - req, + locale, siblingData: tabSiblingData, + user, }) break @@ -148,8 +150,9 @@ export const defaultValuePromise = async ({ id, data, fields: field.tabs.map((tab) => ({ ...tab, type: 'tab' })), - req, + locale, siblingData, + user, }) break diff --git a/packages/ui/src/forms/buildStateFromSchema/index.tsx b/packages/ui/src/forms/buildStateFromSchema/index.tsx index 62e4b9204b..2522d095c5 100644 --- a/packages/ui/src/forms/buildStateFromSchema/index.tsx +++ b/packages/ui/src/forms/buildStateFromSchema/index.tsx @@ -41,8 +41,9 @@ export const buildStateFromSchema = async (args: Args): Promise => { id, data: fullData, fields: fieldSchema, - req, + locale: req.locale, siblingData: fullData, + user: req.user, }) await iterateFields({ diff --git a/packages/ui/src/forms/useField/index.tsx b/packages/ui/src/forms/useField/index.tsx index 0183b62522..513a279a45 100644 --- a/packages/ui/src/forms/useField/index.tsx +++ b/packages/ui/src/forms/useField/index.tsx @@ -16,6 +16,7 @@ import { useFieldProps } from '../FieldPropsProvider/index.js' import { useForm, useFormFields, + useFormInitializing, useFormModified, useFormProcessing, useFormSubmitted, @@ -36,6 +37,7 @@ export const useField = (options: Options): FieldType => { 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 = (options: Options): FieldType => { errorMessage: field?.errorMessage, errorPaths: field?.errorPaths || [], filterOptions, + formInitializing: initializing, formProcessing: processing, formSubmitted: submitted, initialValue, @@ -137,6 +140,7 @@ export const useField = (options: Options): FieldType => { readOnly, permissions, filterOptions, + initializing, ], ) diff --git a/packages/ui/src/forms/useField/types.ts b/packages/ui/src/forms/useField/types.ts index 899898216d..c0b597790c 100644 --- a/packages/ui/src/forms/useField/types.ts +++ b/packages/ui/src/forms/useField/types.ts @@ -14,6 +14,7 @@ export type FieldType = { errorMessage?: string errorPaths?: string[] filterOptions?: FilterOptionsResult + formInitializing: boolean formProcessing: boolean formSubmitted: boolean initialValue?: T diff --git a/packages/ui/src/providers/DocumentInfo/index.tsx b/packages/ui/src/providers/DocumentInfo/index.tsx index f332d1f7be..59f7730344 100644 --- a/packages/ui/src/providers/DocumentInfo/index.tsx +++ b/packages/ui/src/providers/DocumentInfo/index.tsx @@ -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() - const [initialState, setInitialState] = useState() - const [publishedDoc, setPublishedDoc] = useState(null) - const [versions, setVersions] = useState>>(null) - const [docPermissions, setDocPermissions] = useState(null) - const [hasSavePermission, setHasSavePermission] = useState(null) - const [hasPublishPermission, setHasPublishPermission] = useState(null) - const hasInitializedDocPermissions = useRef(false) - const [unpublishedVersions, setUnpublishedVersions] = - useState>>(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(initialDataFromProps) + const [initialState, setInitialState] = useState(initialStateFromProps) + const [publishedDoc, setPublishedDoc] = useState(null) + const [versions, setVersions] = useState>>(null) + const [docPermissions, setDocPermissions] = useState(docPermissionsFromProps) + const [hasSavePermission, setHasSavePermission] = useState(hasSavePermissionFromProps) + const [hasPublishPermission, setHasPublishPermission] = useState( + hasPublishPermissionFromProps, + ) + const isInitializing = initialState === undefined || data === undefined + const hasInitializedDocPermissions = useRef(false) + const [unpublishedVersions, setUnpublishedVersions] = + useState>>(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 - } - const value: DocumentInfoContext = { ...props, docConfig, @@ -484,6 +515,8 @@ export const DocumentInfoProvider: React.FC< hasSavePermission, initialData: data, initialState, + isInitializing, + isLoading, onSave, publishedDoc, setDocFieldPreferences, diff --git a/packages/ui/src/providers/DocumentInfo/types.ts b/packages/ui/src/providers/DocumentInfo/types.ts index 755c3198e8..4843a04a1b 100644 --- a/packages/ui/src/providers/DocumentInfo/types.ts +++ b/packages/ui/src/providers/DocumentInfo/types.ts @@ -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 onSave?: (data: Data) => Promise | void @@ -41,6 +43,8 @@ export type DocumentInfoContext = DocumentInfoProps & { getVersions: () => Promise initialData: Data initialState?: FormState + isInitializing: boolean + isLoading: boolean preferencesKey?: string publishedDoc?: TypeWithID & TypeWithTimestamps & { _status?: string } setDocFieldPreferences: ( diff --git a/packages/ui/src/providers/Root/index.tsx b/packages/ui/src/providers/Root/index.tsx index 16cdcb8fd0..664d9f0522 100644 --- a/packages/ui/src/providers/Root/index.tsx +++ b/packages/ui/src/providers/Root/index.tsx @@ -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' diff --git a/packages/ui/src/providers/Translation/index.tsx b/packages/ui/src/providers/Translation/index.tsx index 3c6e9bf3af..fb1a6d11bc 100644 --- a/packages/ui/src/providers/Translation/index.tsx +++ b/packages/ui/src/providers/Translation/index.tsx @@ -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, diff --git a/packages/ui/src/utilities/getFormState.ts b/packages/ui/src/utilities/getFormState.ts index 34e679d3b0..288da62f80 100644 --- a/packages/ui/src/utilities/getFormState.ts +++ b/packages/ui/src/utilities/getFormState.ts @@ -8,14 +8,16 @@ export const getFormState = async (args: { onError?: (data?: any) => Promise | void serverURL: SanitizedConfig['serverURL'] signal?: AbortSignal + token?: string }): Promise => { - 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, diff --git a/packages/ui/src/utilities/reduceFieldsToValues.ts b/packages/ui/src/utilities/reduceFieldsToValues.ts index 0d88d9b2a9..113deb4224 100644 --- a/packages/ui/src/utilities/reduceFieldsToValues.ts +++ b/packages/ui/src/utilities/reduceFieldsToValues.ts @@ -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 diff --git a/test/access-control/e2e.spec.ts b/test/access-control/e2e.spec.ts index 137d2e364e..d31ac4cefb 100644 --- a/test/access-control/e2e.spec.ts +++ b/test/access-control/e2e.spec.ts @@ -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() diff --git a/test/admin/components/FieldDescription/index.tsx b/test/admin/components/FieldDescription/index.tsx index fad5b340dc..74bca4b675 100644 --- a/test/admin/components/FieldDescription/index.tsx +++ b/test/admin/components/FieldDescription/index.tsx @@ -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 (
diff --git a/test/admin/components/views/CustomView/index.client.tsx b/test/admin/components/views/CustomView/index.client.tsx index 485b554829..8bf7b4e521 100644 --- a/test/admin/components/views/CustomView/index.client.tsx +++ b/test/admin/components/views/CustomView/index.client.tsx @@ -23,16 +23,15 @@ export const ClientForm: React.FC = () => { > - Submit ) } 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 diff --git a/test/admin/e2e/2/e2e.spec.ts b/test/admin/e2e/2/e2e.spec.ts index 0d4cfc5da6..509a512282 100644 --- a/test/admin/e2e/2/e2e.spec.ts +++ b/test/admin/e2e/2/e2e.spec.ts @@ -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 diff --git a/test/auth/e2e.spec.ts b/test/auth/e2e.spec.ts index 4f51bf4ba8..861db5e07e 100644 --- a/test/auth/e2e.spec.ts +++ b/test/auth/e2e.spec.ts @@ -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!') }) diff --git a/test/fields-relationship/e2e.spec.ts b/test/fields-relationship/e2e.spec.ts index 7df9e37ef4..ee2060445e 100644 --- a/test/fields-relationship/e2e.spec.ts +++ b/test/fields-relationship/e2e.spec.ts @@ -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) }) diff --git a/test/fields/collections/Relationship/e2e.spec.ts b/test/fields/collections/Relationship/e2e.spec.ts index 93a3e2eeba..be3a19e914 100644 --- a/test/fields/collections/Relationship/e2e.spec.ts +++ b/test/fields/collections/Relationship/e2e.spec.ts @@ -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) diff --git a/test/fields/collections/RichText/e2e.spec.ts b/test/fields/collections/RichText/e2e.spec.ts index 4f93ceb6df..689b9908dd 100644 --- a/test/fields/collections/RichText/e2e.spec.ts +++ b/test/fields/collections/RichText/e2e.spec.ts @@ -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') diff --git a/test/fields/e2e.spec.ts b/test/fields/e2e.spec.ts index 90d8df697d..6223fab907 100644 --- a/test/fields/e2e.spec.ts +++ b/test/fields/e2e.spec.ts @@ -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) }) }) diff --git a/test/helpers.ts b/test/helpers.ts index fdbb8a3b3c..feb24b2f0a 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -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 await wait(500) // wait for drawer form state to initialize } +export async function openCreateDocDrawer(page: Page, fieldSelector: string): Promise { + 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 { if (!(await page.locator('.template-default.template-default--nav-open').isVisible())) return await page.locator('.nav-toggler >> visible=true').click() diff --git a/test/localization/e2e.spec.ts b/test/localization/e2e.spec.ts index 31bf66fdd6..e4cc3f69ce 100644 --- a/test/localization/e2e.spec.ts +++ b/test/localization/e2e.spec.ts @@ -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) }) }) diff --git a/test/plugin-form-builder/e2e.spec.ts b/test/plugin-form-builder/e2e.spec.ts index e96fdfec06..1af13e00c9 100644 --- a/test/plugin-form-builder/e2e.spec.ts +++ b/test/plugin-form-builder/e2e.spec.ts @@ -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', }) diff --git a/test/uploads/e2e.spec.ts b/test/uploads/e2e.spec.ts index 5be19bd986..debec00f8e 100644 --- a/test/uploads/e2e.spec.ts +++ b/test/uploads/e2e.spec.ts @@ -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')) diff --git a/test/versions/e2e.spec.ts b/test/versions/e2e.spec.ts index 763fc8a8f6..af0789b048 100644 --- a/test/versions/e2e.spec.ts +++ b/test/versions/e2e.spec.ts @@ -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')