From 98eca32b170d4e021247f854e37f2bb07bcd26c0 Mon Sep 17 00:00:00 2001 From: Michael Nelson Date: Fri, 3 Jan 2025 13:19:10 -0700 Subject: [PATCH 1/2] test: add stress-test for blocks performance --- test/_community/blocks/Container.ts | 20 ++ test/_community/blocks/Hero.ts | 362 +++++++++++++++++++++ test/_community/blocks/blockOptions.ts | 121 +++++++ test/_community/blocks/blocks.ts | 9 + test/_community/blocks/index.ts | 1 + test/_community/collections/Pages/index.ts | 21 ++ test/_community/config.ts | 3 +- test/_community/fields/richText.ts | 76 +++++ 8 files changed, 612 insertions(+), 1 deletion(-) create mode 100644 test/_community/blocks/Container.ts create mode 100644 test/_community/blocks/Hero.ts create mode 100644 test/_community/blocks/blockOptions.ts create mode 100644 test/_community/blocks/blocks.ts create mode 100644 test/_community/blocks/index.ts create mode 100644 test/_community/collections/Pages/index.ts create mode 100644 test/_community/fields/richText.ts diff --git a/test/_community/blocks/Container.ts b/test/_community/blocks/Container.ts new file mode 100644 index 00000000000..81e49092dc6 --- /dev/null +++ b/test/_community/blocks/Container.ts @@ -0,0 +1,20 @@ +import type { Block } from 'payload' + +import { blockOptions } from './blockOptions.js' +import { generateHeroBlocks } from './Hero.js' + +const Container: (index: number) => Block = (index) => ({ + slug: `Container${index}`, + interfaceName: `Container${index}Block`, + fields: [ + { + name: 'blocks', + type: 'blocks', + blocks: generateHeroBlocks(50), + }, + blockOptions, + ], +}) + +export const generateContainerBlocks = (count: number) => + Array.from({ length: count }).map((_, index) => Container(index + 1)) diff --git a/test/_community/blocks/Hero.ts b/test/_community/blocks/Hero.ts new file mode 100644 index 00000000000..213a023c8d3 --- /dev/null +++ b/test/_community/blocks/Hero.ts @@ -0,0 +1,362 @@ +import type { Block } from 'payload' + +import { richTextField } from '../fields/richText.js' +import { blockOptions } from './blockOptions.js' + +const buildHero: (index: number) => Block = (index) => ({ + slug: `Hero${index}`, + interfaceName: `Hero${index}Block`, + labels: { + singular: `Hero${index}`, + plural: `Heroes${index}`, + }, + fields: [ + { + label: 'Images', + type: 'collapsible', + fields: [ + { + name: 'images', + interfaceName: 'HeroImages', + label: false, + type: 'group', + fields: [ + { + label: 'Small', + type: 'collapsible', + admin: { initCollapsed: true }, + fields: [ + { + name: 'small', + label: false, + interfaceName: 'SmallHeroImages', + type: 'group', + fields: [ + { + name: 'background', + type: 'upload', + relationTo: 'media', + }, + { + name: 'icon', + type: 'upload', + relationTo: 'media', + }, + ], + }, + ], + }, + { + label: 'Medium', + type: 'collapsible', + admin: { initCollapsed: true }, + fields: [ + { + name: 'medium', + label: false, + interfaceName: 'MediumHeroImages', + type: 'group', + fields: [ + { + name: 'background', + type: 'upload', + relationTo: 'media', + }, + { + name: 'icon', + type: 'upload', + relationTo: 'media', + }, + ], + }, + ], + }, + { + label: 'Large', + type: 'collapsible', + admin: { initCollapsed: true }, + fields: [ + { + name: 'large', + label: false, + interfaceName: 'LargeHeroImages', + type: 'group', + fields: [ + { + name: 'background', + type: 'upload', + relationTo: 'media', + }, + { + name: 'icon', + type: 'upload', + relationTo: 'media', + }, + ], + }, + ], + }, + ], + }, + ], + admin: { initCollapsed: true, hidden: false }, + }, + { + type: 'row', + fields: [richTextField({ name: 'header' }), richTextField({ name: 'subheader' })], + }, + richTextField({ name: 'text' }), + { + name: 'buttons', + type: 'array', + interfaceName: 'DynamicButton', + fields: [ + { + type: 'row', + fields: [ + { name: 'dynamic', type: 'checkbox' }, + { name: 'newTab', label: 'Open in new tab', type: 'checkbox' }, + ], + }, + { + type: 'row', + fields: [ + richTextField({ name: 'label' }), + { + name: 'link', + type: 'text', + admin: { condition: (_, siblingData) => !siblingData?.dynamic }, + }, + ], + }, + { + type: 'row', + fields: [ + { + name: 'value', + type: 'select', + options: [ + { + label: 'Nearest Store - Phone Number', + value: 'nearestPhoneNumber', + }, + { + label: 'Regional Manager - Phone Number', + value: 'regionalManagerPhoneNumber', + }, + ], + admin: { + condition: (_, siblingData) => siblingData?.dynamic, + }, + }, + ], + }, + ], + }, + { + name: 'buttonAlignment', + type: 'select', + options: [ + { label: 'Default', value: 'default' }, + { label: 'Left', value: 'left' }, + { label: 'Center', value: 'center' }, + { label: 'Right', value: 'right' }, + ], + defaultValue: 'default', + }, + { + label: 'Settings', + type: 'collapsible', + fields: [ + { + type: 'row', + fields: [ + { + name: 'type', + type: 'select', + options: [ + { label: 'Standard', value: 'standard' }, + { label: 'Glass', value: 'glass' }, + { label: 'Glass Pane', value: 'glassPane' }, + { label: 'Stretch', value: 'stretch' }, + { label: 'Icon', value: 'icon' }, + ], + admin: { + hidden: true, + }, + }, + { + name: 'style', + type: 'select', + options: [ + { label: 'Gradient', value: 'gradient' }, + { label: 'Blur', value: 'blur' }, + { label: 'Glass', value: 'glass' }, + { label: 'Home', value: 'home' }, + { label: 'Footer', value: 'footer' }, + { label: 'Footer Askew', value: 'footerAskew' }, + ], + defaultValue: 'gradient', + }, + { + name: 'aspectRatio', + type: 'select', + options: [ + { label: 'Mini', value: 'mini' }, + { label: 'Short', value: 'short' }, + { label: 'Tall', value: 'tall' }, + ], + defaultValue: 'short', + admin: { + description: + 'Decides aspects for laptop - tablet - phone. Short is 32/9 - 2/3 - 9/8, Tall is 16/9 - 4/3 - 9/16', + }, + }, + ], + }, + { + type: 'row', + fields: [ + { + name: 'centerOverlay', + type: 'checkbox', + }, + { + name: 'overlayMaxHeight', + type: 'group', + interfaceName: 'OverlayMaxHeight', + fields: [ + { + type: 'row', + fields: [ + { name: 'small', type: 'text' }, + { name: 'medium', type: 'text' }, + { name: 'large', type: 'text' }, + ], + }, + ], + }, + ], + }, + { + type: 'row', + fields: [ + { + name: 'gradientDirection', + type: 'group', + fields: [ + { + type: 'row', + fields: [ + { + name: 'small', + type: 'select', + options: [ + { label: 'Left', value: 'l' }, + { label: 'Right', value: 'r' }, + { label: 'Top', value: 't' }, + { label: 'Bottom', value: 'b' }, + { label: 'Top Left', value: 'tl' }, + { label: 'Top Right', value: 'tr' }, + { label: 'Bottom Left', value: 'bl' }, + { label: 'Bottom Right', value: 'br' }, + ], + }, + { + name: 'medium', + type: 'select', + options: [ + { label: 'Left', value: 'l' }, + { label: 'Right', value: 'r' }, + { label: 'Top', value: 't' }, + { label: 'Bottom', value: 'b' }, + { label: 'Top Left', value: 'tl' }, + { label: 'Top Right', value: 'tr' }, + { label: 'Bottom Left', value: 'bl' }, + { label: 'Bottom Right', value: 'br' }, + ], + }, + { + name: 'large', + type: 'select', + options: [ + { label: 'Left', value: 'l' }, + { label: 'Right', value: 'r' }, + { label: 'Top', value: 't' }, + { label: 'Bottom', value: 'b' }, + { label: 'Top Left', value: 'tl' }, + { label: 'Top Right', value: 'tr' }, + { label: 'Bottom Left', value: 'bl' }, + { label: 'Bottom Right', value: 'br' }, + ], + }, + ], + }, + ], + }, + ], + admin: { + condition: (_, siblingData) => siblingData?.style === 'gradient', + }, + }, + { + type: 'row', + fields: [ + { + name: 'alignment', + type: 'select', + required: true, + options: [ + { label: 'Left', value: 'left' }, + { label: 'Right', value: 'right' }, + ], + defaultValue: 'left', + }, + { + name: 'wideRounding', + type: 'select', + hasMany: false, + options: [ + { label: 'None', value: 'none' }, + { label: 'Top', value: 'wide_rounded_t' }, + { label: 'Bottom', value: 'wide_rounded_b' }, + { label: 'Both', value: 'wide_rounded' }, + ], + defaultValue: 'wide_rounded_b', + admin: { hidden: true }, + }, + { + name: 'rounding', + type: 'select', + hasMany: false, + options: [ + { label: 'None', value: 'none' }, + { label: 'Top', value: 'top' }, + { label: 'Bottom', value: 'bottom' }, + { label: 'Both', value: 'rounded' }, + ], + defaultValue: 'bottom', + }, + ], + }, + { + name: 'status', + type: 'select', + options: [ + { label: 'Active', value: 'active' }, + { label: 'Inactive', value: 'inactive' }, + ], + defaultValue: 'active', + admin: { + description: 'Set to Inactive to hide from the homepage slider.', + }, + }, + ], + admin: { initCollapsed: true }, + }, + blockOptions, + ], +}) + +export const generateHeroBlocks = (count: number) => + Array.from({ length: count }).map((_, index) => buildHero(index + 1)) diff --git a/test/_community/blocks/blockOptions.ts b/test/_community/blocks/blockOptions.ts new file mode 100644 index 00000000000..0a66cb29883 --- /dev/null +++ b/test/_community/blocks/blockOptions.ts @@ -0,0 +1,121 @@ +import type { Field } from 'payload' + +export const blockOptions: Field = { + label: 'Options', + type: 'collapsible', + admin: { initCollapsed: true }, + fields: [ + { + name: 'blockOptions', + label: false, + type: 'group', + interfaceName: 'BlockOptions', + fields: [ + { + type: 'collapsible', + label: 'Styling', + admin: { initCollapsed: true }, + fields: [ + { + type: 'row', + fields: [ + { + name: 'margin', + type: 'select', + options: [ + { label: 'Small', value: 'sm' }, + { label: 'Medium', value: 'md' }, + { label: 'Large', value: 'lg' }, + ], + }, + { + name: 'padding', + type: 'select', + options: [ + { label: 'Small', value: 'sm' }, + { label: 'Medium', value: 'md' }, + { label: 'Large', value: 'lg' }, + ], + defaultValue: 'md', + }, + ], + }, + { + type: 'row', + fields: [ + { + name: 'bg', + label: 'Background', + type: 'select', + options: [ + { label: 'Default', value: 'default' }, + { label: 'Standard', value: 'standard' }, + { label: 'Inverted', value: 'inverted' }, + { label: 'Light', value: 'light' }, + { label: 'Dark', value: 'dark' }, + ], + defaultValue: 'default', + }, + { + name: 'text', + type: 'select', + options: [ + { label: 'Default', value: 'default' }, + { label: 'Standard', value: 'standard' }, + { label: 'Inverted', value: 'inverted' }, + { label: 'Light', value: 'light' }, + { label: 'Dark', value: 'dark' }, + ], + defaultValue: 'default', + }, + ], + }, + { + type: 'row', + fields: [ + { + name: 'showAt', + type: 'select', + options: [ + { label: 'All', value: 'all' }, + { label: 'Small', value: 'sm' }, + { label: 'Medium', value: 'md' }, + { label: 'Large', value: 'lg' }, + ], + }, + { + name: 'hideAt', + type: 'select', + options: [ + { label: 'None', value: 'none' }, + { label: 'Small', value: 'sm' }, + { label: 'Medium', value: 'md' }, + { label: 'Large', value: 'lg' }, + ], + }, + ], + }, + { + name: 'css', + label: 'Custom CSS', + type: 'code', + admin: { + language: 'css', + description: + 'Must be applied to a single class (any name). Example: .cool-stuff {color: "red", background-color: "black"}', + }, + }, + ], + }, + { + name: 'anchorId', + type: 'text', + admin: { + description: + 'Add an anchor ID to this container to link to it from another part of the page.', + }, + }, + ], + }, + ], +} diff --git a/test/_community/blocks/blocks.ts b/test/_community/blocks/blocks.ts new file mode 100644 index 00000000000..22e490e673e --- /dev/null +++ b/test/_community/blocks/blocks.ts @@ -0,0 +1,9 @@ +import type { Block } from 'payload' + +import { generateContainerBlocks } from './Container.js' +import { generateHeroBlocks } from './Hero.js' + +export const generateBlocks = (blockCount: number, containerCount?: number): Block[] => [ + ...generateHeroBlocks(blockCount), + ...(containerCount && containerCount > 0 ? generateContainerBlocks(containerCount) : []), +] diff --git a/test/_community/blocks/index.ts b/test/_community/blocks/index.ts new file mode 100644 index 00000000000..8d91709310d --- /dev/null +++ b/test/_community/blocks/index.ts @@ -0,0 +1 @@ +export { generateBlocks } from './blocks.js' diff --git a/test/_community/collections/Pages/index.ts b/test/_community/collections/Pages/index.ts new file mode 100644 index 00000000000..6491c06e588 --- /dev/null +++ b/test/_community/collections/Pages/index.ts @@ -0,0 +1,21 @@ +import type { CollectionConfig } from 'payload' + +import { generateBlocks } from '../../blocks/index.js' + +export const pagesSlug = 'pages' + +export const PagesCollection: CollectionConfig = { + slug: pagesSlug, + access: { + create: () => true, + read: () => true, + }, + fields: [ + { + name: 'sections', + label: 'Sections', + type: 'blocks', + blocks: generateBlocks(50, 25), + }, + ], +} diff --git a/test/_community/config.ts b/test/_community/config.ts index ee1aee6e46d..e18a81e8dd0 100644 --- a/test/_community/config.ts +++ b/test/_community/config.ts @@ -5,6 +5,7 @@ import path from 'path' import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js' import { devUser } from '../credentials.js' import { MediaCollection } from './collections/Media/index.js' +import { PagesCollection } from './collections/Pages/index.js' import { PostsCollection, postsSlug } from './collections/Posts/index.js' import { MenuGlobal } from './globals/Menu/index.js' @@ -13,7 +14,7 @@ const dirname = path.dirname(filename) export default buildConfigWithDefaults({ // ...extend config here - collections: [PostsCollection, MediaCollection], + collections: [PostsCollection, PagesCollection, MediaCollection], admin: { importMap: { baseDir: path.resolve(dirname), diff --git a/test/_community/fields/richText.ts b/test/_community/fields/richText.ts new file mode 100644 index 00000000000..6bcb0a8a3c8 --- /dev/null +++ b/test/_community/fields/richText.ts @@ -0,0 +1,76 @@ +import type { FieldBase, RichTextField, RowField } from 'payload' + +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { lexicalHTML } from '@payloadcms/richtext-lexical' + +const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1) + +const isEmpty = (node?: { root: any }) => { + if (!node || !node?.root) { + return true + } + + const { root } = node + const isRoot = root.type === 'root' + const hasSingleChild = root.children.length < 2 + const hasNestedChild = root.children.some((childNode: any) => { + if (childNode?.children) { + return childNode.children.length > 0 + } + + return false + }) + + return isRoot && hasSingleChild && !hasNestedChild +} + +/** + * @param name field name + * @param lexicalConfig use `simple` for a marks-only editor + * @returns a tuple of fields: richText field `name`, and string field `name_html` + */ +export const richTextField = ( + { + name, + label, + ...rest + }: { + lexicalConfig?: 'simple' + } & + Partial & Pick = { + name: 'content', + label: 'Content', + lexicalConfig: undefined, + }, +): RowField => { + if (!label) { + label = capitalize(name) + } + + return { + type: 'row', + fields: [ + { + ...rest, + name, + label, + type: 'richText', + hooks: { + ...(rest?.hooks || {}), + beforeChange: [ + ...(rest?.hooks?.beforeChange || []), + ({ value }) => { + // clean up the empty node that dirty lexical editor leaves behind + if (value && isEmpty(value)) { + value = null + } + + return value + }, + ], + }, + }, + lexicalHTML(name, { name: name + '_html' }), + ], + } +} From a6b96611173aaebb155f5f0fe5bb4e830e4bc98c Mon Sep 17 00:00:00 2001 From: Michael Nelson Date: Fri, 3 Jan 2025 14:22:23 -0700 Subject: [PATCH 2/2] Pass blockCount through to generateContainerBlocks to respect config value. --- test/_community/blocks/Container.ts | 8 ++++---- test/_community/blocks/blocks.ts | 4 +++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/test/_community/blocks/Container.ts b/test/_community/blocks/Container.ts index 81e49092dc6..05468d761b7 100644 --- a/test/_community/blocks/Container.ts +++ b/test/_community/blocks/Container.ts @@ -3,18 +3,18 @@ import type { Block } from 'payload' import { blockOptions } from './blockOptions.js' import { generateHeroBlocks } from './Hero.js' -const Container: (index: number) => Block = (index) => ({ +const Container: (index: number, blockCount: number) => Block = (index, blockCount) => ({ slug: `Container${index}`, interfaceName: `Container${index}Block`, fields: [ { name: 'blocks', type: 'blocks', - blocks: generateHeroBlocks(50), + blocks: generateHeroBlocks(blockCount), }, blockOptions, ], }) -export const generateContainerBlocks = (count: number) => - Array.from({ length: count }).map((_, index) => Container(index + 1)) +export const generateContainerBlocks = (count: number, blockCount: number) => + Array.from({ length: count }).map((_, index) => Container(index + 1, blockCount)) diff --git a/test/_community/blocks/blocks.ts b/test/_community/blocks/blocks.ts index 22e490e673e..3119523ef8f 100644 --- a/test/_community/blocks/blocks.ts +++ b/test/_community/blocks/blocks.ts @@ -5,5 +5,7 @@ import { generateHeroBlocks } from './Hero.js' export const generateBlocks = (blockCount: number, containerCount?: number): Block[] => [ ...generateHeroBlocks(blockCount), - ...(containerCount && containerCount > 0 ? generateContainerBlocks(containerCount) : []), + ...(containerCount && containerCount > 0 + ? generateContainerBlocks(containerCount, blockCount) + : []), ]