@vendure/dashboard 3.5.1-master-202511080229 → 3.5.1-master-202511120232

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -27,6 +27,7 @@ let globalCustomFieldsMap: Map<string, CustomFieldConfig[]> = new Map();
27
27
 
28
28
  // Memoization cache using WeakMap to avoid memory leaks
29
29
  const memoizationCache = new WeakMap<DocumentNode, Map<string, TypedDocumentNode<any, any>>>();
30
+ const fragmentMemoizationCache = new WeakMap<DocumentNode, Map<string, TypedDocumentNode<any, any>>>();
30
31
 
31
32
  /**
32
33
  * Creates a cache key for the options object
@@ -34,6 +35,7 @@ const memoizationCache = new WeakMap<DocumentNode, Map<string, TypedDocumentNode
34
35
  function createOptionsKey(options?: {
35
36
  customFieldsMap?: Map<string, CustomFieldConfig[]>;
36
37
  includeCustomFields?: string[];
38
+ includeNestedFragments?: string[];
37
39
  }): string {
38
40
  if (!options) return 'default';
39
41
 
@@ -51,6 +53,10 @@ function createOptionsKey(options?: {
51
53
  parts.push(`include:${options.includeCustomFields.sort().join(',')}`);
52
54
  }
53
55
 
56
+ if (options.includeNestedFragments) {
57
+ parts.push(`nested:${options.includeNestedFragments.sort().join(',')}`);
58
+ }
59
+
54
60
  return parts.join('|') || 'default';
55
61
  }
56
62
 
@@ -82,10 +88,232 @@ export function getCustomFieldsMap() {
82
88
  return globalCustomFieldsMap;
83
89
  }
84
90
 
91
+ /**
92
+ * @description
93
+ * Internal helper function that applies custom fields to a selection set for a given entity type.
94
+ * This is the core logic extracted for reuse.
95
+ */
96
+ function applyCustomFieldsToSelection(
97
+ typeName: string,
98
+ selectionSet: SelectionSetNode,
99
+ customFields: Map<string, CustomFieldConfig[]>,
100
+ options?: {
101
+ includeCustomFields?: string[];
102
+ },
103
+ ): void {
104
+ let entityType = typeName;
105
+
106
+ if (entityType === ('OrderAddress' as any)) {
107
+ // OrderAddress is a special case of the Address entity, and shares its custom fields
108
+ // so we treat it as an alias
109
+ entityType = 'Address';
110
+ }
111
+
112
+ if (entityType === ('Country' as any)) {
113
+ // Country is an alias of Region
114
+ entityType = 'Region';
115
+ }
116
+
117
+ const customFieldsForType = customFields.get(entityType);
118
+ if (customFieldsForType && customFieldsForType.length) {
119
+ // Check if there is already a customFields field in the fragment
120
+ // to avoid duplication
121
+ const existingCustomFieldsField = selectionSet.selections.find(
122
+ selection => isFieldNode(selection) && selection.name.value === 'customFields',
123
+ ) as FieldNode | undefined;
124
+ const selectionNodes: SelectionNode[] = customFieldsForType
125
+ .filter(
126
+ field => !options?.includeCustomFields || options?.includeCustomFields.includes(field.name),
127
+ )
128
+ .map(
129
+ customField =>
130
+ ({
131
+ kind: Kind.FIELD,
132
+ name: {
133
+ kind: Kind.NAME,
134
+ value: customField.name,
135
+ },
136
+ // For "relation" custom fields, we need to also select
137
+ // all the scalar fields of the related type
138
+ ...(customField.type === 'relation'
139
+ ? {
140
+ selectionSet: {
141
+ kind: Kind.SELECTION_SET,
142
+ selections: (
143
+ customField as RelationCustomFieldFragment
144
+ ).scalarFields.map(f => ({
145
+ kind: Kind.FIELD,
146
+ name: { kind: Kind.NAME, value: f },
147
+ })),
148
+ },
149
+ }
150
+ : {}),
151
+ ...(customField.type === 'struct'
152
+ ? {
153
+ selectionSet: {
154
+ kind: Kind.SELECTION_SET,
155
+ selections: (customField as StructCustomFieldFragment).fields.map(
156
+ f => ({
157
+ kind: Kind.FIELD,
158
+ name: { kind: Kind.NAME, value: f.name },
159
+ }),
160
+ ),
161
+ },
162
+ }
163
+ : {}),
164
+ }) as FieldNode,
165
+ );
166
+ if (!existingCustomFieldsField) {
167
+ // If no customFields field exists, add one
168
+ (selectionSet.selections as SelectionNode[]).push({
169
+ kind: Kind.FIELD,
170
+ name: {
171
+ kind: Kind.NAME,
172
+ value: 'customFields',
173
+ },
174
+ selectionSet: {
175
+ kind: Kind.SELECTION_SET,
176
+ selections: selectionNodes,
177
+ },
178
+ });
179
+ } else {
180
+ // If a customFields field already exists, add the custom fields
181
+ // to the existing selection set
182
+ (existingCustomFieldsField.selectionSet as any) = {
183
+ kind: Kind.SELECTION_SET,
184
+ selections: selectionNodes,
185
+ };
186
+ }
187
+
188
+ const localizedFields = customFieldsForType.filter(
189
+ field => field.type === 'localeString' || field.type === 'localeText',
190
+ );
191
+
192
+ const translationsField = selectionSet.selections
193
+ .filter(isFieldNode)
194
+ .find(field => field.name.value === 'translations');
195
+
196
+ if (localizedFields.length && translationsField && translationsField.selectionSet) {
197
+ (translationsField.selectionSet.selections as SelectionNode[]).push({
198
+ name: {
199
+ kind: Kind.NAME,
200
+ value: 'customFields',
201
+ },
202
+ kind: Kind.FIELD,
203
+ selectionSet: {
204
+ kind: Kind.SELECTION_SET,
205
+ selections: localizedFields.map(
206
+ customField =>
207
+ ({
208
+ kind: Kind.FIELD,
209
+ name: {
210
+ kind: Kind.NAME,
211
+ value: customField.name,
212
+ },
213
+ }) as FieldNode,
214
+ ),
215
+ },
216
+ });
217
+ }
218
+ }
219
+ }
220
+
221
+ /**
222
+ * @description
223
+ * Adds custom fields to a single fragment document. This is a more granular version of `addCustomFields()`
224
+ * that operates on individual fragments, allowing for better composability.
225
+ *
226
+ * **Important behavior with fragment dependencies:**
227
+ * - When a document contains multiple fragments (e.g., a main fragment with dependencies passed to `graphql()`),
228
+ * only the **first fragment** is modified with custom fields.
229
+ * - Any additional fragments (dependencies) are left untouched in the output document.
230
+ * - This allows you to selectively control which fragments get custom fields.
231
+ *
232
+ * This function is memoized to return a stable identity for given inputs.
233
+ *
234
+ * @example
235
+ * ```typescript
236
+ * // Basic usage
237
+ * const modifiedFragment = addCustomFieldsToFragment(orderDetailFragment, {
238
+ * includeCustomFields: ['reviewCount', 'priority']
239
+ * });
240
+ *
241
+ * // With fragment dependencies (only OrderDetail gets custom fields, OrderLine doesn't)
242
+ * const orderDetailFragment = graphql(
243
+ * `fragment OrderDetail on Order {
244
+ * id
245
+ * lines { ...OrderLine }
246
+ * }`,
247
+ * [orderLineFragment] // This dependency won't get custom fields
248
+ * );
249
+ * const modified = addCustomFieldsToFragment(orderDetailFragment);
250
+ * ```
251
+ */
252
+ export function addCustomFieldsToFragment<T, V extends Variables = Variables>(
253
+ fragmentDocument: DocumentNode | TypedDocumentNode<T, V>,
254
+ options?: {
255
+ customFieldsMap?: Map<string, CustomFieldConfig[]>;
256
+ includeCustomFields?: string[];
257
+ },
258
+ ): TypedDocumentNode<T, V> {
259
+ const optionsKey = createOptionsKey(options);
260
+
261
+ // Check if we have a cached result for this fragment and options
262
+ let documentCache = fragmentMemoizationCache.get(fragmentDocument);
263
+ if (!documentCache) {
264
+ documentCache = new Map();
265
+ fragmentMemoizationCache.set(fragmentDocument, documentCache);
266
+ }
267
+
268
+ const cachedResult = documentCache.get(optionsKey);
269
+ if (cachedResult) {
270
+ return cachedResult as TypedDocumentNode<T, V>;
271
+ }
272
+
273
+ // Validate that this is a fragment-only document
274
+ const fragmentDefs = fragmentDocument.definitions.filter(isFragmentDefinition);
275
+ const queryDefs = fragmentDocument.definitions.filter(isOperationDefinition);
276
+
277
+ if (queryDefs.length > 0) {
278
+ throw new Error(
279
+ 'addCustomFieldsToFragment() expects a fragment-only document. Use addCustomFields() for documents with queries.',
280
+ );
281
+ }
282
+
283
+ if (fragmentDefs.length === 0) {
284
+ throw new Error(
285
+ 'addCustomFieldsToFragment() expects a document with at least one fragment definition.',
286
+ );
287
+ }
288
+
289
+ // Clone the document
290
+ const clone = JSON.parse(JSON.stringify(fragmentDocument)) as DocumentNode;
291
+ const customFields = options?.customFieldsMap || globalCustomFieldsMap;
292
+
293
+ // Only modify the first fragment (the main one)
294
+ // Any additional fragments are dependencies and should be left untouched
295
+ const fragmentDef = clone.definitions.find(isFragmentDefinition) as FragmentDefinitionNode;
296
+
297
+ // Apply custom fields only to the first/main fragment
298
+ applyCustomFieldsToSelection(
299
+ fragmentDef.typeCondition.name.value,
300
+ fragmentDef.selectionSet,
301
+ customFields,
302
+ options,
303
+ );
304
+
305
+ // Cache the result before returning
306
+ documentCache.set(optionsKey, clone);
307
+ return clone;
308
+ }
309
+
85
310
  /**
86
311
  * Given a GraphQL AST (DocumentNode), this function looks for fragment definitions and adds and configured
87
312
  * custom fields to those fragments.
88
313
  *
314
+ * By default, only adds custom fields to top-level fragments (those used directly in the query result).
315
+ * Use `includeNestedFragments` to also add custom fields to specific nested fragments.
316
+ *
89
317
  * This function is memoized to return a stable identity for given inputs.
90
318
  */
91
319
  export function addCustomFields<T, V extends Variables = Variables>(
@@ -93,6 +321,18 @@ export function addCustomFields<T, V extends Variables = Variables>(
93
321
  options?: {
94
322
  customFieldsMap?: Map<string, CustomFieldConfig[]>;
95
323
  includeCustomFields?: string[];
324
+ /**
325
+ * Names of nested fragments that should also get custom fields.
326
+ * By default, only top-level fragments get custom fields.
327
+ *
328
+ * @example
329
+ * ```typescript
330
+ * addCustomFields(orderDetailDocument, {
331
+ * includeNestedFragments: ['OrderLine', 'Asset']
332
+ * })
333
+ * ```
334
+ */
335
+ includeNestedFragments?: string[];
96
336
  },
97
337
  ): TypedDocumentNode<T, V> {
98
338
  const optionsKey = createOptionsKey(options);
@@ -168,9 +408,13 @@ export function addCustomFields<T, V extends Variables = Variables>(
168
408
 
169
409
  for (const fragmentDef of fragmentDefs) {
170
410
  if (hasQueries) {
171
- // If we have queries, only add custom fields to fragments used at the top level
172
- // Skip fragments that are only used in nested contexts
173
- if (topLevelFragments.has(fragmentDef.name.value)) {
411
+ // If we have queries, add custom fields to:
412
+ // 1. Fragments used at the top level (in the main query result)
413
+ // 2. Fragments explicitly listed in includeNestedFragments option
414
+ const isTopLevel = topLevelFragments.has(fragmentDef.name.value);
415
+ const isExplicitlyIncluded = options?.includeNestedFragments?.includes(fragmentDef.name.value);
416
+
417
+ if (isTopLevel || isExplicitlyIncluded) {
174
418
  targetNodes.push({
175
419
  typeName: fragmentDef.typeCondition.name.value,
176
420
  selectionSet: fragmentDef.selectionSet,
@@ -187,122 +431,7 @@ export function addCustomFields<T, V extends Variables = Variables>(
187
431
  }
188
432
 
189
433
  for (const target of targetNodes) {
190
- let entityType = target.typeName;
191
-
192
- if (entityType === ('OrderAddress' as any)) {
193
- // OrderAddress is a special case of the Address entity, and shares its custom fields
194
- // so we treat it as an alias
195
- entityType = 'Address';
196
- }
197
-
198
- if (entityType === ('Country' as any)) {
199
- // Country is an alias of Region
200
- entityType = 'Region';
201
- }
202
-
203
- const customFieldsForType = customFields.get(entityType);
204
- if (customFieldsForType && customFieldsForType.length) {
205
- // Check if there is already a customFields field in the fragment
206
- // to avoid duplication
207
- const existingCustomFieldsField = target.selectionSet.selections.find(
208
- selection => isFieldNode(selection) && selection.name.value === 'customFields',
209
- ) as FieldNode | undefined;
210
- const selectionNodes: SelectionNode[] = customFieldsForType
211
- .filter(
212
- field =>
213
- !options?.includeCustomFields || options?.includeCustomFields.includes(field.name),
214
- )
215
- .map(
216
- customField =>
217
- ({
218
- kind: Kind.FIELD,
219
- name: {
220
- kind: Kind.NAME,
221
- value: customField.name,
222
- },
223
- // For "relation" custom fields, we need to also select
224
- // all the scalar fields of the related type
225
- ...(customField.type === 'relation'
226
- ? {
227
- selectionSet: {
228
- kind: Kind.SELECTION_SET,
229
- selections: (
230
- customField as RelationCustomFieldFragment
231
- ).scalarFields.map(f => ({
232
- kind: Kind.FIELD,
233
- name: { kind: Kind.NAME, value: f },
234
- })),
235
- },
236
- }
237
- : {}),
238
- ...(customField.type === 'struct'
239
- ? {
240
- selectionSet: {
241
- kind: Kind.SELECTION_SET,
242
- selections: (customField as StructCustomFieldFragment).fields.map(
243
- f => ({
244
- kind: Kind.FIELD,
245
- name: { kind: Kind.NAME, value: f.name },
246
- }),
247
- ),
248
- },
249
- }
250
- : {}),
251
- }) as FieldNode,
252
- );
253
- if (!existingCustomFieldsField) {
254
- // If no customFields field exists, add one
255
- (target.selectionSet.selections as SelectionNode[]).push({
256
- kind: Kind.FIELD,
257
- name: {
258
- kind: Kind.NAME,
259
- value: 'customFields',
260
- },
261
- selectionSet: {
262
- kind: Kind.SELECTION_SET,
263
- selections: selectionNodes,
264
- },
265
- });
266
- } else {
267
- // If a customFields field already exists, add the custom fields
268
- // to the existing selection set
269
- (existingCustomFieldsField.selectionSet as any) = {
270
- kind: Kind.SELECTION_SET,
271
- selections: selectionNodes,
272
- };
273
- }
274
-
275
- const localizedFields = customFieldsForType.filter(
276
- field => field.type === 'localeString' || field.type === 'localeText',
277
- );
278
-
279
- const translationsField = target.selectionSet.selections
280
- .filter(isFieldNode)
281
- .find(field => field.name.value === 'translations');
282
-
283
- if (localizedFields.length && translationsField && translationsField.selectionSet) {
284
- (translationsField.selectionSet.selections as SelectionNode[]).push({
285
- name: {
286
- kind: Kind.NAME,
287
- value: 'customFields',
288
- },
289
- kind: Kind.FIELD,
290
- selectionSet: {
291
- kind: Kind.SELECTION_SET,
292
- selections: localizedFields.map(
293
- customField =>
294
- ({
295
- kind: Kind.FIELD,
296
- name: {
297
- kind: Kind.NAME,
298
- value: customField.name,
299
- },
300
- }) as FieldNode,
301
- ),
302
- },
303
- });
304
- }
305
- }
434
+ applyCustomFieldsToSelection(target.typeName, target.selectionSet, customFields, options);
306
435
  }
307
436
 
308
437
  // Cache the result before returning
@@ -79,7 +79,7 @@ export type PageBlockPosition = { blockId: string; order: 'before' | 'after' | '
79
79
  export type PageBlockLocation = {
80
80
  pageId: string;
81
81
  position: PageBlockPosition;
82
- column: 'main' | 'side';
82
+ column: 'main' | 'side' | 'full';
83
83
  };
84
84
 
85
85
  /**
@@ -34,7 +34,7 @@ export interface HistoryEntryProps {
34
34
  *
35
35
  * ```ts
36
36
  * const success = 'bg-success text-success-foreground';
37
- * const destructive = 'bg-danger text-danger-foreground';
37
+ * const destructive = 'bg-destructive text-destructive-foreground';
38
38
  * ```
39
39
  */
40
40
  timelineIconClassName?: string;
@@ -0,0 +1,138 @@
1
+ import React from 'react';
2
+ import { renderToStaticMarkup } from 'react-dom/server';
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
4
+
5
+ import { PageBlock, PageLayout } from './page-layout.js';
6
+ import { registerDashboardPageBlock } from './layout-extensions.js';
7
+ import { PageContext } from './page-provider.js';
8
+ import { globalRegistry } from '../registry/global-registry.js';
9
+ import { UserSettingsContext, type UserSettingsContextType } from '../../providers/user-settings.js';
10
+
11
+ const useMediaQueryMock = vi.hoisted(() => vi.fn());
12
+ const useCopyToClipboardMock = vi.hoisted(() => vi.fn(() => [null, vi.fn()]));
13
+
14
+ vi.mock('@uidotdev/usehooks', () => ({
15
+ useMediaQuery: useMediaQueryMock,
16
+ useCopyToClipboard: useCopyToClipboardMock,
17
+ }));
18
+
19
+ function registerBlock(
20
+ id: string,
21
+ order: 'before' | 'after' | 'replace',
22
+ pageId = 'customer-list',
23
+ ): void {
24
+ registerDashboardPageBlock({
25
+ id,
26
+ title: id,
27
+ location: {
28
+ pageId,
29
+ column: 'main',
30
+ position: { blockId: 'list-table', order },
31
+ },
32
+ component: ({ context }) => <div data-testid={`page-block-${id}`}>{context.pageId}</div>,
33
+ });
34
+ }
35
+
36
+ function renderPageLayout(children: React.ReactNode, { isDesktop = true } = {}) {
37
+ useMediaQueryMock.mockReturnValue(isDesktop);
38
+ const noop = () => undefined;
39
+ const contextValue = {
40
+ settings: {
41
+ displayLanguage: 'en',
42
+ contentLanguage: 'en',
43
+ theme: 'system',
44
+ displayUiExtensionPoints: false,
45
+ mainNavExpanded: true,
46
+ activeChannelId: '',
47
+ devMode: false,
48
+ hasSeenOnboarding: false,
49
+ tableSettings: {},
50
+ },
51
+ settingsStoreIsAvailable: true,
52
+ setDisplayLanguage: noop,
53
+ setDisplayLocale: noop,
54
+ setContentLanguage: noop,
55
+ setTheme: noop,
56
+ setDisplayUiExtensionPoints: noop,
57
+ setMainNavExpanded: noop,
58
+ setActiveChannelId: noop,
59
+ setDevMode: noop,
60
+ setHasSeenOnboarding: noop,
61
+ setTableSettings: () => undefined,
62
+ setWidgetLayout: noop,
63
+ } as UserSettingsContextType;
64
+
65
+ return renderToStaticMarkup(
66
+ <UserSettingsContext.Provider value={contextValue}>
67
+ <PageContext.Provider value={{ pageId: 'customer-list' }}>
68
+ <PageLayout>{children}</PageLayout>
69
+ </PageContext.Provider>
70
+ </UserSettingsContext.Provider>,
71
+ );
72
+ }
73
+
74
+ function getRenderedBlockIds(markup: string) {
75
+ return Array.from(markup.matchAll(/data-testid="(page-block-[^"]+)"/g)).map(match => match[1]);
76
+ }
77
+
78
+ describe('PageLayout', () => {
79
+ beforeEach(() => {
80
+ useMediaQueryMock.mockReset();
81
+ useCopyToClipboardMock.mockReset();
82
+ useCopyToClipboardMock.mockReturnValue([null, vi.fn()]);
83
+ const pageBlockRegistry = globalRegistry.get('dashboardPageBlockRegistry');
84
+ pageBlockRegistry.clear();
85
+ });
86
+
87
+ it('renders multiple before/after extension blocks in registration order', () => {
88
+ registerBlock('before-1', 'before');
89
+ registerBlock('before-2', 'before');
90
+ registerBlock('after-1', 'after');
91
+
92
+ const markup = renderPageLayout(
93
+ <PageBlock column="main" blockId="list-table">
94
+ <div data-testid="page-block-original">original</div>
95
+ </PageBlock>,
96
+ { isDesktop: true },
97
+ );
98
+
99
+ expect(getRenderedBlockIds(markup)).toEqual([
100
+ 'page-block-before-1',
101
+ 'page-block-before-2',
102
+ 'page-block-original',
103
+ 'page-block-after-1',
104
+ ]);
105
+ });
106
+
107
+ it('replaces original block when replacement extensions are registered', () => {
108
+ registerBlock('replacement-1', 'replace');
109
+ registerBlock('replacement-2', 'replace');
110
+
111
+ const markup = renderPageLayout(
112
+ <PageBlock column="main" blockId="list-table">
113
+ <div data-testid="page-block-original">original</div>
114
+ </PageBlock>,
115
+ { isDesktop: true },
116
+ );
117
+
118
+ expect(getRenderedBlockIds(markup)).toEqual(['page-block-replacement-1', 'page-block-replacement-2']);
119
+ });
120
+
121
+ it('renders extension blocks in mobile layout', () => {
122
+ registerBlock('before-mobile', 'before');
123
+ registerBlock('after-mobile', 'after');
124
+
125
+ const markup = renderPageLayout(
126
+ <PageBlock column="main" blockId="list-table">
127
+ <div data-testid="page-block-original">original</div>
128
+ </PageBlock>,
129
+ { isDesktop: false },
130
+ );
131
+
132
+ expect(getRenderedBlockIds(markup)).toEqual([
133
+ 'page-block-before-mobile',
134
+ 'page-block-original',
135
+ 'page-block-after-mobile',
136
+ ]);
137
+ });
138
+ });
@@ -234,34 +234,63 @@ export function PageLayout({ children, className }: Readonly<PageLayoutProps>) {
234
234
  const blockId =
235
235
  childBlock.props.blockId ??
236
236
  (isOfType(childBlock, CustomFieldsPageBlock) ? 'custom-fields' : undefined);
237
- const extensionBlock = extensionBlocks.find(block => block.location.position.blockId === blockId);
238
237
 
239
- if (extensionBlock) {
240
- let extensionBlockShouldRender = true;
241
- if (typeof extensionBlock?.shouldRender === 'function') {
242
- extensionBlockShouldRender = extensionBlock.shouldRender(page);
238
+ // Get all extension blocks with the same position blockId
239
+ const matchingExtensionBlocks = extensionBlocks.filter(
240
+ block => block.location.position.blockId === blockId,
241
+ );
242
+
243
+ // sort the blocks to make sure we have the correct order
244
+ const arrangedExtensionBlocks = matchingExtensionBlocks.sort((a, b) => {
245
+ const orderPriority = { before: 1, replace: 2, after: 3 };
246
+ return orderPriority[a.location.position.order] - orderPriority[b.location.position.order];
247
+ });
248
+
249
+ const replacementBlockExists = arrangedExtensionBlocks.some(
250
+ block => block.location.position.order === 'replace',
251
+ );
252
+
253
+ let childBlockInserted = false;
254
+ if (matchingExtensionBlocks.length > 0) {
255
+ for (const extensionBlock of arrangedExtensionBlocks) {
256
+ let extensionBlockShouldRender = true;
257
+ if (typeof extensionBlock?.shouldRender === 'function') {
258
+ extensionBlockShouldRender = extensionBlock.shouldRender(page);
259
+ }
260
+
261
+ // Insert child block before the first non-"before" block
262
+ if (
263
+ !childBlockInserted &&
264
+ !replacementBlockExists &&
265
+ extensionBlock.location.position.order !== 'before'
266
+ ) {
267
+ finalChildArray.push(childBlock);
268
+ childBlockInserted = true;
269
+ }
270
+
271
+ const isFullWidth = extensionBlock.location.column === 'full';
272
+ const BlockComponent = isFullWidth ? FullWidthPageBlock : PageBlock;
273
+
274
+ const ExtensionBlock =
275
+ extensionBlock.component && extensionBlockShouldRender ? (
276
+ <BlockComponent
277
+ key={extensionBlock.id}
278
+ column={extensionBlock.location.column}
279
+ blockId={extensionBlock.id}
280
+ title={extensionBlock.title}
281
+ >
282
+ {<extensionBlock.component context={page} />}
283
+ </BlockComponent>
284
+ ) : undefined;
285
+
286
+ if (extensionBlockShouldRender && ExtensionBlock) {
287
+ finalChildArray.push(ExtensionBlock);
288
+ }
243
289
  }
244
- const ExtensionBlock =
245
- extensionBlock.component && extensionBlockShouldRender ? (
246
- <PageBlock
247
- key={childBlock.key}
248
- column={extensionBlock.location.column}
249
- blockId={extensionBlock.id}
250
- title={extensionBlock.title}
251
- >
252
- {<extensionBlock.component context={page} />}
253
- </PageBlock>
254
- ) : undefined;
255
- if (extensionBlock.location.position.order === 'before') {
256
- finalChildArray.push(...[ExtensionBlock, childBlock].filter(x => !!x));
257
- } else if (extensionBlock.location.position.order === 'after') {
258
- finalChildArray.push(...[childBlock, ExtensionBlock].filter(x => !!x));
259
- } else if (
260
- extensionBlock.location.position.order === 'replace' &&
261
- extensionBlockShouldRender &&
262
- ExtensionBlock
263
- ) {
264
- finalChildArray.push(ExtensionBlock);
290
+
291
+ // If all blocks were "before", insert child block at the end
292
+ if (!childBlockInserted && !replacementBlockExists) {
293
+ finalChildArray.push(childBlock);
265
294
  }
266
295
  } else {
267
296
  finalChildArray.push(childBlock);
@@ -286,7 +315,7 @@ export function PageLayout({ children, className }: Readonly<PageLayoutProps>) {
286
315
  <div className="@3xl/layout:col-span-1 space-y-4">{sideBlocks}</div>
287
316
  </div>
288
317
  ) : (
289
- <div className="space-y-4">{children}</div>
318
+ <div className="space-y-4">{finalChildArray}</div>
290
319
  )}
291
320
  </div>
292
321
  );