Skip to content

Commit e4aae63

Browse files
committed
feat(cms): allow blocks within content
closed COD-310
1 parent bf66815 commit e4aae63

8 files changed

Lines changed: 10464 additions & 81 deletions

File tree

apps/cms/src/migrations/20251228_201308_cod_310.json

Lines changed: 10285 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { MigrateDownArgs, MigrateUpArgs, sql } from '@payloadcms/db-postgres';
2+
3+
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
4+
await db.execute(sql`
5+
CREATE TABLE IF NOT EXISTS "reusable_content_blocks_reusable_content" (
6+
"_order" integer NOT NULL,
7+
"_parent_id" integer NOT NULL,
8+
"_path" text NOT NULL,
9+
"id" varchar PRIMARY KEY NOT NULL,
10+
"reusable_content_id" integer NOT NULL,
11+
"ref_id" varchar,
12+
"block_name" varchar
13+
);
14+
15+
DO $$ BEGIN
16+
ALTER TABLE "reusable_content_blocks_reusable_content" ADD CONSTRAINT "reusable_content_blocks_reusable_content_reusable_content_id_reusable_content_id_fk" FOREIGN KEY ("reusable_content_id") REFERENCES "public"."reusable_content"("id") ON DELETE set null ON UPDATE no action;
17+
EXCEPTION
18+
WHEN duplicate_object THEN null;
19+
END $$;
20+
21+
DO $$ BEGIN
22+
ALTER TABLE "reusable_content_blocks_reusable_content" ADD CONSTRAINT "reusable_content_blocks_reusable_content_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."reusable_content"("id") ON DELETE cascade ON UPDATE no action;
23+
EXCEPTION
24+
WHEN duplicate_object THEN null;
25+
END $$;
26+
27+
CREATE INDEX IF NOT EXISTS "reusable_content_blocks_reusable_content_order_idx" ON "reusable_content_blocks_reusable_content" USING btree ("_order");
28+
CREATE INDEX IF NOT EXISTS "reusable_content_blocks_reusable_content_parent_id_idx" ON "reusable_content_blocks_reusable_content" USING btree ("_parent_id");
29+
CREATE INDEX IF NOT EXISTS "reusable_content_blocks_reusable_content_path_idx" ON "reusable_content_blocks_reusable_content" USING btree ("_path");
30+
CREATE INDEX IF NOT EXISTS "reusable_content_blocks_reusable_content_reusable_content_idx" ON "reusable_content_blocks_reusable_content" USING btree ("reusable_content_id");`);
31+
}
32+
33+
export async function down({
34+
db,
35+
payload,
36+
req
37+
}: MigrateDownArgs): Promise<void> {
38+
await db.execute(sql`
39+
DROP TABLE "reusable_content_blocks_reusable_content" CASCADE;`);
40+
}

apps/cms/src/migrations/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import * as migration_20250421_181801_cod_326 from './20250421_181801_cod_326';
2323
import * as migration_20250428_172053_cod_323 from './20250428_172053_cod_323';
2424
import * as migration_20250515_201951_cod_292 from './20250515_201951_cod_292';
2525
import * as migration_20251221_124900_cod_356 from './20251221_124900_cod_356';
26+
import * as migration_20251228_201308_cod_310 from './20251228_201308_cod_310';
2627

2728
export const migrations = [
2829
{
@@ -149,5 +150,10 @@ export const migrations = [
149150
up: migration_20251221_124900_cod_356.up,
150151
down: migration_20251221_124900_cod_356.down,
151152
name: '20251221_124900_cod_356'
153+
},
154+
{
155+
up: migration_20251228_201308_cod_310.up,
156+
down: migration_20251228_201308_cod_310.down,
157+
name: '20251228_201308_cod_310'
152158
}
153159
];

libs/app-cms/ui/blocks/src/lib/content/content.block.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import type { Block } from 'payload';
99
* Define which blocks are available as rich text plugins.
1010
*/
1111
// Using a record to make sure all blocks are included and not forgotten
12-
const blocks: Record<BlockSlug, boolean> = {
12+
const richTextBlocks: Record<BlockSlug, boolean> = {
1313
card: true,
1414
code: true,
1515
form: true,
@@ -24,6 +24,22 @@ const blocks: Record<BlockSlug, boolean> = {
2424
video: false
2525
};
2626

27+
/** Define which blocks are available within the content block itself. */
28+
const inlineBlocks: Record<BlockSlug, boolean> = {
29+
card: true,
30+
form: true,
31+
image: true,
32+
media: true,
33+
code: true,
34+
'reusable-content': true,
35+
'social-media': true,
36+
spacing: true,
37+
// Unsupported blocks
38+
content: false,
39+
'file-area': false,
40+
video: false
41+
};
42+
2743
/**
2844
* Content block for defining a column layout with rich text content.
2945
*/
@@ -69,12 +85,21 @@ export const contentBlock: Block = {
6985
features: ({ rootFeatures }) => {
7086
return [
7187
...rootFeatures,
72-
BlocksFeature({ blocks: getActiveKeys<BlockSlug>(blocks) }),
88+
BlocksFeature({
89+
blocks: getActiveKeys<BlockSlug>(richTextBlocks)
90+
}),
7391
multiTenantLinkFeature()
7492
];
7593
}
7694
}),
7795
label: false
96+
},
97+
{
98+
name: 'blocks',
99+
type: 'blocks',
100+
blockReferences: getActiveKeys<BlockSlug>(inlineBlocks),
101+
blocks: [],
102+
label: false
78103
}
79104
]
80105
}
Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1-
export * from './lib/blocks/CodeBlock';
2-
export * from './lib/blocks/ContentBlock';
3-
export * from './lib/blocks/MediaBlock';
4-
export * from './lib/blocks/RichText';
5-
export * from './lib/RenderBlocks';
1+
export { RichText } from './lib/blocks/RichText';
2+
export { RenderBlocks } from './lib/RenderBlocks';
63

7-
export * from './lib/providers/PayloadProvider';
4+
export {
5+
type FormSubmitResponse,
6+
PayloadProvider,
7+
type PayloadValue,
8+
usePayload
9+
} from './lib/providers/PayloadProvider';

libs/shared/ui/payload-components/src/lib/RenderBlocks.tsx

Lines changed: 67 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,82 @@
11
import type {
22
BlockSlug,
3+
ContentBlock as ContentBlockProps,
34
Page,
4-
ReusableContentBlock
5+
ReusableContentBlock as ReusableContentBlockProps
56
} from '@codeware/shared/util/payload-types';
67
import { cn } from '@codeware/shared/util/ui';
78
import { useRef } from 'react';
89

910
import { CardBlock } from './blocks/CardBlock';
1011
import { CodeBlock } from './blocks/CodeBlock';
11-
import { ContentBlock } from './blocks/ContentBlock';
1212
import { FileAreaBlock } from './blocks/FileAreaBlock';
1313
import { FormBlock } from './blocks/FormBlock';
1414
import { ImageBlock } from './blocks/ImageBlock';
1515
import { MediaBlock } from './blocks/MediaBlock';
16+
import { RichText } from './blocks/RichText';
1617
import { SocialMediaBlock } from './blocks/SocialMediaBlock';
1718
import { SpacingBlock } from './blocks/SpacingBlock';
1819
import { VideoBlock } from './blocks/VideoBlock';
20+
import { ColumnSizeProvider } from './providers/ColumnSizeProvider';
1921

22+
/**
23+
* Render Payload content block data, with the configured number of columns
24+
* and rich text content in each column.
25+
*
26+
* Optional blocks are rendered after the rich text content, whitin each column.
27+
*
28+
* User defined column sizes are applied for tablets and desktops.
29+
* Mobile devices display all columns as full width.
30+
*/
31+
export const ContentBlock: React.FC<ContentBlockProps> = ({ columns }) => {
32+
return (
33+
<div className="grid w-full grid-cols-12 gap-x-4 gap-y-8 overflow-hidden md:gap-x-8 lg:gap-x-16">
34+
{columns?.map((col, index) => {
35+
const { blocks, richText } = col;
36+
const size = col.size ?? 'full';
37+
38+
return (
39+
<ColumnSizeProvider size={size} key={index}>
40+
<div
41+
className={cn('first-child-no-margin col-span-12', {
42+
'md:col-span-4': size === 'one-third',
43+
'md:col-span-6': size === 'half',
44+
'md:col-span-8': size === 'two-thirds'
45+
})}
46+
>
47+
{richText && <RichText data={richText} />}
48+
{!!blocks?.length && (
49+
<RenderBlocks
50+
className={cn({ 'mt-8': !!richText })}
51+
blocks={blocks}
52+
/>
53+
)}
54+
</div>
55+
</ColumnSizeProvider>
56+
);
57+
})}
58+
</div>
59+
);
60+
};
61+
62+
/**
63+
* Render Payload reusable content block data, by rendering its layout blocks.
64+
*/
65+
export const ReusableContentBlock: React.FC<ReusableContentBlockProps> = ({
66+
reusableContent,
67+
refId
68+
}) => {
69+
if (reusableContent && typeof reusableContent === 'object') {
70+
return <RenderBlocks blocks={reusableContent.layout} refId={refId} />;
71+
}
72+
return null;
73+
};
74+
75+
// Why are the components above defined here and not in `blocks/` folder?
76+
// Components depend on `RenderBlocks`. To avoid circular dependencies,
77+
// these components must be defined in the same file as `RenderBlocks`.
78+
79+
/** Block slug to component mapping */
2080
const blocksMap: Record<
2181
BlockSlug,
2282
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -29,35 +89,29 @@ const blocksMap: Record<
2989
form: FormBlock,
3090
image: ImageBlock,
3191
media: MediaBlock,
92+
'reusable-content': ReusableContentBlock,
3293
'social-media': SocialMediaBlock,
3394
spacing: SpacingBlock,
34-
video: VideoBlock,
35-
// Reusable content block invokes RenderBlocks to resolve the nested blocks.
36-
// Keep implementation here to avoid circular dependency.
37-
'reusable-content': ({ reusableContent, refId }: ReusableContentBlock) => {
38-
if (reusableContent && typeof reusableContent === 'object') {
39-
return <RenderBlocks blocks={reusableContent.layout} refId={refId} />;
40-
}
41-
return null;
42-
}
95+
video: VideoBlock
4396
};
4497

4598
type Props = {
4699
blocks: Page['layout'];
47100
refId?: string | null | undefined;
101+
className?: string;
48102
};
49103

50104
/**
51105
* Renders the blocks of a page.
52106
*/
53-
export const RenderBlocks: React.FC<Props> = ({ blocks, refId }) => {
107+
export const RenderBlocks: React.FC<Props> = ({ blocks, className, refId }) => {
54108
const docRef = useRef<HTMLDivElement>(null);
55109

56110
const hasBlocks = blocks && Array.isArray(blocks) && blocks.length > 0;
57111

58112
if (hasBlocks) {
59113
return (
60-
<div id={refId ?? undefined} ref={docRef}>
114+
<div id={refId ?? undefined} ref={docRef} className={className}>
61115
{blocks.map((block, index) => {
62116
const Block = blocksMap[block.blockType];
63117

libs/shared/ui/payload-components/src/lib/blocks/ContentBlock.tsx

Lines changed: 0 additions & 41 deletions
This file was deleted.

libs/shared/util/payload-types/src/lib/payload-types.ts

Lines changed: 31 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -619,32 +619,25 @@ export interface ContentBlock {
619619
};
620620
[k: string]: unknown;
621621
} | null;
622+
blocks?:
623+
| (
624+
| CardBlock
625+
| FormBlock
626+
| ImageBlock
627+
| MediaBlock
628+
| CodeBlock
629+
| ReusableContentBlock
630+
| SocialMediaBlock
631+
| SpacingBlock
632+
)[]
633+
| null;
622634
id?: string | null;
623635
}[]
624636
| null;
625637
id?: string | null;
626638
blockName?: string | null;
627639
blockType: 'content';
628640
}
629-
/**
630-
* This interface was referenced by `Config`'s JSON-Schema
631-
* via the `definition` "FileAreaBlock".
632-
*/
633-
export interface FileAreaBlock {
634-
/**
635-
* Select tags that represent the files to show in the file area.
636-
*/
637-
tags?: (number | Tag)[] | null;
638-
files?:
639-
| {
640-
media?: (number | null) | Media;
641-
id?: string | null;
642-
}[]
643-
| null;
644-
id?: string | null;
645-
blockName?: string | null;
646-
blockType: 'file-area';
647-
}
648641
/**
649642
* This interface was referenced by `Config`'s JSON-Schema
650643
* via the `definition` "FormBlock".
@@ -974,6 +967,25 @@ export interface ReusableContent {
974967
updatedAt: string;
975968
createdAt: string;
976969
}
970+
/**
971+
* This interface was referenced by `Config`'s JSON-Schema
972+
* via the `definition` "FileAreaBlock".
973+
*/
974+
export interface FileAreaBlock {
975+
/**
976+
* Select tags that represent the files to show in the file area.
977+
*/
978+
tags?: (number | Tag)[] | null;
979+
files?:
980+
| {
981+
media?: (number | null) | Media;
982+
id?: string | null;
983+
}[]
984+
| null;
985+
id?: string | null;
986+
blockName?: string | null;
987+
blockType: 'file-area';
988+
}
977989
/**
978990
* This interface was referenced by `Config`'s JSON-Schema
979991
* via the `definition` "SocialMediaBlock".

0 commit comments

Comments
 (0)