diff --git a/.changeset/short-planets-prove.md b/.changeset/short-planets-prove.md
new file mode 100644
index 00000000000..3bac8a2c38f
--- /dev/null
+++ b/.changeset/short-planets-prove.md
@@ -0,0 +1,6 @@
+---
+"@sap-ux/fe-fpm-writer": minor
+"@sap-ux-private/ui-prompting-examples": minor
+---
+
+FEAT: Add full template support for the Page building block with all 7 aggregations and controller stub generation
diff --git a/examples/ui-prompting-examples/src/Page.story.tsx b/examples/ui-prompting-examples/src/Page.story.tsx
index 84805b7eaf8..3b697a3c7d5 100644
--- a/examples/ui-prompting-examples/src/Page.story.tsx
+++ b/examples/ui-prompting-examples/src/Page.story.tsx
@@ -5,7 +5,37 @@ import { BuildingBlockQuestions } from './BuildingBlock.js';
export default { title: 'Building Blocks/Page' };
export const Default = (): JSX.Element => {
- return ;
+ return (
+
+ );
+};
+
+export const FullTemplate = (): JSX.Element => {
+ return (
+
+ );
};
export const ExternalValues = (): JSX.Element => {
diff --git a/packages/fe-fpm-writer/src/building-block/index.ts b/packages/fe-fpm-writer/src/building-block/index.ts
index 32cf1cacb09..642ae9f89fa 100644
--- a/packages/fe-fpm-writer/src/building-block/index.ts
+++ b/packages/fe-fpm-writer/src/building-block/index.ts
@@ -8,12 +8,17 @@ import format from 'xml-formatter';
import * as xpath from 'xpath';
import type { Editor } from 'mem-fs-editor';
-import { getMinimumUI5Version } from '@sap-ux/project-access';
+import { getMinimumUI5Version, getAppProgrammingLanguage } from '@sap-ux/project-access';
import {
BuildingBlockType,
+ PAGE_AGGREGATIONS,
+ PAGE_TEMPLATE_TYPE_FULL,
type BuildingBlock,
type BuildingBlockConfig,
type BuildingBlockMetaPath,
+ type Page,
+ type XmlAggregationGroup,
+ type GenerateBuildingBlockAggregationConfig,
bindingContextAbsolute,
type TemplateConfig
} from './types.js';
@@ -23,10 +28,12 @@ import { getTemplatePath } from '../templates.js';
import { CodeSnippetLanguage, type FilePathProps, type CodeSnippet } from '../prompts/types.js';
import {
CONFIG,
+ copyTpl,
createIdGenerator,
detectTabSpacing,
extendJSON,
getRelativeTemplateComponentPath,
+ type IdGeneratorFunction,
type TemplateContext
} from '../common/file.js';
import { getManifest, getManifestPath } from '../common/utils.js';
@@ -40,11 +47,23 @@ const PLACEHOLDERS = {
'qualifier': 'REPLACE_WITH_A_QUALIFIER'
};
+const PAGE_TEMPLATE_COMMENT = 'This is a sample template, event handlers should be added for implementation';
+
interface MetadataPath {
contextPath?: string;
metaPath: string;
}
+/**
+ * Returns true if the building block data represents a Page building block with the full template type.
+ *
+ * @param data - the building block data
+ * @returns true if full Page template
+ */
+function isFullPageTemplate(data: BuildingBlock): boolean {
+ return data.buildingBlockType === BuildingBlockType.Page && (data as Page).templateType === PAGE_TEMPLATE_TYPE_FULL;
+}
+
/**
* Generates a building block into the provided xml view file.
*
@@ -94,6 +113,13 @@ export async function generateBuildingBlock(
templateConfig
);
+ const fullPageTemplate = isFullPageTemplate(buildingBlockData);
+
+ if (fullPageTemplate) {
+ const pageData = buildingBlockData as Page;
+ appendPageAggregations(fs, xmlDocument, templateDocument, fnGenerateId, pageData);
+ }
+
if (
buildingBlockData.buildingBlockType === BuildingBlockType.RichTextEditor ||
buildingBlockData.buildingBlockType === BuildingBlockType.RichTextEditorButtonGroups
@@ -116,6 +142,10 @@ export async function generateBuildingBlock(
config.replace
);
+ if (fullPageTemplate) {
+ await applyPageControllerTemplate(fs, basePath, viewOrFragmentPath);
+ }
+
if (allowAutoAddDependencyLib && manifest && !validateDependenciesLibs(manifest, ['sap.fe.macros'])) {
// "sap.fe.macros" is missing - enhance manifest.json for missing "sap.fe.macros"
const manifestPath = await getManifestPath(basePath, fs);
@@ -132,6 +162,287 @@ export async function generateBuildingBlock(
return fs;
}
+/**
+ * Resolves the sap.fe.macros namespace prefix from the view document.
+ * If sap.fe.macros is the default namespace (no prefix), declares xmlns:macros on the document element
+ * so that generated prefixed elements like remain valid.
+ *
+ * @param xmlDocument - the view XML document
+ * @returns the resolved namespace prefix string (e.g. 'macros')
+ */
+function resolveMacrosPrefix(xmlDocument: Document): string {
+ const prefix = getOrAddNamespace(xmlDocument, 'sap.fe.macros', 'macros');
+ if (prefix === '') {
+ xmlDocument.documentElement.setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:macros', 'sap.fe.macros');
+ return 'macros';
+ }
+ return prefix;
+}
+
+/**
+ * Renders a Page aggregation EJS template and parses it as an XML fragment document.
+ * Inherits all xmlns:* declarations from the view root so inner content can use any view-declared prefix.
+ *
+ * @param fs - the memfs editor instance
+ * @param aggName - the aggregation name (e.g. 'footer', 'items')
+ * @param aggContext - the EJS template context
+ * @param aggContext.macrosPrefix - the namespace prefix string (e.g. 'macros:')
+ * @param aggContext.mContent - optional inner XML content for the aggregation
+ * @param aggContext.aggId - the generated unique ID for the aggregation element
+ * @param fragMacrosNS - the namespace prefix resolved for sap.fe.macros
+ * @param xmlDocument - the view XML document (used to inherit namespace declarations)
+ * @returns parsed XML document whose documentElement contains the aggregation child nodes
+ */
+function buildPageAggregationFragment(
+ fs: Editor,
+ aggName: string,
+ aggContext: { macrosPrefix: string; mContent: string; aggId: string },
+ fragMacrosNS: string,
+ xmlDocument: Document
+): Document {
+ const aggPath = getTemplatePath(`/building-block/page/${aggName}.xml`);
+ const aggContent = render(fs.read(aggPath), aggContext, {}); // NOSONAR - template is a controlled file on disk, not user input
+ const extraNamespaces = Array.from(xmlDocument.documentElement.attributes)
+ .filter((a) => a.name.startsWith('xmlns:') && a.name !== `xmlns:${fragMacrosNS}` && a.name !== 'xmlns:m')
+ .map((a) => `${a.name}="${a.value}"`)
+ .join(' ');
+ const wrapped = `${aggContent}`;
+ const errorHandler = (level: string, message: string): never => {
+ throw new Error(`Unable to parse page aggregation fragment '${aggName}'. Details: [${level}] - ${message}`);
+ };
+ return new DOMParser({ errorHandler }).parseFromString(wrapped, 'text/xml');
+}
+
+/**
+ * Appends the 7 Page building block aggregation fragments as child elements of the templateDocument root.
+ *
+ * @param {Editor} fs - the memfs editor instance
+ * @param {Document} xmlDocument - the view XML document (used to resolve namespace prefixes)
+ * @param {Document} templateDocument - the template document whose root element receives the children
+ * @param {IdGeneratorFunction} generateId - function to generate unique IDs
+ * @param {Page} pageData - the Page building block data containing optional aggregation mContent
+ */
+function appendPageAggregations(
+ fs: Editor,
+ xmlDocument: Document,
+ templateDocument: Document,
+ generateId: IdGeneratorFunction,
+ pageData: Page
+): void {
+ const fragMacrosNS = resolveMacrosPrefix(xmlDocument);
+ const macrosPrefix = `${fragMacrosNS}:`;
+ const pageElement = templateDocument.documentElement;
+ pageElement.appendChild(templateDocument.createComment(PAGE_TEMPLATE_COMMENT));
+ for (const aggName of PAGE_AGGREGATIONS) {
+ const mContent = pageData.aggregations?.[aggName] ?? '';
+ const aggId = generateId(aggName);
+ const aggContext = { macrosPrefix, mContent, aggId };
+ const aggDoc = buildPageAggregationFragment(fs, aggName, aggContext, fragMacrosNS, xmlDocument);
+ for (const node of Array.from(aggDoc.documentElement.childNodes)) {
+ if (node.nodeType === 1 /* Element */) {
+ (node as Element).setAttribute('id', aggId);
+ pageElement.appendChild(templateDocument.importNode(node, true));
+ }
+ }
+ }
+}
+
+/**
+ * Returns the local name of an Element if it belongs to the sap.fe.macros namespace, otherwise an empty string.
+ * This ensures only Page aggregation elements are sorted by position; non-macros elements fall back to the items slot.
+ *
+ * @param el - the DOM Element
+ * @returns the local name string, or '' if not a sap.fe.macros element
+ */
+function getElementLocalName(el: Element): string {
+ if (el.namespaceURI !== 'sap.fe.macros') {
+ return '';
+ }
+ return typeof el.localName === 'string' ? el.localName : '';
+}
+
+/**
+ * Builds a comparator for sorting XmlAggregationGroups by their position in aggNames.
+ * Unknown elements fall back to the position of 'items'. Ties are broken by original index.
+ *
+ * @param aggNames - ordered list of aggregation names
+ * @returns comparator function for Array.prototype.sort
+ */
+function buildAggregationComparator(
+ aggNames: readonly string[]
+): (a: XmlAggregationGroup, b: XmlAggregationGroup) => number {
+ const itemsIdx = aggNames.indexOf('items');
+ const fallbackIdx = itemsIdx === -1 ? aggNames.length : itemsIdx;
+ return (a, b) => {
+ const aIdx = aggNames.indexOf(getElementLocalName(a.element));
+ const bIdx = aggNames.indexOf(getElementLocalName(b.element));
+ const aOrder = aIdx === -1 ? fallbackIdx : aIdx;
+ const bOrder = bIdx === -1 ? fallbackIdx : bIdx;
+ return aOrder === bOrder ? a.originalIndex - b.originalIndex : aOrder - bOrder;
+ };
+}
+
+/**
+ * Reorders the child elements of a macros:Page node to match the canonical PAGE_AGGREGATIONS order.
+ * Preserves relative order of siblings with the same local name. Pure whitespace text nodes are dropped
+ * because the xml-formatter call that follows will regenerate proper indentation.
+ *
+ * @param pageElement - the macros:Page DOM node whose children should be sorted
+ */
+function sortPageAggregationChildren(pageElement: Node): void {
+ const allChildren = Array.from(pageElement.childNodes);
+ const aggNames = PAGE_AGGREGATIONS as readonly string[];
+
+ // Build pairs of [preceding comments, element] to preserve user comments.
+ // Comments that appear before the first element are treated as leading and will remain before all aggregation elements.
+ const groups: XmlAggregationGroup[] = [];
+ const leadingComments: Node[] = [];
+ let pendingComments: Node[] = [];
+ let firstElementSeen = false;
+
+ for (const node of allChildren) {
+ if (node.nodeType === 8 /* Comment */) {
+ // Comments before the first element are leading; after, they are pending
+ (firstElementSeen ? pendingComments : leadingComments).push(node);
+ } else if (node.nodeType === 1 /* Element */) {
+ firstElementSeen = true;
+ groups.push({ comments: pendingComments, element: node as Element, originalIndex: groups.length });
+ pendingComments = [];
+ } else if (node.nodeType === 3 /* Text */ && (node as Text).data?.trim()) {
+ // Preserve non-whitespace text nodes with their surrounding group
+ pendingComments.push(node);
+ }
+ // Pure whitespace text nodes are intentionally dropped (xml-formatter regenerates indentation)
+ }
+
+ groups.sort(buildAggregationComparator(aggNames));
+
+ while (pageElement.firstChild) {
+ pageElement.removeChild(pageElement.firstChild); // NOSONAR - xmldom nodes do not implement Node.remove()
+ }
+
+ // Re-insert leading comments first (always before any element)
+ for (const comment of leadingComments) {
+ pageElement.appendChild(comment);
+ }
+
+ for (const { comments, element } of groups) {
+ for (const comment of comments) {
+ pageElement.appendChild(comment);
+ }
+ pageElement.appendChild(element);
+ }
+
+ // Trailing orphan comments (after the last element)
+ for (const comment of pendingComments) {
+ pageElement.appendChild(comment);
+ }
+}
+
+/**
+ * Appends a single Page building block aggregation template to an existing `` element in a view XML file.
+ *
+ * @param {string} basePath - the base path of the application
+ * @param {GenerateBuildingBlockAggregationConfig} config - the aggregation configuration containing aggregationName and mContent
+ * @param {Editor} [fs] - the memfs editor instance
+ * @returns {Editor} the updated memfs editor instance
+ */
+export async function generateBuildingBlockAggregation(
+ basePath: string,
+ config: GenerateBuildingBlockAggregationConfig,
+ fs?: Editor
+): Promise {
+ const { viewPath, buildingBlockType, aggregationName: aggName, mContent = '' } = config;
+ fs ??= create(createStorage());
+ if (buildingBlockType !== BuildingBlockType.Page) {
+ throw new Error(
+ `generateBuildingBlockAggregation: unsupported building block type '${buildingBlockType}'. Only 'Page' is currently supported.`
+ );
+ }
+ const xmlDocument = getUI5XmlDocument(basePath, viewPath, fs);
+
+ const generateId = await createIdGenerator({ basePath, fsEditor: fs });
+ const aggId = generateId(aggName);
+
+ const fragMacrosNS = resolveMacrosPrefix(xmlDocument);
+ const macrosPrefix = `${fragMacrosNS}:`;
+ const aggContext = { macrosPrefix, mContent, aggId };
+ const aggDoc = buildPageAggregationFragment(fs, aggName, aggContext, fragMacrosNS, xmlDocument);
+
+ const nsMap = (xmlDocument.documentElement as any)?._nsMap ?? {};
+ // Prefix-agnostic XPath — works regardless of the alias used in the view for sap.fe.macros.
+ const xpathSelect = xpath.useNamespaces(nsMap);
+ const pageNodes = xpathSelect(`//*[local-name()='Page' and namespace-uri()='sap.fe.macros']`, xmlDocument);
+ if (!pageNodes || !Array.isArray(pageNodes) || pageNodes.length === 0) {
+ throw new Error(`Page element (sap.fe.macros) not found in view ${viewPath}.`);
+ }
+
+ const pageElement = pageNodes[0] as Node;
+ if (aggName === 'footer' && pageElement.nodeType === 1 /* Element */) {
+ (pageElement as Element).setAttribute('showFooter', 'true');
+ }
+ const childNodes = Array.from(pageElement.childNodes);
+ const hasExistingAggregation = childNodes.some(
+ (node) =>
+ node.nodeType === 1 /* Element */ &&
+ (node as Element).localName === aggName &&
+ (node as Element).namespaceURI === 'sap.fe.macros'
+ );
+ if (hasExistingAggregation) {
+ sortPageAggregationChildren(pageElement);
+ const existingXmlContent = new XMLSerializer().serializeToString(xmlDocument);
+ fs.write(join(basePath, viewPath), format(existingXmlContent));
+ return fs;
+ }
+
+ const hasExistingElementChildren = childNodes.some((n) => n.nodeType === 1 /* Element */);
+ const hasTemplateComment = childNodes.some(
+ (n) => n.nodeType === 8 /* Comment */ && (n as Comment).data?.includes(PAGE_TEMPLATE_COMMENT)
+ );
+ if (!hasExistingElementChildren && !hasTemplateComment) {
+ pageElement.appendChild(xmlDocument.createComment(PAGE_TEMPLATE_COMMENT));
+ }
+ for (const node of Array.from(aggDoc.documentElement.childNodes)) {
+ if (node.nodeType === 1 /* Element */) {
+ (node as Element).setAttribute('id', aggId);
+ pageElement.appendChild(xmlDocument.importNode(node, true));
+ }
+ }
+ sortPageAggregationChildren(pageElement);
+
+ const newXmlContent = new XMLSerializer().serializeToString(xmlDocument);
+ fs.write(join(basePath, viewPath), format(newXmlContent));
+
+ return fs;
+}
+
+/**
+ * Copies the Page controller template (JS or TS) into the view directory if no controller file exists yet.
+ * Uses getAppProgrammingLanguage to decide whether to generate a JS or TS controller stub.
+ *
+ * @param {Editor} fs - the memfs editor instance
+ * @param {string} basePath - the base path of the application
+ * @param {string} viewOrFragmentPath - the relative path of the view/fragment file
+ */
+async function applyPageControllerTemplate(fs: Editor, basePath: string, viewOrFragmentPath: string): Promise {
+ if (!viewOrFragmentPath.endsWith('.view.xml')) {
+ return;
+ }
+ const { dir: viewDir, name: viewName } = parse(viewOrFragmentPath);
+ const viewBaseName = viewName.replace(/\.view$/, '');
+ const tsControllerPath = join(basePath, viewDir, `${viewBaseName}.controller.ts`);
+ const jsControllerPath = join(basePath, viewDir, `${viewBaseName}.controller.js`);
+ // Skip if a controller already exists in either language to avoid duplicate stubs
+ if (fs.exists(tsControllerPath) || fs.exists(jsControllerPath)) {
+ return;
+ }
+ const detectedLanguage = await getAppProgrammingLanguage(basePath, fs);
+ const isTypeScript = detectedLanguage === 'TypeScript';
+ const controllerExt = isTypeScript ? 'ts' : 'js';
+ const controllerPath = isTypeScript ? tsControllerPath : jsControllerPath;
+ copyTpl(fs, getTemplatePath(`/building-block/page/Controller.${controllerExt}`), controllerPath);
+}
+
/**
* Returns the UI5 xml file document (view/fragment).
*
@@ -371,7 +682,12 @@ function updateViewFile(
fs: Editor,
replace: boolean = false
): Editor {
- const xpathSelect = xpath.useNamespaces((viewDocument.firstChild as any)._nsMap);
+ const firstChild = viewDocument.firstChild;
+ if (!firstChild) {
+ throw new Error(`Unable to read namespace map from view ${viewPath}.`);
+ }
+ const nsMap = (firstChild as any)?._nsMap ?? {};
+ const xpathSelect = xpath.useNamespaces(nsMap);
// Find target aggregated element and append template as child
const targetNodes = xpathSelect(aggregationPath, viewDocument);
@@ -433,11 +749,49 @@ export async function getSerializedFileContent(
// Read the view xml and template files and get content of the view xml file
const xmlDocument = viewOrFragmentPath ? getUI5XmlDocument(basePath, viewOrFragmentPath, fs) : undefined;
const { content: manifest, path: manifestPath } = await getManifest(basePath, fs, false);
- const content = getTemplateContent(buildingBlockData, xmlDocument, manifest, fs, true);
+ const fnGenerateId = buildingBlockData.generateId ?? (await createIdGenerator({ basePath, fsEditor: fs }));
+ const content = getTemplateContent(
+ { ...buildingBlockData, generateId: fnGenerateId },
+ xmlDocument,
+ manifest,
+ fs,
+ true
+ );
+
+ // For the full Page template, augment the snippet with all 7 aggregations
+ let viewOrFragmentContent = content;
+ const pageData = buildingBlockData as Page;
+ const isFullPage = isFullPageTemplate(buildingBlockData);
+ if (isFullPage) {
+ // Use the real view document for namespace resolution if available, otherwise create a minimal fallback
+ const nsDoc =
+ xmlDocument ??
+ new DOMParser().parseFromString(
+ '',
+ 'text/xml'
+ );
+ // Parse content directly so documentElement IS the element,
+ // matching what appendPageAggregations expects as templateDocument.documentElement.
+ const snippetErrorHandler = (level: string, message: string): never => {
+ throw new Error(`Unable to parse Page building block snippet. Details: [${level}] - ${message}`);
+ };
+ const snippetMacrosNS = getOrAddNamespace(nsDoc, 'sap.fe.macros', 'macros') || 'macros';
+ const snippetContent = `${content}`.replace(
+ new RegExp(`^<(${snippetMacrosNS}:Page)`),
+ `<$1 xmlns:${snippetMacrosNS}="sap.fe.macros"`
+ );
+ const snippetDoc = new DOMParser({ errorHandler: snippetErrorHandler }).parseFromString(
+ snippetContent,
+ 'text/xml'
+ );
+ appendPageAggregations(fs, nsDoc, snippetDoc, fnGenerateId, pageData);
+ const resultNode = snippetDoc.documentElement;
+ viewOrFragmentContent = resultNode ? format(new XMLSerializer().serializeToString(resultNode)) : content;
+ }
const filePathProps = getFilePathProps(basePath, viewOrFragmentPath);
// Snippet for fragment xml
snippets['viewOrFragmentPath'] = {
- content,
+ content: viewOrFragmentContent,
language: CodeSnippetLanguage.XML,
filePathProps
};
diff --git a/packages/fe-fpm-writer/src/building-block/prompts/questions/page.ts b/packages/fe-fpm-writer/src/building-block/prompts/questions/page.ts
index d08ce592254..b5a9fa1b106 100644
--- a/packages/fe-fpm-writer/src/building-block/prompts/questions/page.ts
+++ b/packages/fe-fpm-writer/src/building-block/prompts/questions/page.ts
@@ -2,7 +2,7 @@ import type { Answers } from 'inquirer';
import { i18nNamespaces, translate } from '../../../i18n.js';
import { getBuildingBlockIdPrompt, getViewOrFragmentPathPrompt, getAggregationPathPrompt } from '../utils/index.js';
import type { PromptContext, Prompts } from '../../../prompts/types.js';
-import { BuildingBlockType } from '../../types.js';
+import { BuildingBlockType, PAGE_TEMPLATE_TYPE_BASIC, PAGE_TEMPLATE_TYPE_FULL } from '../../types.js';
import type { BuildingBlockConfig, Page } from '../../types.js';
import { SapShortTextType, SapLongTextType } from '@sap-ux/i18n';
@@ -19,6 +19,17 @@ export async function getPageBuildingBlockPrompts(context: PromptContext): Promi
return {
questions: [
+ {
+ type: 'list',
+ name: 'buildingBlockData.templateType',
+ message: t('templateType.message') as string,
+ default: PAGE_TEMPLATE_TYPE_BASIC,
+ choices: [
+ { value: PAGE_TEMPLATE_TYPE_BASIC, name: t('templateType.basic') as string },
+ { value: PAGE_TEMPLATE_TYPE_FULL, name: t('templateType.full') as string }
+ ],
+ guiOptions: { mandatory: true }
+ },
getViewOrFragmentPathPrompt(context, t('viewOrFragmentPath.validate') as string, {
message: t('viewOrFragmentPath.message') as string,
guiOptions: {
diff --git a/packages/fe-fpm-writer/src/building-block/types.ts b/packages/fe-fpm-writer/src/building-block/types.ts
index aad8987cd9c..3973c93bf5d 100644
--- a/packages/fe-fpm-writer/src/building-block/types.ts
+++ b/packages/fe-fpm-writer/src/building-block/types.ts
@@ -387,6 +387,18 @@ export interface Table extends BuildingBlock {
*
* @extends {BuildingBlock}
*/
+export const PAGE_AGGREGATIONS = [
+ 'breadcrumbs',
+ 'navigationActions',
+ 'titleContent',
+ 'actions',
+ 'headerContent',
+ 'items',
+ 'footer'
+] as const;
+
+export type PageAggregationName = (typeof PAGE_AGGREGATIONS)[number];
+
export interface Page extends BuildingBlock {
/**
* The title of the page.
@@ -397,6 +409,43 @@ export interface Page extends BuildingBlock {
* The description of the page.
*/
description?: string;
+
+ /**
+ * The template type for the page building block.
+ * 'full' generates a full page template with all aggregations and controller stubs.
+ * 'basic' generates a minimal self-closing tag (default behavior).
+ */
+ templateType?: PageTemplateType;
+
+ /**
+ * Optional mContent strings keyed by aggregation name.
+ * When templateType is 'full', each entry is written as the inner XML of the corresponding aggregation.
+ */
+ aggregations?: Partial>;
+}
+
+export const PAGE_TEMPLATE_TYPE_FULL = 'full' as const;
+export const PAGE_TEMPLATE_TYPE_BASIC = 'basic' as const;
+export type PageTemplateType = typeof PAGE_TEMPLATE_TYPE_FULL | typeof PAGE_TEMPLATE_TYPE_BASIC;
+
+/**
+ * A group of XML nodes representing one Page aggregation element and its preceding sibling comments.
+ * Used when re-ordering aggregation children under a macros:Page element.
+ */
+export type XmlAggregationGroup = { comments: Node[]; element: Element; originalIndex: number };
+
+/**
+ * Configuration for appending a named aggregation to an existing building block element in a view XML file.
+ */
+export interface GenerateBuildingBlockAggregationConfig {
+ /** Path to the view XML file, relative to basePath. */
+ viewPath: string;
+ /** Type of the building block whose aggregation should be appended. Currently only 'Page' is supported. */
+ buildingBlockType: BuildingBlockType;
+ /** Name of the aggregation to append. */
+ aggregationName: PageAggregationName;
+ /** Optional inner XML content for the aggregation. */
+ mContent?: string;
}
/**
diff --git a/packages/fe-fpm-writer/src/index.ts b/packages/fe-fpm-writer/src/index.ts
index fb63e66ebf3..a0de68157b1 100644
--- a/packages/fe-fpm-writer/src/index.ts
+++ b/packages/fe-fpm-writer/src/index.ts
@@ -43,9 +43,16 @@ export type {
CustomFormField,
RichTextEditor,
ButtonGroupConfig,
- Action
+ Action,
+ PageTemplateType
} from './building-block/types.js';
-export { generateBuildingBlock, getSerializedFileContent } from './building-block/index.js';
+export { PAGE_TEMPLATE_TYPE_FULL, PAGE_TEMPLATE_TYPE_BASIC, PAGE_AGGREGATIONS } from './building-block/types.js';
+export type { PageAggregationName, GenerateBuildingBlockAggregationConfig } from './building-block/types.js';
+export {
+ generateBuildingBlock,
+ getSerializedFileContent,
+ generateBuildingBlockAggregation
+} from './building-block/index.js';
export type {
ChartPromptsAnswer,
FilterBarPromptsAnswer,
diff --git a/packages/fe-fpm-writer/src/prompts/translations/i18n.ts b/packages/fe-fpm-writer/src/prompts/translations/i18n.ts
index cd6cd89c815..1f74e0a9e9f 100644
--- a/packages/fe-fpm-writer/src/prompts/translations/i18n.ts
+++ b/packages/fe-fpm-writer/src/prompts/translations/i18n.ts
@@ -280,6 +280,11 @@ const ns1 = {
'Adding button groups replaces the default button groups in the Rich Text Editor with your chosen configuration.'
},
'page': {
+ 'templateType': {
+ 'message': 'Page Layout',
+ 'basic': 'Basic',
+ 'full': 'Full'
+ },
'id': {
'message': 'Building Block ID',
'validation': 'An ID is required to generate the page building block.'
diff --git a/packages/fe-fpm-writer/templates/building-block/page/Controller.js b/packages/fe-fpm-writer/templates/building-block/page/Controller.js
new file mode 100644
index 00000000000..4bc38a0dbce
--- /dev/null
+++ b/packages/fe-fpm-writer/templates/building-block/page/Controller.js
@@ -0,0 +1,17 @@
+sap.ui.define([], function () {
+ 'use strict';
+
+ return {
+ onPressHome: function (_event) {},
+
+ onPressPage1: function (_event) {},
+
+ onPressPage2: function (_event) {},
+
+ onFullScreen: function (_event) {},
+
+ onClickAction1: function (_event) {},
+
+ onClickAction2: function (_event) {}
+ };
+});
diff --git a/packages/fe-fpm-writer/templates/building-block/page/Controller.ts b/packages/fe-fpm-writer/templates/building-block/page/Controller.ts
new file mode 100644
index 00000000000..498f0c2cf4a
--- /dev/null
+++ b/packages/fe-fpm-writer/templates/building-block/page/Controller.ts
@@ -0,0 +1,14 @@
+import ExtensionAPI from 'sap/fe/core/ExtensionAPI';
+import Event from 'sap/ui/base/Event';
+
+export function onPressHome(this: ExtensionAPI, _event: Event): void {}
+
+export function onPressPage1(this: ExtensionAPI, _event: Event): void {}
+
+export function onPressPage2(this: ExtensionAPI, _event: Event): void {}
+
+export function onFullScreen(this: ExtensionAPI, _event: Event): void {}
+
+export function onClickAction1(this: ExtensionAPI, _event: Event): void {}
+
+export function onClickAction2(this: ExtensionAPI, _event: Event): void {}
diff --git a/packages/fe-fpm-writer/templates/building-block/page/View.xml b/packages/fe-fpm-writer/templates/building-block/page/View.xml
index 0dbd3f1339a..e9ebfd16fd1 100644
--- a/packages/fe-fpm-writer/templates/building-block/page/View.xml
+++ b/packages/fe-fpm-writer/templates/building-block/page/View.xml
@@ -1,5 +1,6 @@
<<%- macrosNamespace %>:Page
id="<%- data.id %>"<% if (data.title) { %>
title="<%- data.title %>"<% } %><% if (data.description) { %>
- description="<%- data.description %>"<% } %>
+ description="<%- data.description %>"<% } %><% if (data.templateType === 'full') { %>
+ showFooter="true"<% } %>
/>
\ No newline at end of file
diff --git a/packages/fe-fpm-writer/templates/building-block/page/actions.xml b/packages/fe-fpm-writer/templates/building-block/page/actions.xml
new file mode 100644
index 00000000000..c4a35ba2727
--- /dev/null
+++ b/packages/fe-fpm-writer/templates/building-block/page/actions.xml
@@ -0,0 +1,4 @@
+<<%- macrosPrefix %>actions>
+
+ <%- mContent %>
+<%- macrosPrefix %>actions>
diff --git a/packages/fe-fpm-writer/templates/building-block/page/breadcrumbs.xml b/packages/fe-fpm-writer/templates/building-block/page/breadcrumbs.xml
new file mode 100644
index 00000000000..84daa8b3e09
--- /dev/null
+++ b/packages/fe-fpm-writer/templates/building-block/page/breadcrumbs.xml
@@ -0,0 +1,4 @@
+<<%- macrosPrefix %>breadcrumbs>
+
+ <%- mContent %>
+<%- macrosPrefix %>breadcrumbs>
diff --git a/packages/fe-fpm-writer/templates/building-block/page/footer.xml b/packages/fe-fpm-writer/templates/building-block/page/footer.xml
new file mode 100644
index 00000000000..4e1063d6213
--- /dev/null
+++ b/packages/fe-fpm-writer/templates/building-block/page/footer.xml
@@ -0,0 +1,4 @@
+<<%- macrosPrefix %>footer>
+
+ <%- mContent %>
+<%- macrosPrefix %>footer>
diff --git a/packages/fe-fpm-writer/templates/building-block/page/headerContent.xml b/packages/fe-fpm-writer/templates/building-block/page/headerContent.xml
new file mode 100644
index 00000000000..c74f353119d
--- /dev/null
+++ b/packages/fe-fpm-writer/templates/building-block/page/headerContent.xml
@@ -0,0 +1,4 @@
+<<%- macrosPrefix %>headerContent>
+
+ <%- mContent %>
+<%- macrosPrefix %>headerContent>
diff --git a/packages/fe-fpm-writer/templates/building-block/page/items.xml b/packages/fe-fpm-writer/templates/building-block/page/items.xml
new file mode 100644
index 00000000000..f254f137bd3
--- /dev/null
+++ b/packages/fe-fpm-writer/templates/building-block/page/items.xml
@@ -0,0 +1,9 @@
+<<%- macrosPrefix %>items>
+
+
+
+
+
+
+
+<%- macrosPrefix %>items>
diff --git a/packages/fe-fpm-writer/templates/building-block/page/navigationActions.xml b/packages/fe-fpm-writer/templates/building-block/page/navigationActions.xml
new file mode 100644
index 00000000000..519cad9b13a
--- /dev/null
+++ b/packages/fe-fpm-writer/templates/building-block/page/navigationActions.xml
@@ -0,0 +1,4 @@
+<<%- macrosPrefix %>navigationActions>
+
+ <%- mContent %>
+<%- macrosPrefix %>navigationActions>
diff --git a/packages/fe-fpm-writer/templates/building-block/page/titleContent.xml b/packages/fe-fpm-writer/templates/building-block/page/titleContent.xml
new file mode 100644
index 00000000000..db1da1ea76f
--- /dev/null
+++ b/packages/fe-fpm-writer/templates/building-block/page/titleContent.xml
@@ -0,0 +1,4 @@
+<<%- macrosPrefix %>titleContent>
+
+ <%- mContent %>
+<%- macrosPrefix %>titleContent>
diff --git a/packages/fe-fpm-writer/test/unit/__snapshots__/building-block.test.ts.snap b/packages/fe-fpm-writer/test/unit/__snapshots__/building-block.test.ts.snap
index 602e6054276..8b2aeecc92d 100644
--- a/packages/fe-fpm-writer/test/unit/__snapshots__/building-block.test.ts.snap
+++ b/packages/fe-fpm-writer/test/unit/__snapshots__/building-block.test.ts.snap
@@ -1872,6 +1872,49 @@ exports[`Building Blocks RichTextEditorButtonGroups building block multiple Rich
"
`;
+exports[`Building Blocks generate Page building block with full template inserts all 7 aggregations: generate-page-block-full 1`] = `
+"
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+"
+`;
+
exports[`Building Blocks generate Page building block with replace target locator set: generate-page-block 1`] = `
"
diff --git a/packages/fe-fpm-writer/test/unit/building-block.test.ts b/packages/fe-fpm-writer/test/unit/building-block.test.ts
index cfed7f3b795..fae252701ef 100644
--- a/packages/fe-fpm-writer/test/unit/building-block.test.ts
+++ b/packages/fe-fpm-writer/test/unit/building-block.test.ts
@@ -17,7 +17,12 @@ import type {
Action
} from '../../src/index.js';
-import { BuildingBlockType, generateBuildingBlock, getSerializedFileContent } from '../../src/index.js';
+import {
+ BuildingBlockType,
+ generateBuildingBlock,
+ getSerializedFileContent,
+ generateBuildingBlockAggregation
+} from '../../src/index.js';
import { BUILDING_BLOCK_CONFIG } from '../../src/building-block/processor.js';
import testManifestContent from './sample/building-block/webapp/manifest.json';
import { clearTestOutput, writeFilesForDebugging } from '../common/index.js';
@@ -1094,6 +1099,130 @@ describe('Building Blocks', () => {
await writeFilesForDebugging(fs);
});
+ test('generate Page building block with full template inserts all 7 aggregations', async () => {
+ const aggregationPath = `/mvc:View/*[local-name()='Page']`;
+ const basePath = join(testAppPath, 'generate-page-block-full');
+ fs.write(join(basePath, manifestFilePath), JSON.stringify(testManifestContent));
+ fs.write(join(basePath, xmlViewFilePath), testXmlViewContent);
+
+ await generateBuildingBlock(
+ basePath,
+ {
+ viewOrFragmentPath: xmlViewFilePath,
+ aggregationPath,
+ buildingBlockData: {
+ id: 'testPage',
+ buildingBlockType: BuildingBlockType.Page,
+ title: 'Test Page',
+ templateType: 'full',
+ generateId,
+ aggregations: {
+ breadcrumbs:
+ '\n \n \n \n',
+ navigationActions:
+ '',
+ actions:
+ '\n '
+ }
+ },
+ replace: true
+ },
+ fs
+ );
+
+ expect(fs.read(join(basePath, xmlViewFilePath))).toMatchSnapshot('generate-page-block-full');
+ });
+
+ test('generate Page building block with full template creates JS controller', async () => {
+ const aggregationPath = `/mvc:View/*[local-name()='Page']`;
+ const basePath = join(testAppPath, 'generate-page-block-full-controller');
+ fs.write(join(basePath, manifestFilePath), JSON.stringify(testManifestContent));
+ fs.write(join(basePath, xmlViewFilePath), testXmlViewContent);
+
+ await generateBuildingBlock(
+ basePath,
+ {
+ viewOrFragmentPath: xmlViewFilePath,
+ aggregationPath,
+ buildingBlockData: {
+ id: 'testPage',
+ buildingBlockType: BuildingBlockType.Page,
+ templateType: 'full',
+ generateId
+ },
+ replace: true
+ },
+ fs
+ );
+
+ const controllerPath = join(basePath, 'webapp/ext/main/Main.controller.js');
+ expect(fs.exists(controllerPath)).toBe(true);
+ const controllerContent = fs.read(controllerPath);
+ expect(controllerContent).toContain('onPressHome');
+ expect(controllerContent).toContain('onPressPage1');
+ expect(controllerContent).toContain('onPressPage2');
+ expect(controllerContent).toContain('onFullScreen');
+ expect(controllerContent).toContain('onClickAction1');
+ expect(controllerContent).toContain('onClickAction2');
+ });
+
+ test('generate Page building block with full template creates TS controller when .controller.ts exists', async () => {
+ const aggregationPath = `/mvc:View/*[local-name()='Page']`;
+ const basePath = join(testAppPath, 'generate-page-block-full-ts-controller');
+ fs.write(join(basePath, manifestFilePath), JSON.stringify(testManifestContent));
+ fs.write(join(basePath, xmlViewFilePath), testXmlViewContent);
+ fs.write(join(basePath, 'webapp/ext/main/Main.controller.ts'), '// existing ts controller');
+
+ await generateBuildingBlock(
+ basePath,
+ {
+ viewOrFragmentPath: xmlViewFilePath,
+ aggregationPath,
+ buildingBlockData: {
+ id: 'testPage',
+ buildingBlockType: BuildingBlockType.Page,
+ templateType: 'full',
+ generateId
+ },
+ replace: true
+ },
+ fs
+ );
+
+ expect(fs.read(join(basePath, 'webapp/ext/main/Main.controller.ts'))).toBe('// existing ts controller');
+ expect(fs.exists(join(basePath, 'webapp/ext/main/Main.controller.js'))).toBe(false);
+ });
+
+ test('generate Page building block with basic template does not insert aggregations or controller', async () => {
+ const aggregationPath = `/mvc:View/*[local-name()='Page']`;
+ const basePath = join(testAppPath, 'generate-page-block-blank');
+ fs.write(join(basePath, manifestFilePath), JSON.stringify(testManifestContent));
+ fs.write(join(basePath, xmlViewFilePath), testXmlViewContent);
+
+ await generateBuildingBlock(
+ basePath,
+ {
+ viewOrFragmentPath: xmlViewFilePath,
+ aggregationPath,
+ buildingBlockData: {
+ id: 'testPage',
+ buildingBlockType: BuildingBlockType.Page,
+ templateType: 'basic',
+ generateId
+ },
+ replace: true
+ },
+ fs
+ );
+
+ const viewContent = fs.read(join(basePath, xmlViewFilePath));
+ expect(viewContent).not.toContain('showFooter');
+ expect(viewContent).not.toContain('macros:breadcrumbs');
+ expect(viewContent).not.toContain('macros:footer');
+ expect(fs.exists(join(basePath, 'webapp/ext/main/Main.controller.js'))).toBe(false);
+ expect(fs.exists(join(basePath, 'webapp/ext/main/Main.controller.ts'))).toBe(false);
+ });
+
test('throws error if aggregationPath not found', async () => {
const aggregationPath = `/mvc:Test`;
const basePath = join(testAppPath, 'generate-page-block-error');
@@ -3711,4 +3840,170 @@ describe('Building Blocks', () => {
await writeFilesForDebugging(fs);
});
});
+
+ describe('generateBuildingBlockAggregation', () => {
+ const pageViewContent = `
+
+
+`;
+
+ it('appends aggregation with unique id attribute on wrapper element', async () => {
+ const basePath = join(testAppPath, 'page-bb-agg');
+ fs.write(join(basePath, xmlViewFilePath), pageViewContent);
+
+ const result = await generateBuildingBlockAggregation(
+ basePath,
+ {
+ viewPath: xmlViewFilePath,
+ buildingBlockType: BuildingBlockType.Page,
+ aggregationName: 'footer',
+ mContent: ''
+ },
+ fs
+ );
+
+ const output = result.read(join(basePath, xmlViewFilePath));
+ expect(output).toContain('id="footer"');
+ });
+
+ it('does not append duplicate aggregation when it already exists in view', async () => {
+ const basePath = join(testAppPath, 'page-bb-agg-dup');
+ const viewWithExistingId = `
+
+
+
+`;
+ fs.write(join(basePath, xmlViewFilePath), viewWithExistingId);
+ findFilesByExtensionMock.mockResolvedValue([join(basePath, xmlViewFilePath)]);
+
+ const result = await generateBuildingBlockAggregation(
+ basePath,
+ {
+ viewPath: xmlViewFilePath,
+ buildingBlockType: BuildingBlockType.Page,
+ aggregationName: 'footer',
+ mContent: ''
+ },
+ fs
+ );
+
+ const output = result.read(join(basePath, xmlViewFilePath));
+ expect((output.match(/ {
+ // Start with aggregations in wrong order: footer, actions, navigationActions
+ const basePath = join(testAppPath, 'page-bb-agg-sort');
+ const viewOutOfOrder = `
+
+
+
+
+`;
+ fs.write(join(basePath, xmlViewFilePath), viewOutOfOrder);
+
+ // Adding navigationActions (index 1) should trigger a full sort
+ const result = await generateBuildingBlockAggregation(
+ basePath,
+ {
+ viewPath: xmlViewFilePath,
+ buildingBlockType: BuildingBlockType.Page,
+ aggregationName: 'navigationActions',
+ mContent: ''
+ },
+ fs
+ );
+
+ const output = result.read(join(basePath, xmlViewFilePath));
+ const navPos = output.indexOf('macros:navigationActions');
+ const actPos = output.indexOf('macros:actions');
+ const footPos = output.indexOf('macros:footer');
+ expect(navPos).toBeLessThan(actPos);
+ expect(actPos).toBeLessThan(footPos);
+ });
+
+ it('adds template comment as first child when Page has no existing aggregations', async () => {
+ const basePath = join(testAppPath, 'page-bb-agg-comment');
+ fs.write(join(basePath, xmlViewFilePath), pageViewContent);
+
+ const result = await generateBuildingBlockAggregation(
+ basePath,
+ {
+ viewPath: xmlViewFilePath,
+ buildingBlockType: BuildingBlockType.Page,
+ aggregationName: 'items',
+ mContent: ''
+ },
+ fs
+ );
+
+ const output = result.read(join(basePath, xmlViewFilePath));
+ expect(output).toContain('This is a sample template, event handlers should be added for implementation');
+ // Comment should appear before the aggregation element
+ const commentPos = output.indexOf('This is a sample template');
+ const itemsPos = output.indexOf('macros:items');
+ expect(commentPos).toBeLessThan(itemsPos);
+ });
+
+ it('does not add template comment when Page already has aggregation children', async () => {
+ const basePath = join(testAppPath, 'page-bb-agg-no-comment');
+ const viewWithExisting = `
+
+
+
+`;
+ fs.write(join(basePath, xmlViewFilePath), viewWithExisting);
+
+ const result = await generateBuildingBlockAggregation(
+ basePath,
+ {
+ viewPath: xmlViewFilePath,
+ buildingBlockType: BuildingBlockType.Page,
+ aggregationName: 'items',
+ mContent: ''
+ },
+ fs
+ );
+
+ const output = result.read(join(basePath, xmlViewFilePath));
+ // Comment should not be added again if children already exist
+ expect(output.split('This is a sample template').length - 1).toBeLessThanOrEqual(1);
+ });
+
+ it('preserves template comment when reordering aggregations', async () => {
+ const basePath = join(testAppPath, 'page-bb-agg-sort-comment');
+ const viewWithComment = `
+
+
+
+
+
+`;
+ fs.write(join(basePath, xmlViewFilePath), viewWithComment);
+
+ const result = await generateBuildingBlockAggregation(
+ basePath,
+ {
+ viewPath: xmlViewFilePath,
+ buildingBlockType: BuildingBlockType.Page,
+ aggregationName: 'navigationActions',
+ mContent: ''
+ },
+ fs
+ );
+
+ const output = result.read(join(basePath, xmlViewFilePath));
+ expect(output).toContain('This is a sample template, event handlers should be added for implementation');
+ // Comment should remain before all aggregation elements
+ const commentPos = output.indexOf('This is a sample template');
+ const navPos = output.indexOf('macros:navigationActions');
+ expect(commentPos).toBeLessThan(navPos);
+ });
+ });
});
diff --git a/packages/fe-fpm-writer/test/unit/prompts/__snapshots__/prompts.test.ts.snap b/packages/fe-fpm-writer/test/unit/prompts/__snapshots__/prompts.test.ts.snap
index 83ff082e277..8a63cda477b 100644
--- a/packages/fe-fpm-writer/test/unit/prompts/__snapshots__/prompts.test.ts.snap
+++ b/packages/fe-fpm-writer/test/unit/prompts/__snapshots__/prompts.test.ts.snap
@@ -427,6 +427,25 @@ Object {
},
},
"questions": Array [
+ Object {
+ "choices": Array [
+ Object {
+ "name": "Basic",
+ "value": "basic",
+ },
+ Object {
+ "name": "Full",
+ "value": "full",
+ },
+ ],
+ "default": "basic",
+ "guiOptions": Object {
+ "mandatory": true,
+ },
+ "message": "Page Layout",
+ "name": "buildingBlockData.templateType",
+ "type": "list",
+ },
Object {
"choices": Array [],
"guiOptions": Object {
@@ -1808,6 +1827,25 @@ Object {
},
},
"questions": Array [
+ Object {
+ "choices": Array [
+ Object {
+ "name": "Basic",
+ "value": "basic",
+ },
+ Object {
+ "name": "Full",
+ "value": "full",
+ },
+ ],
+ "default": "basic",
+ "guiOptions": Object {
+ "mandatory": true,
+ },
+ "message": "Page Layout",
+ "name": "buildingBlockData.templateType",
+ "type": "list",
+ },
Object {
"choices": [Function],
"guiOptions": Object {