@sap-ux/fe-fpm-writer 1.2.0 → 1.2.1
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.
- package/dist/building-block/index.d.ts +2 -10
- package/dist/building-block/index.js +28 -284
- package/dist/building-block/processAggregation.d.ts +81 -0
- package/dist/building-block/processAggregation.js +408 -0
- package/dist/building-block/processor.d.ts +10 -0
- package/dist/building-block/processor.js +22 -3
- package/dist/building-block/prompts/questions/page.js +24 -13
- package/dist/building-block/prompts/utils/xml.js +26 -1
- package/dist/building-block/types.d.ts +5 -4
- package/dist/building-block/types.js +3 -0
- package/dist/prompts/translations/i18n.d.ts +1 -0
- package/dist/prompts/translations/i18n.js +2 -1
- package/package.json +3 -3
- package/templates/building-block/page/actions.xml +2 -1
- package/templates/building-block/page/breadcrumbs.xml +5 -1
- package/templates/building-block/page/footer.xml +5 -1
- package/templates/building-block/page/headerContent.xml +3 -1
- package/templates/building-block/page/items.xml +2 -0
- package/templates/building-block/page/navigationActions.xml +1 -1
- package/templates/building-block/page/titleContent.xml +1 -1
- package/templates/building-block/page/Controller.js +0 -17
- package/templates/building-block/page/Controller.ts +0 -14
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Editor } from 'mem-fs-editor';
|
|
2
|
-
import { type BuildingBlock, type BuildingBlockConfig
|
|
2
|
+
import { type BuildingBlock, type BuildingBlockConfig } from './types.js';
|
|
3
3
|
import { type CodeSnippet } from '../prompts/types.js';
|
|
4
|
+
export { generateBuildingBlockAggregation, getUI5XmlDocument } from './processAggregation.js';
|
|
4
5
|
/**
|
|
5
6
|
* Generates a building block into the provided xml view file.
|
|
6
7
|
*
|
|
@@ -10,15 +11,6 @@ import { type CodeSnippet } from '../prompts/types.js';
|
|
|
10
11
|
* @returns {Editor} the updated memfs editor instance
|
|
11
12
|
*/
|
|
12
13
|
export declare function generateBuildingBlock<T extends BuildingBlock>(basePath: string, config: BuildingBlockConfig<T>, fs?: Editor): Promise<Editor>;
|
|
13
|
-
/**
|
|
14
|
-
* Appends a single Page building block aggregation template to an existing `<macros:Page>` element in a view XML file.
|
|
15
|
-
*
|
|
16
|
-
* @param {string} basePath - the base path of the application
|
|
17
|
-
* @param {GenerateBuildingBlockAggregationConfig} config - the aggregation configuration containing aggregationName and mContent
|
|
18
|
-
* @param {Editor} [fs] - the memfs editor instance
|
|
19
|
-
* @returns {Editor} the updated memfs editor instance
|
|
20
|
-
*/
|
|
21
|
-
export declare function generateBuildingBlockAggregation(basePath: string, config: GenerateBuildingBlockAggregationConfig, fs?: Editor): Promise<Editor>;
|
|
22
14
|
/**
|
|
23
15
|
* Method returns the manifest content for the required dependency library.
|
|
24
16
|
*
|
|
@@ -6,22 +6,23 @@ import { join, parse, relative } from 'node:path';
|
|
|
6
6
|
import { DOMParser, XMLSerializer } from '@xmldom/xmldom';
|
|
7
7
|
import format from 'xml-formatter';
|
|
8
8
|
import * as xpath from 'xpath';
|
|
9
|
-
import { getMinimumUI5Version
|
|
10
|
-
import { BuildingBlockType,
|
|
9
|
+
import { getMinimumUI5Version } from '@sap-ux/project-access';
|
|
10
|
+
import { BuildingBlockType, PAGE_TEMPLATE_TYPE_FULL, bindingContextAbsolute } from './types.js';
|
|
11
11
|
import { getErrorMessage, validateBasePath, validateDependenciesLibs } from '../common/validate.js';
|
|
12
12
|
import { getTemplatePath } from '../templates.js';
|
|
13
13
|
import { CodeSnippetLanguage } from '../prompts/types.js';
|
|
14
|
-
import { CONFIG,
|
|
14
|
+
import { CONFIG, createIdGenerator, detectTabSpacing, extendJSON, getRelativeTemplateComponentPath } from '../common/file.js';
|
|
15
15
|
import { getManifest, getManifestPath } from '../common/utils.js';
|
|
16
16
|
import { getOrAddNamespace } from './prompts/utils/xml.js';
|
|
17
17
|
import { i18nNamespaces, translate } from '../i18n.js';
|
|
18
|
-
import { processBuildingBlock } from './processor.js';
|
|
18
|
+
import { processBuildingBlock, resolveAggregationPath } from './processor.js';
|
|
19
|
+
import { appendPageAggregations, ensureMissingAggregation, getPageAggregationNames, getUI5XmlDocument, validateFullPageTemplateVersion } from './processAggregation.js';
|
|
20
|
+
export { generateBuildingBlockAggregation, getUI5XmlDocument } from './processAggregation.js';
|
|
19
21
|
const PLACEHOLDERS = {
|
|
20
22
|
'id': 'REPLACE_WITH_BUILDING_BLOCK_ID',
|
|
21
23
|
'entitySet': 'REPLACE_WITH_ENTITY',
|
|
22
24
|
'qualifier': 'REPLACE_WITH_A_QUALIFIER'
|
|
23
25
|
};
|
|
24
|
-
const PAGE_TEMPLATE_COMMENT = 'This is a sample template, event handlers should be added for implementation';
|
|
25
26
|
/**
|
|
26
27
|
* Returns true if the building block data represents a Page building block with the full template type.
|
|
27
28
|
*
|
|
@@ -51,6 +52,9 @@ export async function generateBuildingBlock(basePath, config, fs) {
|
|
|
51
52
|
const { path: manifestPath, content: manifest } = await getManifest(basePath, fs);
|
|
52
53
|
// Read the view xml and template files and update contents of the view xml file
|
|
53
54
|
const xmlDocument = getUI5XmlDocument(basePath, viewOrFragmentPath, fs);
|
|
55
|
+
if (aggregationPath) {
|
|
56
|
+
ensureMissingAggregation(xmlDocument, aggregationPath);
|
|
57
|
+
}
|
|
54
58
|
const { updatedAggregationPath, processedBuildingBlockData, hasAggregation, aggregationNamespace } = processBuildingBlock({ ...buildingBlockData, generateId: fnGenerateId }, xmlDocument, manifestPath, manifest, aggregationPath, fs);
|
|
55
59
|
const templateConfig = {
|
|
56
60
|
hasAggregation,
|
|
@@ -58,9 +62,12 @@ export async function generateBuildingBlock(basePath, config, fs) {
|
|
|
58
62
|
};
|
|
59
63
|
const templateDocument = getTemplateDocument({ ...processedBuildingBlockData, generateId: fnGenerateId }, xmlDocument, fs, manifest, templateConfig);
|
|
60
64
|
const fullPageTemplate = isFullPageTemplate(buildingBlockData);
|
|
65
|
+
const pageAggregationNames = getPageAggregationNames(buildingBlockData);
|
|
61
66
|
if (fullPageTemplate) {
|
|
62
|
-
|
|
63
|
-
|
|
67
|
+
validateFullPageTemplateVersion(manifest);
|
|
68
|
+
}
|
|
69
|
+
if (pageAggregationNames) {
|
|
70
|
+
appendPageAggregations(fs, xmlDocument, templateDocument, fnGenerateId, pageAggregationNames, fullPageTemplate);
|
|
64
71
|
}
|
|
65
72
|
if (buildingBlockData.buildingBlockType === BuildingBlockType.RichTextEditor ||
|
|
66
73
|
buildingBlockData.buildingBlockType === BuildingBlockType.RichTextEditorButtonGroups) {
|
|
@@ -72,9 +79,6 @@ export async function generateBuildingBlock(basePath, config, fs) {
|
|
|
72
79
|
getOrAddNamespace(xmlDocument, 'sap.fe.macros.richtexteditor', 'richtexteditor');
|
|
73
80
|
}
|
|
74
81
|
fs = updateViewFile(basePath, viewOrFragmentPath, updatedAggregationPath, xmlDocument, templateDocument, fs, config.replace);
|
|
75
|
-
if (fullPageTemplate) {
|
|
76
|
-
await applyPageControllerTemplate(fs, basePath, viewOrFragmentPath);
|
|
77
|
-
}
|
|
78
82
|
if (allowAutoAddDependencyLib && manifest && !validateDependenciesLibs(manifest, ['sap.fe.macros'])) {
|
|
79
83
|
// "sap.fe.macros" is missing - enhance manifest.json for missing "sap.fe.macros"
|
|
80
84
|
const manifestPath = await getManifestPath(basePath, fs);
|
|
@@ -89,271 +93,6 @@ export async function generateBuildingBlock(basePath, config, fs) {
|
|
|
89
93
|
}
|
|
90
94
|
return fs;
|
|
91
95
|
}
|
|
92
|
-
/**
|
|
93
|
-
* Resolves the sap.fe.macros namespace prefix from the view document.
|
|
94
|
-
* If sap.fe.macros is the default namespace (no prefix), declares xmlns:macros on the document element
|
|
95
|
-
* so that generated prefixed elements like <macros:items> remain valid.
|
|
96
|
-
*
|
|
97
|
-
* @param xmlDocument - the view XML document
|
|
98
|
-
* @returns the resolved namespace prefix string (e.g. 'macros')
|
|
99
|
-
*/
|
|
100
|
-
function resolveMacrosPrefix(xmlDocument) {
|
|
101
|
-
const prefix = getOrAddNamespace(xmlDocument, 'sap.fe.macros', 'macros');
|
|
102
|
-
if (prefix === '') {
|
|
103
|
-
xmlDocument.documentElement.setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:macros', 'sap.fe.macros');
|
|
104
|
-
return 'macros';
|
|
105
|
-
}
|
|
106
|
-
return prefix;
|
|
107
|
-
}
|
|
108
|
-
/**
|
|
109
|
-
* Renders a Page aggregation EJS template and parses it as an XML fragment document.
|
|
110
|
-
* Inherits all xmlns:* declarations from the view root so inner content can use any view-declared prefix.
|
|
111
|
-
*
|
|
112
|
-
* @param fs - the memfs editor instance
|
|
113
|
-
* @param aggName - the aggregation name (e.g. 'footer', 'items')
|
|
114
|
-
* @param aggContext - the EJS template context
|
|
115
|
-
* @param aggContext.macrosPrefix - the namespace prefix string (e.g. 'macros:')
|
|
116
|
-
* @param aggContext.mContent - optional inner XML content for the aggregation
|
|
117
|
-
* @param aggContext.aggId - the generated unique ID for the aggregation element
|
|
118
|
-
* @param fragMacrosNS - the namespace prefix resolved for sap.fe.macros
|
|
119
|
-
* @param xmlDocument - the view XML document (used to inherit namespace declarations)
|
|
120
|
-
* @returns parsed XML document whose documentElement contains the aggregation child nodes
|
|
121
|
-
*/
|
|
122
|
-
function buildPageAggregationFragment(fs, aggName, aggContext, fragMacrosNS, xmlDocument) {
|
|
123
|
-
const aggPath = getTemplatePath(`/building-block/page/${aggName}.xml`);
|
|
124
|
-
const aggContent = render(fs.read(aggPath), aggContext, {}); // NOSONAR - template is a controlled file on disk, not user input
|
|
125
|
-
const extraNamespaces = Array.from(xmlDocument.documentElement.attributes)
|
|
126
|
-
.filter((a) => a.name.startsWith('xmlns:') && a.name !== `xmlns:${fragMacrosNS}` && a.name !== 'xmlns:m')
|
|
127
|
-
.map((a) => `${a.name}="${a.value}"`)
|
|
128
|
-
.join(' ');
|
|
129
|
-
const wrapped = `<root xmlns:${fragMacrosNS}="sap.fe.macros" xmlns="sap.m" xmlns:m="sap.m" ${extraNamespaces}>${aggContent}</root>`;
|
|
130
|
-
const errorHandler = (level, message) => {
|
|
131
|
-
throw new Error(`Unable to parse page aggregation fragment '${aggName}'. Details: [${level}] - ${message}`);
|
|
132
|
-
};
|
|
133
|
-
return new DOMParser({ errorHandler }).parseFromString(wrapped, 'text/xml');
|
|
134
|
-
}
|
|
135
|
-
/**
|
|
136
|
-
* Appends the 7 Page building block aggregation fragments as child elements of the templateDocument root.
|
|
137
|
-
*
|
|
138
|
-
* @param {Editor} fs - the memfs editor instance
|
|
139
|
-
* @param {Document} xmlDocument - the view XML document (used to resolve namespace prefixes)
|
|
140
|
-
* @param {Document} templateDocument - the template document whose root element receives the children
|
|
141
|
-
* @param {IdGeneratorFunction} generateId - function to generate unique IDs
|
|
142
|
-
* @param {Page} pageData - the Page building block data containing optional aggregation mContent
|
|
143
|
-
*/
|
|
144
|
-
function appendPageAggregations(fs, xmlDocument, templateDocument, generateId, pageData) {
|
|
145
|
-
const fragMacrosNS = resolveMacrosPrefix(xmlDocument);
|
|
146
|
-
const macrosPrefix = `${fragMacrosNS}:`;
|
|
147
|
-
const pageElement = templateDocument.documentElement;
|
|
148
|
-
pageElement.appendChild(templateDocument.createComment(PAGE_TEMPLATE_COMMENT));
|
|
149
|
-
for (const aggName of PAGE_AGGREGATIONS) {
|
|
150
|
-
const mContent = pageData.aggregations?.[aggName] ?? '';
|
|
151
|
-
const aggId = generateId(aggName);
|
|
152
|
-
const aggContext = { macrosPrefix, mContent, aggId };
|
|
153
|
-
const aggDoc = buildPageAggregationFragment(fs, aggName, aggContext, fragMacrosNS, xmlDocument);
|
|
154
|
-
for (const node of Array.from(aggDoc.documentElement.childNodes)) {
|
|
155
|
-
if (node.nodeType === 1 /* Element */) {
|
|
156
|
-
node.setAttribute('id', aggId);
|
|
157
|
-
pageElement.appendChild(templateDocument.importNode(node, true));
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
/**
|
|
163
|
-
* Returns the local name of an Element if it belongs to the sap.fe.macros namespace, otherwise an empty string.
|
|
164
|
-
* This ensures only Page aggregation elements are sorted by position; non-macros elements fall back to the items slot.
|
|
165
|
-
*
|
|
166
|
-
* @param el - the DOM Element
|
|
167
|
-
* @returns the local name string, or '' if not a sap.fe.macros element
|
|
168
|
-
*/
|
|
169
|
-
function getElementLocalName(el) {
|
|
170
|
-
if (el.namespaceURI !== 'sap.fe.macros') {
|
|
171
|
-
return '';
|
|
172
|
-
}
|
|
173
|
-
return typeof el.localName === 'string' ? el.localName : '';
|
|
174
|
-
}
|
|
175
|
-
/**
|
|
176
|
-
* Builds a comparator for sorting XmlAggregationGroups by their position in aggNames.
|
|
177
|
-
* Unknown elements fall back to the position of 'items'. Ties are broken by original index.
|
|
178
|
-
*
|
|
179
|
-
* @param aggNames - ordered list of aggregation names
|
|
180
|
-
* @returns comparator function for Array.prototype.sort
|
|
181
|
-
*/
|
|
182
|
-
function buildAggregationComparator(aggNames) {
|
|
183
|
-
const itemsIdx = aggNames.indexOf('items');
|
|
184
|
-
const fallbackIdx = itemsIdx === -1 ? aggNames.length : itemsIdx;
|
|
185
|
-
return (a, b) => {
|
|
186
|
-
const aIdx = aggNames.indexOf(getElementLocalName(a.element));
|
|
187
|
-
const bIdx = aggNames.indexOf(getElementLocalName(b.element));
|
|
188
|
-
const aOrder = aIdx === -1 ? fallbackIdx : aIdx;
|
|
189
|
-
const bOrder = bIdx === -1 ? fallbackIdx : bIdx;
|
|
190
|
-
return aOrder === bOrder ? a.originalIndex - b.originalIndex : aOrder - bOrder;
|
|
191
|
-
};
|
|
192
|
-
}
|
|
193
|
-
/**
|
|
194
|
-
* Reorders the child elements of a macros:Page node to match the canonical PAGE_AGGREGATIONS order.
|
|
195
|
-
* Preserves relative order of siblings with the same local name. Pure whitespace text nodes are dropped
|
|
196
|
-
* because the xml-formatter call that follows will regenerate proper indentation.
|
|
197
|
-
*
|
|
198
|
-
* @param pageElement - the macros:Page DOM node whose children should be sorted
|
|
199
|
-
*/
|
|
200
|
-
function sortPageAggregationChildren(pageElement) {
|
|
201
|
-
const allChildren = Array.from(pageElement.childNodes);
|
|
202
|
-
const aggNames = PAGE_AGGREGATIONS;
|
|
203
|
-
// Build pairs of [preceding comments, element] to preserve user comments.
|
|
204
|
-
// Comments that appear before the first element are treated as leading and will remain before all aggregation elements.
|
|
205
|
-
const groups = [];
|
|
206
|
-
const leadingComments = [];
|
|
207
|
-
let pendingComments = [];
|
|
208
|
-
let firstElementSeen = false;
|
|
209
|
-
for (const node of allChildren) {
|
|
210
|
-
if (node.nodeType === 8 /* Comment */) {
|
|
211
|
-
// Comments before the first element are leading; after, they are pending
|
|
212
|
-
(firstElementSeen ? pendingComments : leadingComments).push(node);
|
|
213
|
-
}
|
|
214
|
-
else if (node.nodeType === 1 /* Element */) {
|
|
215
|
-
firstElementSeen = true;
|
|
216
|
-
groups.push({ comments: pendingComments, element: node, originalIndex: groups.length });
|
|
217
|
-
pendingComments = [];
|
|
218
|
-
}
|
|
219
|
-
else if (node.nodeType === 3 /* Text */ && node.data?.trim()) {
|
|
220
|
-
// Preserve non-whitespace text nodes with their surrounding group
|
|
221
|
-
pendingComments.push(node);
|
|
222
|
-
}
|
|
223
|
-
// Pure whitespace text nodes are intentionally dropped (xml-formatter regenerates indentation)
|
|
224
|
-
}
|
|
225
|
-
groups.sort(buildAggregationComparator(aggNames));
|
|
226
|
-
while (pageElement.firstChild) {
|
|
227
|
-
pageElement.removeChild(pageElement.firstChild); // NOSONAR - xmldom nodes do not implement Node.remove()
|
|
228
|
-
}
|
|
229
|
-
// Re-insert leading comments first (always before any element)
|
|
230
|
-
for (const comment of leadingComments) {
|
|
231
|
-
pageElement.appendChild(comment);
|
|
232
|
-
}
|
|
233
|
-
for (const { comments, element } of groups) {
|
|
234
|
-
for (const comment of comments) {
|
|
235
|
-
pageElement.appendChild(comment);
|
|
236
|
-
}
|
|
237
|
-
pageElement.appendChild(element);
|
|
238
|
-
}
|
|
239
|
-
// Trailing orphan comments (after the last element)
|
|
240
|
-
for (const comment of pendingComments) {
|
|
241
|
-
pageElement.appendChild(comment);
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
/**
|
|
245
|
-
* Appends a single Page building block aggregation template to an existing `<macros:Page>` element in a view XML file.
|
|
246
|
-
*
|
|
247
|
-
* @param {string} basePath - the base path of the application
|
|
248
|
-
* @param {GenerateBuildingBlockAggregationConfig} config - the aggregation configuration containing aggregationName and mContent
|
|
249
|
-
* @param {Editor} [fs] - the memfs editor instance
|
|
250
|
-
* @returns {Editor} the updated memfs editor instance
|
|
251
|
-
*/
|
|
252
|
-
export async function generateBuildingBlockAggregation(basePath, config, fs) {
|
|
253
|
-
const { viewPath, buildingBlockType, aggregationName: aggName, mContent = '' } = config;
|
|
254
|
-
fs ??= create(createStorage());
|
|
255
|
-
if (buildingBlockType !== BuildingBlockType.Page) {
|
|
256
|
-
throw new Error(`generateBuildingBlockAggregation: unsupported building block type '${buildingBlockType}'. Only 'Page' is currently supported.`);
|
|
257
|
-
}
|
|
258
|
-
const xmlDocument = getUI5XmlDocument(basePath, viewPath, fs);
|
|
259
|
-
const generateId = await createIdGenerator({ basePath, fsEditor: fs });
|
|
260
|
-
const aggId = generateId(aggName);
|
|
261
|
-
const fragMacrosNS = resolveMacrosPrefix(xmlDocument);
|
|
262
|
-
const macrosPrefix = `${fragMacrosNS}:`;
|
|
263
|
-
const aggContext = { macrosPrefix, mContent, aggId };
|
|
264
|
-
const aggDoc = buildPageAggregationFragment(fs, aggName, aggContext, fragMacrosNS, xmlDocument);
|
|
265
|
-
const nsMap = xmlDocument.documentElement?._nsMap ?? {};
|
|
266
|
-
// Prefix-agnostic XPath — works regardless of the alias used in the view for sap.fe.macros.
|
|
267
|
-
const xpathSelect = xpath.useNamespaces(nsMap);
|
|
268
|
-
const pageNodes = xpathSelect(`//*[local-name()='Page' and namespace-uri()='sap.fe.macros']`, xmlDocument);
|
|
269
|
-
if (!pageNodes || !Array.isArray(pageNodes) || pageNodes.length === 0) {
|
|
270
|
-
throw new Error(`Page element (sap.fe.macros) not found in view ${viewPath}.`);
|
|
271
|
-
}
|
|
272
|
-
const pageElement = pageNodes[0];
|
|
273
|
-
if (aggName === 'footer' && pageElement.nodeType === 1 /* Element */) {
|
|
274
|
-
pageElement.setAttribute('showFooter', 'true');
|
|
275
|
-
}
|
|
276
|
-
const childNodes = Array.from(pageElement.childNodes);
|
|
277
|
-
const hasExistingAggregation = childNodes.some((node) => node.nodeType === 1 /* Element */ &&
|
|
278
|
-
node.localName === aggName &&
|
|
279
|
-
node.namespaceURI === 'sap.fe.macros');
|
|
280
|
-
if (hasExistingAggregation) {
|
|
281
|
-
sortPageAggregationChildren(pageElement);
|
|
282
|
-
const existingXmlContent = new XMLSerializer().serializeToString(xmlDocument);
|
|
283
|
-
fs.write(join(basePath, viewPath), format(existingXmlContent));
|
|
284
|
-
return fs;
|
|
285
|
-
}
|
|
286
|
-
const hasExistingElementChildren = childNodes.some((n) => n.nodeType === 1 /* Element */);
|
|
287
|
-
const hasTemplateComment = childNodes.some((n) => n.nodeType === 8 /* Comment */ && n.data?.includes(PAGE_TEMPLATE_COMMENT));
|
|
288
|
-
if (!hasExistingElementChildren && !hasTemplateComment) {
|
|
289
|
-
pageElement.appendChild(xmlDocument.createComment(PAGE_TEMPLATE_COMMENT));
|
|
290
|
-
}
|
|
291
|
-
for (const node of Array.from(aggDoc.documentElement.childNodes)) {
|
|
292
|
-
if (node.nodeType === 1 /* Element */) {
|
|
293
|
-
node.setAttribute('id', aggId);
|
|
294
|
-
pageElement.appendChild(xmlDocument.importNode(node, true));
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
sortPageAggregationChildren(pageElement);
|
|
298
|
-
const newXmlContent = new XMLSerializer().serializeToString(xmlDocument);
|
|
299
|
-
fs.write(join(basePath, viewPath), format(newXmlContent));
|
|
300
|
-
return fs;
|
|
301
|
-
}
|
|
302
|
-
/**
|
|
303
|
-
* Copies the Page controller template (JS or TS) into the view directory if no controller file exists yet.
|
|
304
|
-
* Uses getAppProgrammingLanguage to decide whether to generate a JS or TS controller stub.
|
|
305
|
-
*
|
|
306
|
-
* @param {Editor} fs - the memfs editor instance
|
|
307
|
-
* @param {string} basePath - the base path of the application
|
|
308
|
-
* @param {string} viewOrFragmentPath - the relative path of the view/fragment file
|
|
309
|
-
*/
|
|
310
|
-
async function applyPageControllerTemplate(fs, basePath, viewOrFragmentPath) {
|
|
311
|
-
if (!viewOrFragmentPath.endsWith('.view.xml')) {
|
|
312
|
-
return;
|
|
313
|
-
}
|
|
314
|
-
const { dir: viewDir, name: viewName } = parse(viewOrFragmentPath);
|
|
315
|
-
const viewBaseName = viewName.replace(/\.view$/, '');
|
|
316
|
-
const tsControllerPath = join(basePath, viewDir, `${viewBaseName}.controller.ts`);
|
|
317
|
-
const jsControllerPath = join(basePath, viewDir, `${viewBaseName}.controller.js`);
|
|
318
|
-
// Skip if a controller already exists in either language to avoid duplicate stubs
|
|
319
|
-
if (fs.exists(tsControllerPath) || fs.exists(jsControllerPath)) {
|
|
320
|
-
return;
|
|
321
|
-
}
|
|
322
|
-
const detectedLanguage = await getAppProgrammingLanguage(basePath, fs);
|
|
323
|
-
const isTypeScript = detectedLanguage === 'TypeScript';
|
|
324
|
-
const controllerExt = isTypeScript ? 'ts' : 'js';
|
|
325
|
-
const controllerPath = isTypeScript ? tsControllerPath : jsControllerPath;
|
|
326
|
-
copyTpl(fs, getTemplatePath(`/building-block/page/Controller.${controllerExt}`), controllerPath);
|
|
327
|
-
}
|
|
328
|
-
/**
|
|
329
|
-
* Returns the UI5 xml file document (view/fragment).
|
|
330
|
-
*
|
|
331
|
-
* @param {string} basePath - the base path
|
|
332
|
-
* @param {string} viewPath - the path of the xml view relative to the base path
|
|
333
|
-
* @param {Editor} fs - the memfs editor instance
|
|
334
|
-
* @returns {Document} the view xml file document
|
|
335
|
-
*/
|
|
336
|
-
function getUI5XmlDocument(basePath, viewPath, fs) {
|
|
337
|
-
let viewContent;
|
|
338
|
-
try {
|
|
339
|
-
viewContent = fs.read(join(basePath, viewPath));
|
|
340
|
-
}
|
|
341
|
-
catch (error) {
|
|
342
|
-
throw new Error(`Unable to read xml view file. Details: ${getErrorMessage(error)}`);
|
|
343
|
-
}
|
|
344
|
-
const errorHandler = (level, message) => {
|
|
345
|
-
throw new Error(`Unable to parse xml view file. Details: [${level}] - ${message}`);
|
|
346
|
-
};
|
|
347
|
-
// Parse the xml view content
|
|
348
|
-
let viewDocument;
|
|
349
|
-
try {
|
|
350
|
-
viewDocument = new DOMParser({ errorHandler }).parseFromString(viewContent, 'text/xml');
|
|
351
|
-
}
|
|
352
|
-
catch (error) {
|
|
353
|
-
throw new Error(`Unable to parse xml view file. Details: ${getErrorMessage(error)}`);
|
|
354
|
-
}
|
|
355
|
-
return viewDocument;
|
|
356
|
-
}
|
|
357
96
|
/**
|
|
358
97
|
* Method returns default values for metadata path.
|
|
359
98
|
*
|
|
@@ -513,14 +252,17 @@ function getTemplateDocument(buildingBlockData, viewDocument, fs, manifest, temp
|
|
|
513
252
|
* @returns {Editor} the updated memfs editor instance
|
|
514
253
|
*/
|
|
515
254
|
function updateViewFile(basePath, viewPath, aggregationPath, viewDocument, templateDocument, fs, replace = false) {
|
|
516
|
-
const
|
|
517
|
-
if (!
|
|
255
|
+
const root = viewDocument.documentElement ?? viewDocument.firstChild;
|
|
256
|
+
if (!root) {
|
|
518
257
|
throw new Error(`Unable to read namespace map from view ${viewPath}.`);
|
|
519
258
|
}
|
|
520
|
-
const nsMap =
|
|
259
|
+
const nsMap = root?._nsMap ?? {};
|
|
521
260
|
const xpathSelect = xpath.useNamespaces(nsMap);
|
|
261
|
+
// XPath 1.0 does not support default namespaces: unprefixed names match only no-namespace
|
|
262
|
+
// elements. Rewrite each unprefixed step to a *[local-name()='X'] predicate.
|
|
263
|
+
const resolvedPath = resolveAggregationPath(aggregationPath);
|
|
522
264
|
// Find target aggregated element and append template as child
|
|
523
|
-
const targetNodes = xpathSelect(
|
|
265
|
+
const targetNodes = xpathSelect(resolvedPath, viewDocument);
|
|
524
266
|
if (targetNodes && Array.isArray(targetNodes) && targetNodes.length > 0) {
|
|
525
267
|
const targetNode = targetNodes[0];
|
|
526
268
|
const sourceNode = viewDocument.importNode(templateDocument.documentElement, true);
|
|
@@ -577,11 +319,13 @@ export async function getSerializedFileContent(basePath, config, fs) {
|
|
|
577
319
|
const { content: manifest, path: manifestPath } = await getManifest(basePath, fs, false);
|
|
578
320
|
const fnGenerateId = buildingBlockData.generateId ?? (await createIdGenerator({ basePath, fsEditor: fs }));
|
|
579
321
|
const content = getTemplateContent({ ...buildingBlockData, generateId: fnGenerateId }, xmlDocument, manifest, fs, true);
|
|
580
|
-
// For
|
|
322
|
+
// For full Page templates, augment the snippet with all PAGE_AGGREGATIONS
|
|
581
323
|
let viewOrFragmentContent = content;
|
|
582
|
-
const
|
|
583
|
-
|
|
584
|
-
|
|
324
|
+
const pageAggNames = getPageAggregationNames(buildingBlockData);
|
|
325
|
+
if (pageAggNames) {
|
|
326
|
+
if (isFullPageTemplate(buildingBlockData)) {
|
|
327
|
+
validateFullPageTemplateVersion(manifest);
|
|
328
|
+
}
|
|
585
329
|
// Use the real view document for namespace resolution if available, otherwise create a minimal fallback
|
|
586
330
|
const nsDoc = xmlDocument ??
|
|
587
331
|
new DOMParser().parseFromString('<mvc:View xmlns:mvc="sap.ui.core.mvc" xmlns:macros="sap.fe.macros" xmlns="sap.m"/>', 'text/xml');
|
|
@@ -593,7 +337,7 @@ export async function getSerializedFileContent(basePath, config, fs) {
|
|
|
593
337
|
const snippetMacrosNS = getOrAddNamespace(nsDoc, 'sap.fe.macros', 'macros') || 'macros';
|
|
594
338
|
const snippetContent = `${content}`.replace(new RegExp(`^<(${snippetMacrosNS}:Page)`), `<$1 xmlns:${snippetMacrosNS}="sap.fe.macros"`);
|
|
595
339
|
const snippetDoc = new DOMParser({ errorHandler: snippetErrorHandler }).parseFromString(snippetContent, 'text/xml');
|
|
596
|
-
appendPageAggregations(fs, nsDoc, snippetDoc, fnGenerateId,
|
|
340
|
+
appendPageAggregations(fs, nsDoc, snippetDoc, fnGenerateId, pageAggNames, isFullPageTemplate(buildingBlockData));
|
|
597
341
|
const resultNode = snippetDoc.documentElement;
|
|
598
342
|
viewOrFragmentContent = resultNode ? format(new XMLSerializer().serializeToString(resultNode)) : content;
|
|
599
343
|
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { Editor } from 'mem-fs-editor';
|
|
2
|
+
import { type BuildingBlock, type PageAggregationName, type GenerateBuildingBlockAggregationConfig } from './types.js';
|
|
3
|
+
import type { Manifest } from '../common/types.js';
|
|
4
|
+
import { type IdGeneratorFunction } from '../common/file.js';
|
|
5
|
+
/**
|
|
6
|
+
* Returns the UI5 xml file document (view/fragment).
|
|
7
|
+
*
|
|
8
|
+
* @param {string} basePath - the base path
|
|
9
|
+
* @param {string} viewPath - the path of the xml view relative to the base path
|
|
10
|
+
* @param {Editor} fs - the memfs editor instance
|
|
11
|
+
* @returns {Document} the view xml file document
|
|
12
|
+
*/
|
|
13
|
+
export declare function getUI5XmlDocument(basePath: string, viewPath: string, fs: Editor): Document;
|
|
14
|
+
/**
|
|
15
|
+
* Returns the aggregation names to append for a Page building block, or undefined if not a Page.
|
|
16
|
+
* Full template appends all PAGE_AGGREGATIONS; basic template appends nothing.
|
|
17
|
+
*
|
|
18
|
+
* @param data - the building block data
|
|
19
|
+
* @returns aggregation names array, or undefined if not a Page building block
|
|
20
|
+
*/
|
|
21
|
+
export declare function getPageAggregationNames(data: BuildingBlock): readonly PageAggregationName[] | undefined;
|
|
22
|
+
/**
|
|
23
|
+
* Throws if the manifest's minUI5Version is set and does not meet the 1.145.0 requirement for the full Page template.
|
|
24
|
+
* If minUI5Version is not set, it is treated as latest and passes the check.
|
|
25
|
+
*
|
|
26
|
+
* @param manifest - the manifest content, or undefined if not available
|
|
27
|
+
*/
|
|
28
|
+
export declare function validateFullPageTemplateVersion(manifest: Manifest | undefined): void;
|
|
29
|
+
/** IDs needed per aggregation template, keyed by the variable name used in the EJS template. */
|
|
30
|
+
/** Controls to generate IDs for per aggregation. `key` is the EJS template variable name, `base` is passed to generateId. */
|
|
31
|
+
export declare const AGGREGATION_ID_KEYS: Partial<Record<PageAggregationName, {
|
|
32
|
+
key: string;
|
|
33
|
+
base: string;
|
|
34
|
+
}[]>>;
|
|
35
|
+
/**
|
|
36
|
+
* Generates a map of unique IDs for all controls in a given Page aggregation template.
|
|
37
|
+
* Keys match the `ids.*` variable names referenced in the EJS templates.
|
|
38
|
+
*
|
|
39
|
+
* @param aggName - the aggregation name
|
|
40
|
+
* @param generateId - the project-aware ID generator
|
|
41
|
+
* @returns an object mapping each template variable name to a unique ID string
|
|
42
|
+
*/
|
|
43
|
+
export declare function buildAggregationIds(aggName: PageAggregationName, generateId: IdGeneratorFunction): Record<string, string>;
|
|
44
|
+
/**
|
|
45
|
+
*
|
|
46
|
+
* @param {Editor} fs - the memfs editor instance
|
|
47
|
+
* @param {Document} xmlDocument - the view XML document (used to resolve namespace prefixes)
|
|
48
|
+
* @param {Document} templateDocument - the template document whose root element receives the children
|
|
49
|
+
* @param {IdGeneratorFunction} generateId - function to generate unique IDs
|
|
50
|
+
* @param {readonly PageAggregationName[]} [aggNames] - aggregation names to append; defaults to all PAGE_AGGREGATIONS
|
|
51
|
+
* @param useDefaults - when true, the items aggregation renders its default IconTabBar content
|
|
52
|
+
*/
|
|
53
|
+
export declare function appendPageAggregations(fs: Editor, xmlDocument: Document, templateDocument: Document, generateId: IdGeneratorFunction, aggNames?: readonly PageAggregationName[], useDefaults?: boolean): void;
|
|
54
|
+
/**
|
|
55
|
+
* Reorders the child elements of a macros:Page node to match the canonical PAGE_AGGREGATIONS order.
|
|
56
|
+
* Preserves relative order of siblings with the same local name. Pure whitespace text nodes are dropped
|
|
57
|
+
* because the xml-formatter call that follows will regenerate proper indentation.
|
|
58
|
+
*
|
|
59
|
+
* @param pageElement - the macros:Page DOM node whose children should be sorted
|
|
60
|
+
*/
|
|
61
|
+
export declare function sortPageAggregationChildren(pageElement: Node): void;
|
|
62
|
+
/**
|
|
63
|
+
* Ensures a namespaced aggregation element exists in the XML document at the given aggregation path.
|
|
64
|
+
* If the final segment (e.g. 'macros:items') is missing from the DOM, the parent node is located
|
|
65
|
+
* via the path prefix and the element is inserted in place. This allows generateBuildingBlock to
|
|
66
|
+
* handle missing aggregation containers in a single write pass, avoiding a separate commit.
|
|
67
|
+
*
|
|
68
|
+
* @param {Document} xmlDocument - The XML document to mutate
|
|
69
|
+
* @param {string} aggregationPath - Full XPath to the target aggregation (e.g. '/mvc:View/macros:Page/macros:items')
|
|
70
|
+
*/
|
|
71
|
+
export declare function ensureMissingAggregation(xmlDocument: Document, aggregationPath: string): void;
|
|
72
|
+
/**
|
|
73
|
+
* Appends a single Page building block aggregation template to an existing `<macros:Page>` element in a view XML file.
|
|
74
|
+
*
|
|
75
|
+
* @param {string} basePath - the base path of the application
|
|
76
|
+
* @param {GenerateBuildingBlockAggregationConfig} config - the aggregation configuration containing aggregationName
|
|
77
|
+
* @param {Editor} [fs] - the memfs editor instance
|
|
78
|
+
* @returns {Editor} the updated memfs editor instance
|
|
79
|
+
*/
|
|
80
|
+
export declare function generateBuildingBlockAggregation(basePath: string, config: GenerateBuildingBlockAggregationConfig, fs?: Editor): Promise<Editor>;
|
|
81
|
+
//# sourceMappingURL=processAggregation.d.ts.map
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
import { create as createStorage } from 'mem-fs';
|
|
2
|
+
import { create } from 'mem-fs-editor';
|
|
3
|
+
import { render } from 'ejs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { DOMParser, XMLSerializer } from '@xmldom/xmldom';
|
|
6
|
+
import format from 'xml-formatter';
|
|
7
|
+
import * as xpath from 'xpath';
|
|
8
|
+
import { coerce, lt } from 'semver';
|
|
9
|
+
import { getMinimumUI5Version } from '@sap-ux/project-access';
|
|
10
|
+
import { BuildingBlockType, PAGE_AGGREGATIONS, PAGE_FULL_TEMPLATE_MIN_UI5_VERSION, PAGE_TEMPLATE_COMMENT, PAGE_TEMPLATE_TYPE_FULL } from './types.js';
|
|
11
|
+
import { getTemplatePath } from '../templates.js';
|
|
12
|
+
import { createIdGenerator } from '../common/file.js';
|
|
13
|
+
import { getErrorMessage } from '../common/validate.js';
|
|
14
|
+
import { i18nNamespaces, translate } from '../i18n.js';
|
|
15
|
+
import { getOrAddNamespace } from './prompts/utils/xml.js';
|
|
16
|
+
import { resolveAggregationPath } from './processor.js';
|
|
17
|
+
/**
|
|
18
|
+
* Returns the UI5 xml file document (view/fragment).
|
|
19
|
+
*
|
|
20
|
+
* @param {string} basePath - the base path
|
|
21
|
+
* @param {string} viewPath - the path of the xml view relative to the base path
|
|
22
|
+
* @param {Editor} fs - the memfs editor instance
|
|
23
|
+
* @returns {Document} the view xml file document
|
|
24
|
+
*/
|
|
25
|
+
export function getUI5XmlDocument(basePath, viewPath, fs) {
|
|
26
|
+
let viewContent;
|
|
27
|
+
try {
|
|
28
|
+
viewContent = fs.read(join(basePath, viewPath));
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
throw new Error(`Unable to read xml view file. Details: ${getErrorMessage(error)}`);
|
|
32
|
+
}
|
|
33
|
+
const errorHandler = (level, message) => {
|
|
34
|
+
throw new Error(`Unable to parse xml view file. Details: [${level}] - ${message}`);
|
|
35
|
+
};
|
|
36
|
+
let viewDocument;
|
|
37
|
+
try {
|
|
38
|
+
viewDocument = new DOMParser({ errorHandler }).parseFromString(viewContent, 'text/xml');
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
throw new Error(`Unable to parse xml view file. Details: ${getErrorMessage(error)}`);
|
|
42
|
+
}
|
|
43
|
+
return viewDocument;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Returns the aggregation names to append for a Page building block, or undefined if not a Page.
|
|
47
|
+
* Full template appends all PAGE_AGGREGATIONS; basic template appends nothing.
|
|
48
|
+
*
|
|
49
|
+
* @param data - the building block data
|
|
50
|
+
* @returns aggregation names array, or undefined if not a Page building block
|
|
51
|
+
*/
|
|
52
|
+
export function getPageAggregationNames(data) {
|
|
53
|
+
if (data.buildingBlockType !== BuildingBlockType.Page) {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
return data.templateType === PAGE_TEMPLATE_TYPE_FULL ? PAGE_AGGREGATIONS : undefined;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Throws if the manifest's minUI5Version is set and does not meet the 1.145.0 requirement for the full Page template.
|
|
60
|
+
* If minUI5Version is not set, it is treated as latest and passes the check.
|
|
61
|
+
*
|
|
62
|
+
* @param manifest - the manifest content, or undefined if not available
|
|
63
|
+
*/
|
|
64
|
+
export function validateFullPageTemplateVersion(manifest) {
|
|
65
|
+
const minUI5Version = manifest ? coerce(getMinimumUI5Version(manifest)) : undefined;
|
|
66
|
+
if (minUI5Version && lt(minUI5Version, PAGE_FULL_TEMPLATE_MIN_UI5_VERSION)) {
|
|
67
|
+
const t = translate(i18nNamespaces.buildingBlock, 'pageBuildingBlock.');
|
|
68
|
+
throw new Error(`${t('fullTemplateMinUi5VersionRequirement', { minUI5Version: minUI5Version.version })}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Resolves the sap.fe.macros namespace prefix from the view document.
|
|
73
|
+
* If sap.fe.macros is the default namespace (no prefix), declares xmlns:macros on the document element
|
|
74
|
+
* so that generated prefixed elements like <macros:items> remain valid.
|
|
75
|
+
*
|
|
76
|
+
* @param xmlDocument - the view XML document
|
|
77
|
+
* @returns the resolved namespace prefix string (e.g. 'macros')
|
|
78
|
+
*/
|
|
79
|
+
function resolveMacrosPrefix(xmlDocument) {
|
|
80
|
+
const prefix = getOrAddNamespace(xmlDocument, 'sap.fe.macros', 'macros');
|
|
81
|
+
if (prefix === '') {
|
|
82
|
+
xmlDocument.documentElement.setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:macros', 'sap.fe.macros');
|
|
83
|
+
return 'macros';
|
|
84
|
+
}
|
|
85
|
+
return prefix;
|
|
86
|
+
}
|
|
87
|
+
/** IDs needed per aggregation template, keyed by the variable name used in the EJS template. */
|
|
88
|
+
/** Controls to generate IDs for per aggregation. `key` is the EJS template variable name, `base` is passed to generateId. */
|
|
89
|
+
export const AGGREGATION_ID_KEYS = {
|
|
90
|
+
breadcrumbs: [
|
|
91
|
+
{ key: 'Breadcrumbs', base: 'breadcrumbs_breadcrumbs' },
|
|
92
|
+
{ key: 'Link', base: 'breadcrumbs_link' },
|
|
93
|
+
{ key: 'Link1', base: 'breadcrumbs_link_1' },
|
|
94
|
+
{ key: 'Link2', base: 'breadcrumbs_link_2' }
|
|
95
|
+
],
|
|
96
|
+
navigationActions: [{ key: 'Button', base: 'navigationActions_button' }],
|
|
97
|
+
titleContent: [{ key: 'GenericTag', base: 'titleContent_genericTag' }],
|
|
98
|
+
actions: [
|
|
99
|
+
{ key: 'Button', base: 'actions_button' },
|
|
100
|
+
{ key: 'Button1', base: 'actions_button_1' }
|
|
101
|
+
],
|
|
102
|
+
headerContent: [
|
|
103
|
+
{ key: 'VBox', base: 'headerContent_vbox' },
|
|
104
|
+
{ key: 'Title', base: 'headerContent_title' }
|
|
105
|
+
],
|
|
106
|
+
footer: [
|
|
107
|
+
{ key: 'OverflowToolbar', base: 'footer_overflowToolbar' },
|
|
108
|
+
{ key: 'ToolbarSpacer', base: 'footer_toolbarSpacer' },
|
|
109
|
+
{ key: 'Button', base: 'footer_button' },
|
|
110
|
+
{ key: 'Button1', base: 'footer_button_1' }
|
|
111
|
+
]
|
|
112
|
+
};
|
|
113
|
+
/**
|
|
114
|
+
* Generates a map of unique IDs for all controls in a given Page aggregation template.
|
|
115
|
+
* Keys match the `ids.*` variable names referenced in the EJS templates.
|
|
116
|
+
*
|
|
117
|
+
* @param aggName - the aggregation name
|
|
118
|
+
* @param generateId - the project-aware ID generator
|
|
119
|
+
* @returns an object mapping each template variable name to a unique ID string
|
|
120
|
+
*/
|
|
121
|
+
export function buildAggregationIds(aggName, generateId) {
|
|
122
|
+
const entries = AGGREGATION_ID_KEYS[aggName] ?? [];
|
|
123
|
+
const validatedIds = [];
|
|
124
|
+
const ids = {};
|
|
125
|
+
for (const { key, base } of entries) {
|
|
126
|
+
const id = generateId(base, validatedIds);
|
|
127
|
+
ids[key] = id;
|
|
128
|
+
validatedIds.push(id);
|
|
129
|
+
}
|
|
130
|
+
return ids;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Renders a Page aggregation EJS template and parses it as an XML fragment document.
|
|
134
|
+
* Inherits all xmlns:* declarations from the view root so inner content can use any view-declared prefix.
|
|
135
|
+
*
|
|
136
|
+
* @param fs - the memfs editor instance
|
|
137
|
+
* @param aggName - the aggregation name (e.g. 'footer', 'items')
|
|
138
|
+
* @param aggContext - the EJS template context
|
|
139
|
+
* @param aggContext.macrosPrefix - the namespace prefix string (e.g. 'macros:')
|
|
140
|
+
* @param aggContext.aggId - the generated unique ID for the aggregation element
|
|
141
|
+
* @param aggContext.showDefaultContent - when true, the items template renders the default IconTabBar
|
|
142
|
+
* @param aggContext.ids - map of unique IDs for named controls in the template (e.g. ids.Button, ids.Link)
|
|
143
|
+
* @param fragMacrosNS - the namespace prefix resolved for sap.fe.macros
|
|
144
|
+
* @param xmlDocument - the view XML document (used to inherit namespace declarations)
|
|
145
|
+
* @returns parsed XML document whose documentElement contains the aggregation child nodes
|
|
146
|
+
*/
|
|
147
|
+
function buildPageAggregationFragment(fs, aggName, aggContext, fragMacrosNS, xmlDocument) {
|
|
148
|
+
const aggPath = getTemplatePath(`/building-block/page/${aggName}.xml`);
|
|
149
|
+
const aggContent = render(fs.read(aggPath), aggContext, {}); // NOSONAR - template is a controlled file on disk, not user input
|
|
150
|
+
const extraNamespaces = Array.from(xmlDocument.documentElement.attributes)
|
|
151
|
+
.filter((a) => a.name.startsWith('xmlns:') && a.name !== `xmlns:${fragMacrosNS}` && a.name !== 'xmlns:m')
|
|
152
|
+
.map((a) => `${a.name}="${a.value}"`)
|
|
153
|
+
.join(' ');
|
|
154
|
+
const wrapped = `<root xmlns:${fragMacrosNS}="sap.fe.macros" xmlns="sap.m" xmlns:m="sap.m" ${extraNamespaces}>${aggContent}</root>`;
|
|
155
|
+
const errorHandler = (level, message) => {
|
|
156
|
+
throw new Error(`Unable to parse page aggregation fragment '${aggName}'. Details: [${level}] - ${message}`);
|
|
157
|
+
};
|
|
158
|
+
return new DOMParser({ errorHandler }).parseFromString(wrapped, 'text/xml');
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
*
|
|
162
|
+
* @param {Editor} fs - the memfs editor instance
|
|
163
|
+
* @param {Document} xmlDocument - the view XML document (used to resolve namespace prefixes)
|
|
164
|
+
* @param {Document} templateDocument - the template document whose root element receives the children
|
|
165
|
+
* @param {IdGeneratorFunction} generateId - function to generate unique IDs
|
|
166
|
+
* @param {readonly PageAggregationName[]} [aggNames] - aggregation names to append; defaults to all PAGE_AGGREGATIONS
|
|
167
|
+
* @param useDefaults - when true, the items aggregation renders its default IconTabBar content
|
|
168
|
+
*/
|
|
169
|
+
export function appendPageAggregations(fs, xmlDocument, templateDocument, generateId, aggNames = PAGE_AGGREGATIONS, useDefaults = true) {
|
|
170
|
+
const fragMacrosNS = resolveMacrosPrefix(xmlDocument);
|
|
171
|
+
const macrosPrefix = `${fragMacrosNS}:`;
|
|
172
|
+
const pageElement = templateDocument.documentElement;
|
|
173
|
+
pageElement.appendChild(templateDocument.createComment(PAGE_TEMPLATE_COMMENT));
|
|
174
|
+
for (const aggName of aggNames) {
|
|
175
|
+
const aggId = generateId(aggName);
|
|
176
|
+
const showDefaultContent = aggName === 'items' && useDefaults;
|
|
177
|
+
const ids = buildAggregationIds(aggName, generateId);
|
|
178
|
+
const aggContext = { macrosPrefix, aggId, showDefaultContent, ids };
|
|
179
|
+
const aggDoc = buildPageAggregationFragment(fs, aggName, aggContext, fragMacrosNS, xmlDocument);
|
|
180
|
+
for (const node of Array.from(aggDoc.documentElement.childNodes)) {
|
|
181
|
+
if (node.nodeType === 1 /* Element */) {
|
|
182
|
+
pageElement.appendChild(templateDocument.importNode(node, true));
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Returns the local name of an Element if it belongs to the sap.fe.macros namespace, otherwise an empty string.
|
|
189
|
+
* This ensures only Page aggregation elements are sorted by position; non-macros elements fall back to the items slot.
|
|
190
|
+
*
|
|
191
|
+
* @param el - the DOM Element
|
|
192
|
+
* @returns the local name string, or '' if not a sap.fe.macros element
|
|
193
|
+
*/
|
|
194
|
+
function getElementLocalName(el) {
|
|
195
|
+
if (el.namespaceURI !== 'sap.fe.macros') {
|
|
196
|
+
return '';
|
|
197
|
+
}
|
|
198
|
+
return typeof el.localName === 'string' ? el.localName : '';
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Builds a comparator for sorting XmlAggregationGroups by their position in aggNames.
|
|
202
|
+
* Unknown elements fall back to the position of 'items'. Ties are broken by original index.
|
|
203
|
+
*
|
|
204
|
+
* @param aggNames - ordered list of aggregation names
|
|
205
|
+
* @returns comparator function for Array.prototype.sort
|
|
206
|
+
*/
|
|
207
|
+
function buildAggregationComparator(aggNames) {
|
|
208
|
+
const itemsIdx = aggNames.indexOf('items');
|
|
209
|
+
const fallbackIdx = itemsIdx === -1 ? aggNames.length : itemsIdx;
|
|
210
|
+
return (a, b) => {
|
|
211
|
+
const aIdx = aggNames.indexOf(getElementLocalName(a.element));
|
|
212
|
+
const bIdx = aggNames.indexOf(getElementLocalName(b.element));
|
|
213
|
+
const aOrder = aIdx === -1 ? fallbackIdx : aIdx;
|
|
214
|
+
const bOrder = bIdx === -1 ? fallbackIdx : bIdx;
|
|
215
|
+
return aOrder === bOrder ? a.originalIndex - b.originalIndex : aOrder - bOrder;
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Reorders the child elements of a macros:Page node to match the canonical PAGE_AGGREGATIONS order.
|
|
220
|
+
* Preserves relative order of siblings with the same local name. Pure whitespace text nodes are dropped
|
|
221
|
+
* because the xml-formatter call that follows will regenerate proper indentation.
|
|
222
|
+
*
|
|
223
|
+
* @param pageElement - the macros:Page DOM node whose children should be sorted
|
|
224
|
+
*/
|
|
225
|
+
export function sortPageAggregationChildren(pageElement) {
|
|
226
|
+
const allChildren = Array.from(pageElement.childNodes);
|
|
227
|
+
const aggNames = PAGE_AGGREGATIONS;
|
|
228
|
+
// Build pairs of [preceding comments, element] to preserve user comments.
|
|
229
|
+
// Comments that appear before the first element are treated as leading and will remain before all aggregation elements.
|
|
230
|
+
const groups = [];
|
|
231
|
+
const leadingComments = [];
|
|
232
|
+
let pendingComments = [];
|
|
233
|
+
let firstElementSeen = false;
|
|
234
|
+
for (const node of allChildren) {
|
|
235
|
+
if (node.nodeType === 8 /* Comment */) {
|
|
236
|
+
// Comments before the first element are leading; after, they are pending
|
|
237
|
+
(firstElementSeen ? pendingComments : leadingComments).push(node);
|
|
238
|
+
}
|
|
239
|
+
else if (node.nodeType === 1 /* Element */) {
|
|
240
|
+
firstElementSeen = true;
|
|
241
|
+
groups.push({ comments: pendingComments, element: node, originalIndex: groups.length });
|
|
242
|
+
pendingComments = [];
|
|
243
|
+
}
|
|
244
|
+
else if (node.nodeType === 3 /* Text */ && node.data?.trim()) {
|
|
245
|
+
// Preserve non-whitespace text nodes with their surrounding group
|
|
246
|
+
pendingComments.push(node);
|
|
247
|
+
}
|
|
248
|
+
// Pure whitespace text nodes are intentionally dropped (xml-formatter regenerates indentation)
|
|
249
|
+
}
|
|
250
|
+
groups.sort(buildAggregationComparator(aggNames));
|
|
251
|
+
while (pageElement.firstChild) {
|
|
252
|
+
pageElement.removeChild(pageElement.firstChild); // NOSONAR - xmldom nodes do not implement Node.remove()
|
|
253
|
+
}
|
|
254
|
+
// Re-insert leading comments first (always before any element)
|
|
255
|
+
for (const comment of leadingComments) {
|
|
256
|
+
pageElement.appendChild(comment);
|
|
257
|
+
}
|
|
258
|
+
for (const { comments, element } of groups) {
|
|
259
|
+
for (const comment of comments) {
|
|
260
|
+
pageElement.appendChild(comment);
|
|
261
|
+
}
|
|
262
|
+
pageElement.appendChild(element);
|
|
263
|
+
}
|
|
264
|
+
// Trailing orphan comments (after the last element)
|
|
265
|
+
for (const comment of pendingComments) {
|
|
266
|
+
pageElement.appendChild(comment);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Ensures a namespaced aggregation element exists in the XML document at the given aggregation path.
|
|
271
|
+
* If the final segment (e.g. 'macros:items') is missing from the DOM, the parent node is located
|
|
272
|
+
* via the path prefix and the element is inserted in place. This allows generateBuildingBlock to
|
|
273
|
+
* handle missing aggregation containers in a single write pass, avoiding a separate commit.
|
|
274
|
+
*
|
|
275
|
+
* @param {Document} xmlDocument - The XML document to mutate
|
|
276
|
+
* @param {string} aggregationPath - Full XPath to the target aggregation (e.g. '/mvc:View/macros:Page/macros:items')
|
|
277
|
+
*/
|
|
278
|
+
export function ensureMissingAggregation(xmlDocument, aggregationPath) {
|
|
279
|
+
const nsMap = xmlDocument.documentElement?._nsMap ?? {};
|
|
280
|
+
const xpathSelect = xpath.useNamespaces(nsMap);
|
|
281
|
+
if (xpathSelect(resolveAggregationPath(aggregationPath), xmlDocument).length > 0) {
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
const lastSlash = aggregationPath.lastIndexOf('/');
|
|
285
|
+
if (lastSlash <= 0) {
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
const lastSegment = aggregationPath.slice(lastSlash + 1);
|
|
289
|
+
const colonIdx = lastSegment.indexOf(':');
|
|
290
|
+
if (colonIdx === -1) {
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
const prefix = lastSegment.slice(0, colonIdx);
|
|
294
|
+
const localName = lastSegment.slice(colonIdx + 1);
|
|
295
|
+
// getOrAddNamespace resolves the URI from the document and declares it if missing.
|
|
296
|
+
// Fall back to 'sap.fe.macros' for the 'macros' prefix in case it's declared as a default
|
|
297
|
+
// namespace (xmlns="sap.fe.macros") and therefore absent from the prefix→URI nsMap.
|
|
298
|
+
let namespaceUri = nsMap[prefix];
|
|
299
|
+
if (!namespaceUri && prefix === 'macros') {
|
|
300
|
+
namespaceUri = 'sap.fe.macros';
|
|
301
|
+
}
|
|
302
|
+
if (!namespaceUri) {
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
const resolvedPrefix = getOrAddNamespace(xmlDocument, namespaceUri, prefix);
|
|
306
|
+
if (resolvedPrefix === '' && prefix) {
|
|
307
|
+
xmlDocument.documentElement.setAttributeNS('http://www.w3.org/2000/xmlns/', `xmlns:${prefix}`, namespaceUri);
|
|
308
|
+
}
|
|
309
|
+
// Rebuild xpathSelect with the prefix explicitly mapped so XPath resolves prefixed steps
|
|
310
|
+
// correctly even when the document uses sap.fe.macros as its default namespace.
|
|
311
|
+
const xpathSelectWithPrefix = xpath.useNamespaces({ ...nsMap, [prefix]: namespaceUri });
|
|
312
|
+
const parentNodes = xpathSelectWithPrefix(resolveAggregationPath(aggregationPath.slice(0, lastSlash)), xmlDocument);
|
|
313
|
+
if (parentNodes.length === 0) {
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
parentNodes[0].appendChild(xmlDocument.createElementNS(namespaceUri, `${prefix}:${localName}`));
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Wraps any direct sap.fe.macros children of pageElement that are not named Page aggregations
|
|
320
|
+
* (i.e. loose building blocks like macros:Form, macros:Table) inside a <macros:items> element.
|
|
321
|
+
* Called before inserting a new named aggregation so the DOM stays well-formed.
|
|
322
|
+
*
|
|
323
|
+
* @param pageElement - the macros:Page DOM element
|
|
324
|
+
* @param xmlDocument - the owner document
|
|
325
|
+
* @param macrosNS - the sap.fe.macros namespace URI
|
|
326
|
+
* @param macrosPrefix - the resolved prefix string (e.g. 'macros')
|
|
327
|
+
*/
|
|
328
|
+
function wrapLooseBuildingBlocksInItems(pageElement, xmlDocument, macrosNS, macrosPrefix) {
|
|
329
|
+
const aggregationSet = new Set(PAGE_AGGREGATIONS);
|
|
330
|
+
const looseChildren = Array.from(pageElement.childNodes).filter((n) => n.nodeType === 1 /* Element */ &&
|
|
331
|
+
n.namespaceURI === macrosNS &&
|
|
332
|
+
!aggregationSet.has(n.localName));
|
|
333
|
+
if (looseChildren.length === 0) {
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
const itemsName = `${macrosPrefix}:items`;
|
|
337
|
+
let itemsEl = Array.from(pageElement.childNodes).find((n) => n.nodeType === 1 && n.namespaceURI === macrosNS && n.localName === 'items');
|
|
338
|
+
if (!itemsEl) {
|
|
339
|
+
itemsEl = xmlDocument.createElementNS(macrosNS, itemsName);
|
|
340
|
+
pageElement.insertBefore(itemsEl, looseChildren[0]);
|
|
341
|
+
}
|
|
342
|
+
for (const child of looseChildren) {
|
|
343
|
+
itemsEl.appendChild(child);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Appends a single Page building block aggregation template to an existing `<macros:Page>` element in a view XML file.
|
|
348
|
+
*
|
|
349
|
+
* @param {string} basePath - the base path of the application
|
|
350
|
+
* @param {GenerateBuildingBlockAggregationConfig} config - the aggregation configuration containing aggregationName
|
|
351
|
+
* @param {Editor} [fs] - the memfs editor instance
|
|
352
|
+
* @returns {Editor} the updated memfs editor instance
|
|
353
|
+
*/
|
|
354
|
+
export async function generateBuildingBlockAggregation(basePath, config, fs) {
|
|
355
|
+
const { viewPath, buildingBlockType, aggregationName: aggName } = config;
|
|
356
|
+
fs ??= create(createStorage());
|
|
357
|
+
if (buildingBlockType !== BuildingBlockType.Page) {
|
|
358
|
+
throw new Error(`generateBuildingBlockAggregation: unsupported building block type '${buildingBlockType}'. Only 'Page' is currently supported.`);
|
|
359
|
+
}
|
|
360
|
+
const xmlDocument = getUI5XmlDocument(basePath, viewPath, fs);
|
|
361
|
+
const generateId = await createIdGenerator({ basePath, fsEditor: fs });
|
|
362
|
+
const aggId = generateId(aggName);
|
|
363
|
+
const fragMacrosNS = resolveMacrosPrefix(xmlDocument);
|
|
364
|
+
const macrosPrefix = `${fragMacrosNS}:`;
|
|
365
|
+
const ids = buildAggregationIds(aggName, generateId);
|
|
366
|
+
const aggContext = { macrosPrefix, aggId, showDefaultContent: false, ids };
|
|
367
|
+
const aggDoc = buildPageAggregationFragment(fs, aggName, aggContext, fragMacrosNS, xmlDocument);
|
|
368
|
+
const nsMap = xmlDocument.documentElement?._nsMap ?? {};
|
|
369
|
+
// Prefix-agnostic XPath — works regardless of the alias used in the view for sap.fe.macros.
|
|
370
|
+
const xpathSelect = xpath.useNamespaces(nsMap);
|
|
371
|
+
const pageNodes = xpathSelect(`//*[local-name()='Page' and namespace-uri()='sap.fe.macros']`, xmlDocument);
|
|
372
|
+
if (!pageNodes || !Array.isArray(pageNodes) || pageNodes.length === 0) {
|
|
373
|
+
throw new Error(`Page element (sap.fe.macros) not found in view ${viewPath}.`);
|
|
374
|
+
}
|
|
375
|
+
const pageElement = pageNodes[0];
|
|
376
|
+
if (aggName === 'footer' && pageElement.nodeType === 1 /* Element */) {
|
|
377
|
+
pageElement.setAttribute('showFooter', 'true');
|
|
378
|
+
}
|
|
379
|
+
const childNodes = Array.from(pageElement.childNodes);
|
|
380
|
+
const hasExistingAggregation = childNodes.some((node) => node.nodeType === 1 /* Element */ &&
|
|
381
|
+
node.localName === aggName &&
|
|
382
|
+
node.namespaceURI === (nsMap[fragMacrosNS] ?? 'sap.fe.macros'));
|
|
383
|
+
if (hasExistingAggregation) {
|
|
384
|
+
sortPageAggregationChildren(pageElement);
|
|
385
|
+
const existingXmlContent = new XMLSerializer().serializeToString(xmlDocument);
|
|
386
|
+
fs.write(join(basePath, viewPath), format(existingXmlContent));
|
|
387
|
+
return fs;
|
|
388
|
+
}
|
|
389
|
+
const hasExistingElementChildren = childNodes.some((n) => n.nodeType === 1 /* Element */);
|
|
390
|
+
const hasTemplateComment = childNodes.some((n) => n.nodeType === 8 /* Comment */ && n.data?.includes(PAGE_TEMPLATE_COMMENT));
|
|
391
|
+
// Move any loose macros building blocks (e.g. macros:Form, macros:Table) into macros:items
|
|
392
|
+
// before inserting the new named aggregation so the Page DOM stays well-formed.
|
|
393
|
+
const macrosNsUri = nsMap[fragMacrosNS] ?? 'sap.fe.macros';
|
|
394
|
+
wrapLooseBuildingBlocksInItems(pageElement, xmlDocument, macrosNsUri, fragMacrosNS);
|
|
395
|
+
if (!hasExistingElementChildren && !hasTemplateComment) {
|
|
396
|
+
pageElement.appendChild(xmlDocument.createComment(PAGE_TEMPLATE_COMMENT));
|
|
397
|
+
}
|
|
398
|
+
for (const node of Array.from(aggDoc.documentElement.childNodes)) {
|
|
399
|
+
if (node.nodeType === 1 /* Element */) {
|
|
400
|
+
pageElement.appendChild(xmlDocument.importNode(node, true));
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
sortPageAggregationChildren(pageElement);
|
|
404
|
+
const newXmlContent = new XMLSerializer().serializeToString(xmlDocument);
|
|
405
|
+
fs.write(join(basePath, viewPath), format(newXmlContent));
|
|
406
|
+
return fs;
|
|
407
|
+
}
|
|
408
|
+
//# sourceMappingURL=processAggregation.js.map
|
|
@@ -51,6 +51,16 @@ export declare const BUTTON_GROUP_CONFIGS: {
|
|
|
51
51
|
* Configuration map for building block types.
|
|
52
52
|
*/
|
|
53
53
|
export declare const BUILDING_BLOCK_CONFIG: Partial<Record<BuildingBlockType, BuildingBlockTemplateConfig>>;
|
|
54
|
+
/**
|
|
55
|
+
* Rewrites an XPath aggregation path so that unprefixed steps match elements in any namespace.
|
|
56
|
+
* XPath 1.0 with useNamespaces does not support default namespaces: an unprefixed name only
|
|
57
|
+
* matches no-namespace nodes. Steps like "IconTabBar" or "items" (in the sap.m default ns)
|
|
58
|
+
* would never match. This converts them to *[local-name()='step'] predicates.
|
|
59
|
+
*
|
|
60
|
+
* @param {string} aggregationPath - The original aggregation XPath
|
|
61
|
+
* @returns {string} The rewritten XPath safe for use with xpath.useNamespaces
|
|
62
|
+
*/
|
|
63
|
+
export declare function resolveAggregationPath(aggregationPath: string): string;
|
|
54
64
|
/**
|
|
55
65
|
* Processes building block configuration.
|
|
56
66
|
*
|
|
@@ -291,7 +291,7 @@ function processRichTextEditorButtonGroups(buildingBlockData, context) {
|
|
|
291
291
|
}
|
|
292
292
|
const existingButtonGroupsMap = new Map();
|
|
293
293
|
if (hasAggregation && xmlDocument && updatedAggregationPath) {
|
|
294
|
-
const xpathSelect = xpath.useNamespaces(xmlDocument.
|
|
294
|
+
const xpathSelect = xpath.useNamespaces(xmlDocument.documentElement?._nsMap ?? {});
|
|
295
295
|
// Example: [<Element: richtexteditor:buttonGroups>] containing all ButtonGroup children
|
|
296
296
|
const buttonGroupsElements = xpathSelect(updatedAggregationPath, xmlDocument);
|
|
297
297
|
if (buttonGroupsElements.length > 0) {
|
|
@@ -314,6 +314,23 @@ function processRichTextEditorButtonGroups(buildingBlockData, context) {
|
|
|
314
314
|
}
|
|
315
315
|
buildingBlockData.buttonGroups = mergeButtonGroups(existingButtonGroupsMap, buildingBlockData);
|
|
316
316
|
}
|
|
317
|
+
/**
|
|
318
|
+
* Rewrites an XPath aggregation path so that unprefixed steps match elements in any namespace.
|
|
319
|
+
* XPath 1.0 with useNamespaces does not support default namespaces: an unprefixed name only
|
|
320
|
+
* matches no-namespace nodes. Steps like "IconTabBar" or "items" (in the sap.m default ns)
|
|
321
|
+
* would never match. This converts them to *[local-name()='step'] predicates.
|
|
322
|
+
*
|
|
323
|
+
* @param {string} aggregationPath - The original aggregation XPath
|
|
324
|
+
* @returns {string} The rewritten XPath safe for use with xpath.useNamespaces
|
|
325
|
+
*/
|
|
326
|
+
export function resolveAggregationPath(aggregationPath) {
|
|
327
|
+
return aggregationPath.replace(/\/([A-Za-z_][A-Za-z0-9_.-]*)(?=\/|$)/g, (_match, step) => {
|
|
328
|
+
if (step.includes(':')) {
|
|
329
|
+
return `/${step}`;
|
|
330
|
+
}
|
|
331
|
+
return `/*[local-name()='${step}']`;
|
|
332
|
+
});
|
|
333
|
+
}
|
|
317
334
|
/**
|
|
318
335
|
* Updates aggregation path based on XML document structure.
|
|
319
336
|
*
|
|
@@ -326,9 +343,11 @@ function processRichTextEditorButtonGroups(buildingBlockData, context) {
|
|
|
326
343
|
* @returns {object} Object containing the updated aggregation path
|
|
327
344
|
*/
|
|
328
345
|
function updateAggregationPath(xmlDocument, aggregationPath, config, namespace) {
|
|
329
|
-
const
|
|
346
|
+
const nsMap = xmlDocument.documentElement?._nsMap ?? {};
|
|
347
|
+
const xpathSelect = xpath.useNamespaces(nsMap);
|
|
348
|
+
const resolvedPath = resolveAggregationPath(aggregationPath);
|
|
330
349
|
// First, get the target element from the aggregationPath
|
|
331
|
-
const targetElement = xpathSelect(
|
|
350
|
+
const targetElement = xpathSelect(resolvedPath, xmlDocument);
|
|
332
351
|
if (!targetElement || !Array.isArray(targetElement) || targetElement.length === 0) {
|
|
333
352
|
return { updatedAggregationPath: aggregationPath, hasElement: false };
|
|
334
353
|
}
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { i18nNamespaces, translate } from '../../../i18n.js';
|
|
2
2
|
import { getBuildingBlockIdPrompt, getViewOrFragmentPathPrompt, getAggregationPathPrompt } from '../utils/index.js';
|
|
3
|
-
import { BuildingBlockType, PAGE_TEMPLATE_TYPE_BASIC, PAGE_TEMPLATE_TYPE_FULL } from '../../types.js';
|
|
3
|
+
import { BuildingBlockType, PAGE_FULL_TEMPLATE_MIN_UI5_VERSION, PAGE_TEMPLATE_TYPE_BASIC, PAGE_TEMPLATE_TYPE_FULL } from '../../types.js';
|
|
4
4
|
import { SapShortTextType, SapLongTextType } from '@sap-ux/i18n';
|
|
5
|
+
import { getMinimumUI5Version } from '@sap-ux/project-access';
|
|
6
|
+
import { coerce, lt } from 'semver';
|
|
7
|
+
import { getManifest } from '../../../common/utils.js';
|
|
5
8
|
/**
|
|
6
9
|
* Returns a list of prompts required to generate a page building block.
|
|
7
10
|
*
|
|
@@ -10,19 +13,26 @@ import { SapShortTextType, SapLongTextType } from '@sap-ux/i18n';
|
|
|
10
13
|
*/
|
|
11
14
|
export async function getPageBuildingBlockPrompts(context) {
|
|
12
15
|
const t = translate(i18nNamespaces.buildingBlock, 'prompts.page.');
|
|
16
|
+
const { content: manifest } = await getManifest(context.appPath, context.fs, false);
|
|
17
|
+
const minUI5Version = manifest ? coerce(getMinimumUI5Version(manifest)) : undefined;
|
|
18
|
+
const hideTemplateType = !!minUI5Version && lt(minUI5Version, PAGE_FULL_TEMPLATE_MIN_UI5_VERSION);
|
|
13
19
|
return {
|
|
14
20
|
questions: [
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
21
|
+
...(hideTemplateType
|
|
22
|
+
? []
|
|
23
|
+
: [
|
|
24
|
+
{
|
|
25
|
+
type: 'list',
|
|
26
|
+
name: 'buildingBlockData.templateType',
|
|
27
|
+
message: t('templateType.message'),
|
|
28
|
+
default: PAGE_TEMPLATE_TYPE_BASIC,
|
|
29
|
+
choices: [
|
|
30
|
+
{ value: PAGE_TEMPLATE_TYPE_BASIC, name: t('templateType.basic') },
|
|
31
|
+
{ value: PAGE_TEMPLATE_TYPE_FULL, name: t('templateType.full') }
|
|
32
|
+
],
|
|
33
|
+
guiOptions: { mandatory: true }
|
|
34
|
+
}
|
|
35
|
+
]),
|
|
26
36
|
getViewOrFragmentPathPrompt(context, t('viewOrFragmentPath.validate'), {
|
|
27
37
|
message: t('viewOrFragmentPath.message'),
|
|
28
38
|
guiOptions: {
|
|
@@ -69,7 +79,8 @@ export async function getPageBuildingBlockPrompts(context) {
|
|
|
69
79
|
],
|
|
70
80
|
initialAnswers: {
|
|
71
81
|
buildingBlockData: {
|
|
72
|
-
buildingBlockType: BuildingBlockType.Page
|
|
82
|
+
buildingBlockType: BuildingBlockType.Page,
|
|
83
|
+
...(hideTemplateType ? { templateType: PAGE_TEMPLATE_TYPE_BASIC } : {})
|
|
73
84
|
}
|
|
74
85
|
}
|
|
75
86
|
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { DOMParser } from '@xmldom/xmldom';
|
|
2
2
|
import * as xpath from 'xpath';
|
|
3
|
+
import { MACROS_NAMESPACE_URI } from '../../types.js';
|
|
3
4
|
/**
|
|
4
5
|
* Converts the provided xpath string from `/mvc:View/Page/content` to
|
|
5
6
|
* `/mvc:View/*[local-name()='Page']/*[local-name()='content']`.
|
|
@@ -14,6 +15,29 @@ export const augmentXpathWithLocalNames = (path) => {
|
|
|
14
15
|
}
|
|
15
16
|
return result.join('/');
|
|
16
17
|
};
|
|
18
|
+
/**
|
|
19
|
+
* If the given node is a macros:Page element that has no macros:items child, adds a synthesized
|
|
20
|
+
* macros:items path to the result map so callers can target it as an aggregation path.
|
|
21
|
+
* ensureMissingAggregation in generateBuildingBlock will create the DOM element when needed.
|
|
22
|
+
*
|
|
23
|
+
* @param node - the current DOM node being visited
|
|
24
|
+
* @param parentNode - accumulated XPath prefix up to (but not including) this node
|
|
25
|
+
* @param macrosNamespace - the resolved macros namespace prefix (e.g. 'macros')
|
|
26
|
+
* @param result - mutable map of XPath choices to augment
|
|
27
|
+
*/
|
|
28
|
+
function addMacrosItemsPathIfMissing(node, parentNode, macrosNamespace, result) {
|
|
29
|
+
if (node.localName !== 'Page' || node.namespaceURI !== MACROS_NAMESPACE_URI) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const resolvedPrefix = macrosNamespace || 'macros';
|
|
33
|
+
const macrosItemsName = `${resolvedPrefix}:items`;
|
|
34
|
+
const hasItemsChild = Array.from(node.childNodes).some((child) => child.nodeType === child.ELEMENT_NODE && child.localName === 'items');
|
|
35
|
+
if (!hasItemsChild) {
|
|
36
|
+
const pageStep = node.prefix ? node.nodeName : `${resolvedPrefix}:Page`;
|
|
37
|
+
const itemsPath = `${parentNode}/${pageStep}/${macrosItemsName}`;
|
|
38
|
+
result[itemsPath] = augmentXpathWithLocalNames(itemsPath);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
17
41
|
/**
|
|
18
42
|
* Returns a list of xpath strings for each element of the xml file provided.
|
|
19
43
|
*
|
|
@@ -44,10 +68,11 @@ export function getXPathStringsForXmlFile(xmlFilePath, fs) {
|
|
|
44
68
|
// This prevents suggesting insertion points outside macros:Page when a macros:Page is present.
|
|
45
69
|
hasPageMacroChild = Array.from(node.childNodes).some((child) => child.nodeType === child.ELEMENT_NODE &&
|
|
46
70
|
child.localName === 'Page' &&
|
|
47
|
-
child.
|
|
71
|
+
child.namespaceURI === MACROS_NAMESPACE_URI);
|
|
48
72
|
if (!hasPageMacroChild) {
|
|
49
73
|
result[`${parentNode}/${node.nodeName}`] = augmentXpathWithLocalNames(`${parentNode}/${node.nodeName}`);
|
|
50
74
|
}
|
|
75
|
+
addMacrosItemsPathIfMissing(node, parentNode, macrosNamespace || 'macros', result);
|
|
51
76
|
const childNodes = Array.from(node.childNodes);
|
|
52
77
|
for (const childNode of childNodes) {
|
|
53
78
|
if (childNode.nodeType === childNode.ELEMENT_NODE) {
|
|
@@ -380,8 +380,8 @@ export interface Page extends BuildingBlock {
|
|
|
380
380
|
description?: string;
|
|
381
381
|
/**
|
|
382
382
|
* The template type for the page building block.
|
|
383
|
-
* 'full' generates a full page template with all aggregations
|
|
384
|
-
* 'basic' generates a minimal self-closing
|
|
383
|
+
* 'full' generates a full page template with all aggregations.
|
|
384
|
+
* 'basic' generates a minimal self-closing <macros:Page/> with no aggregations.
|
|
385
385
|
*/
|
|
386
386
|
templateType?: PageTemplateType;
|
|
387
387
|
/**
|
|
@@ -393,6 +393,9 @@ export interface Page extends BuildingBlock {
|
|
|
393
393
|
export declare const PAGE_TEMPLATE_TYPE_FULL: "full";
|
|
394
394
|
export declare const PAGE_TEMPLATE_TYPE_BASIC: "basic";
|
|
395
395
|
export type PageTemplateType = typeof PAGE_TEMPLATE_TYPE_FULL | typeof PAGE_TEMPLATE_TYPE_BASIC;
|
|
396
|
+
export declare const PAGE_FULL_TEMPLATE_MIN_UI5_VERSION = "1.145.0";
|
|
397
|
+
export declare const PAGE_TEMPLATE_COMMENT = "This is a sample template, event handlers should be added for implementation";
|
|
398
|
+
export declare const MACROS_NAMESPACE_URI = "sap.fe.macros";
|
|
396
399
|
/**
|
|
397
400
|
* A group of XML nodes representing one Page aggregation element and its preceding sibling comments.
|
|
398
401
|
* Used when re-ordering aggregation children under a macros:Page element.
|
|
@@ -412,8 +415,6 @@ export interface GenerateBuildingBlockAggregationConfig {
|
|
|
412
415
|
buildingBlockType: BuildingBlockType;
|
|
413
416
|
/** Name of the aggregation to append. */
|
|
414
417
|
aggregationName: PageAggregationName;
|
|
415
|
-
/** Optional inner XML content for the aggregation. */
|
|
416
|
-
mContent?: string;
|
|
417
418
|
}
|
|
418
419
|
/**
|
|
419
420
|
* Represents a custom filter to be used inside the FilterBar.
|
|
@@ -39,4 +39,7 @@ export const PAGE_AGGREGATIONS = [
|
|
|
39
39
|
];
|
|
40
40
|
export const PAGE_TEMPLATE_TYPE_FULL = 'full';
|
|
41
41
|
export const PAGE_TEMPLATE_TYPE_BASIC = 'basic';
|
|
42
|
+
export const PAGE_FULL_TEMPLATE_MIN_UI5_VERSION = '1.145.0';
|
|
43
|
+
export const PAGE_TEMPLATE_COMMENT = 'This is a sample template, event handlers should be added for implementation';
|
|
44
|
+
export const MACROS_NAMESPACE_URI = 'sap.fe.macros';
|
|
42
45
|
//# sourceMappingURL=types.js.map
|
|
@@ -305,7 +305,8 @@ const ns1 = {
|
|
|
305
305
|
}
|
|
306
306
|
},
|
|
307
307
|
'pageBuildingBlock': {
|
|
308
|
-
'minUi5VersionRequirement': 'The Page building block feature requires SAPUI5 1.136.0 or higher. The current version is {{ minUI5Version }}, so the Page building block will not be added.'
|
|
308
|
+
'minUi5VersionRequirement': 'The Page building block feature requires SAPUI5 1.136.0 or higher. The current version is {{ minUI5Version }}, so the Page building block will not be added.',
|
|
309
|
+
'fullTemplateMinUi5VersionRequirement': 'The full Page building block feature requires SAPUI5 1.145.0 or higher. The current version is {{ minUI5Version }}, so the full template will not be added.'
|
|
309
310
|
},
|
|
310
311
|
'richTextEditorBuildingBlock': {
|
|
311
312
|
'minUi5VersionRequirement': 'The Rich Text Editor building block feature requires SAPUI5 1.117.0 or higher. The current version is {{ minUI5Version }}, so the Rich Text Editor building block will not be added.'
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sap-ux/fe-fpm-writer",
|
|
3
3
|
"description": "SAP Fiori elements flexible programming model writer",
|
|
4
|
-
"version": "1.2.
|
|
4
|
+
"version": "1.2.1",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "https://github.com/SAP/open-ux-tools.git",
|
|
@@ -33,8 +33,8 @@
|
|
|
33
33
|
"xpath": "0.0.34",
|
|
34
34
|
"@sap-ux/fiori-annotation-api": "1.0.15",
|
|
35
35
|
"@sap-ux/i18n": "1.0.2",
|
|
36
|
-
"@sap-ux/
|
|
37
|
-
"@sap-ux/
|
|
36
|
+
"@sap-ux/logger": "1.0.3",
|
|
37
|
+
"@sap-ux/project-access": "2.1.6"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"@jest/globals": "30.4.1",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<<%- macrosPrefix %>actions>
|
|
2
2
|
<!-- Actions aggregation -->
|
|
3
|
-
<%-
|
|
3
|
+
<Button id="<%- ids.Button %>" text="Action 1" press=".onActionsClickAction1" type="Ghost" />
|
|
4
|
+
<Button id="<%- ids.Button1 %>" text="Action 2" press=".onActionsClickAction2" type="Ghost" />
|
|
4
5
|
</<%- macrosPrefix %>actions>
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
<<%- macrosPrefix %>breadcrumbs>
|
|
2
2
|
<!-- Breadcrumbs aggregation -->
|
|
3
|
-
<%-
|
|
3
|
+
<Breadcrumbs id="<%- ids.Breadcrumbs %>">
|
|
4
|
+
<Link id="<%- ids.Link %>" text="Home" press=".onBreadcrumbsPressHome" />
|
|
5
|
+
<Link id="<%- ids.Link1 %>" text="Page 1" press=".onBreadcrumbsPressPage1" />
|
|
6
|
+
<Link id="<%- ids.Link2 %>" text="Page 2" press=".onBreadcrumbsPressPage2" />
|
|
7
|
+
</Breadcrumbs>
|
|
4
8
|
</<%- macrosPrefix %>breadcrumbs>
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
<<%- macrosPrefix %>footer>
|
|
2
2
|
<!-- Footer aggregation -->
|
|
3
|
-
<%-
|
|
3
|
+
<OverflowToolbar id="<%- ids.OverflowToolbar %>">
|
|
4
|
+
<ToolbarSpacer id="<%- ids.ToolbarSpacer %>" />
|
|
5
|
+
<Button id="<%- ids.Button %>" text="Approve" press=".onFooterApprove" type="Accept" icon="" />
|
|
6
|
+
<Button id="<%- ids.Button1 %>" text="Reject" press=".onFooterReject" type="Reject" icon="" />
|
|
7
|
+
</OverflowToolbar>
|
|
4
8
|
</<%- macrosPrefix %>footer>
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
<<%- macrosPrefix %>items>
|
|
2
2
|
<!-- Main content -->
|
|
3
|
+
<% if (showDefaultContent) { -%>
|
|
3
4
|
<IconTabBar id="iconTabBar<%- aggId %>" class="sapUiResponsiveContentPadding">
|
|
4
5
|
<items>
|
|
5
6
|
<IconTabFilter id="iconTabFilter<%- aggId %>" text="Tab 1">
|
|
6
7
|
</IconTabFilter>
|
|
7
8
|
</items>
|
|
8
9
|
</IconTabBar>
|
|
10
|
+
<% } -%>
|
|
9
11
|
</<%- macrosPrefix %>items>
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
sap.ui.define([], function () {
|
|
2
|
-
'use strict';
|
|
3
|
-
|
|
4
|
-
return {
|
|
5
|
-
onPressHome: function (_event) {},
|
|
6
|
-
|
|
7
|
-
onPressPage1: function (_event) {},
|
|
8
|
-
|
|
9
|
-
onPressPage2: function (_event) {},
|
|
10
|
-
|
|
11
|
-
onFullScreen: function (_event) {},
|
|
12
|
-
|
|
13
|
-
onClickAction1: function (_event) {},
|
|
14
|
-
|
|
15
|
-
onClickAction2: function (_event) {}
|
|
16
|
-
};
|
|
17
|
-
});
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
import ExtensionAPI from 'sap/fe/core/ExtensionAPI';
|
|
2
|
-
import Event from 'sap/ui/base/Event';
|
|
3
|
-
|
|
4
|
-
export function onPressHome(this: ExtensionAPI, _event: Event): void {}
|
|
5
|
-
|
|
6
|
-
export function onPressPage1(this: ExtensionAPI, _event: Event): void {}
|
|
7
|
-
|
|
8
|
-
export function onPressPage2(this: ExtensionAPI, _event: Event): void {}
|
|
9
|
-
|
|
10
|
-
export function onFullScreen(this: ExtensionAPI, _event: Event): void {}
|
|
11
|
-
|
|
12
|
-
export function onClickAction1(this: ExtensionAPI, _event: Event): void {}
|
|
13
|
-
|
|
14
|
-
export function onClickAction2(this: ExtensionAPI, _event: Event): void {}
|