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 %> +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 %> +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 %> +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 %> +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> + + + + + + + +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 %> +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 %> +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 = ` + + +