diff --git a/.changeset/loud-insects-tease.md b/.changeset/loud-insects-tease.md
new file mode 100644
index 0000000000..5b7f0afaea
--- /dev/null
+++ b/.changeset/loud-insects-tease.md
@@ -0,0 +1,7 @@
+---
+'@sap-ux-private/preview-middleware-client': minor
+'@sap-ux/preview-middleware': minor
+'@sap-ux/adp-tooling': minor
+---
+
+Enhancing the preview-middleware with new functionality such as adding an XML Fragment (creating "addXML" change).
diff --git a/packages/adp-tooling/.eslintignore b/packages/adp-tooling/.eslintignore
index cc1f8e004c..ad630365f0 100644
--- a/packages/adp-tooling/.eslintignore
+++ b/packages/adp-tooling/.eslintignore
@@ -1,2 +1,2 @@
test/fixtures
-dist
+dist
\ No newline at end of file
diff --git a/packages/adp-tooling/README.md b/packages/adp-tooling/README.md
index 491519ad34..f975ebf657 100644
--- a/packages/adp-tooling/README.md
+++ b/packages/adp-tooling/README.md
@@ -1,12 +1,17 @@
# `@sap-ux/adp-tooling`
-Module containing different tooling modules helpful when working with SAP UI5 adaptation projects.
+A module containing different tooling modules helpful when working with SAP UI5 adaptation projects.
-## preview
+## Submodules
+
+### preview
The submodule preview contains functionality allowing to preview adaptation projects. It is not a standalone UI5 middleware but designed to be integrated into the `@sap-ux/preview-middleware.`.
-## writer
+### writer
The submodule writer contains functionality to generate the core project structure of an SAP UI5 adaptation project. It is not a standalone generator but designed to be integrated into `@sap-ux/create` or any kind of yeoman generator.
-## base
-The submodule contains functionality required in different scenarios, e.g. prompting for generation or when initializing the preview.
\ No newline at end of file
+### base
+The submodule contains functionality required in different scenarios, e.g. prompting for generation or when initializing the preview.
+
+## Templates
+The templates folder contains ejs templates to be used for the generation of new adaptation projects as well as to generate artifacts in existing adaptation projects.
\ No newline at end of file
diff --git a/packages/adp-tooling/jest.config.js b/packages/adp-tooling/jest.config.js
index c26cdfebc5..9e9be597ec 100644
--- a/packages/adp-tooling/jest.config.js
+++ b/packages/adp-tooling/jest.config.js
@@ -1,2 +1,2 @@
const config = require('../../jest.base');
-module.exports = config;
\ No newline at end of file
+module.exports = config;
diff --git a/packages/adp-tooling/package.json b/packages/adp-tooling/package.json
index 691557a6ce..6e2da486d5 100644
--- a/packages/adp-tooling/package.json
+++ b/packages/adp-tooling/package.json
@@ -39,6 +39,7 @@
"@sap-ux/logger": "workspace:*",
"@sap-ux/system-access": "workspace:*",
"@sap-ux/ui5-config": "workspace:*",
+ "sanitize-filename": "1.6.3",
"ejs": "3.1.9",
"mem-fs": "2.1.0",
"mem-fs-editor": "9.4.0",
diff --git a/packages/adp-tooling/src/preview/adp-preview.ts b/packages/adp-tooling/src/preview/adp-preview.ts
index 5749a7ccf1..d9db65d84b 100644
--- a/packages/adp-tooling/src/preview/adp-preview.ts
+++ b/packages/adp-tooling/src/preview/adp-preview.ts
@@ -1,11 +1,21 @@
-import type { ToolsLogger } from '@sap-ux/logger';
+import express from 'express';
import { ZipFile } from 'yazl';
-import type { AdpPreviewConfig, DescriptorVariant } from '../types';
-import type { NextFunction, Request, Response } from 'express';
-import type { MergedAppDescriptor } from '@sap-ux/axios-extension';
import type { ReaderCollection } from '@ui5/fs';
+import type { MiddlewareUtils } from '@ui5/server';
+import type { NextFunction, Request, Response, Router, RequestHandler } from 'express';
+
+import type { ToolsLogger } from '@sap-ux/logger';
import type { UI5FlexLayer } from '@sap-ux/project-access';
import { createAbapServiceProvider } from '@sap-ux/system-access';
+import type { MergedAppDescriptor } from '@sap-ux/axios-extension';
+
+import RoutesHandler from './routes-handler';
+import type { AdpPreviewConfig, DescriptorVariant } from '../types';
+
+export const enum ApiRoutes {
+ FRAGMENT = '/adp/api/fragment',
+ CONTROLLER = '/adp/api/controller'
+}
/**
* Create a buffer based on the given zip file object.
@@ -36,6 +46,10 @@ export class AdpPreview {
* Merged descriptor variant with reference app manifest
*/
private mergedDescriptor: MergedAppDescriptor;
+ /**
+ * Routes handler class to handle API requests
+ */
+ private routesHandler: RoutesHandler;
/**
* @returns merged manifest.
@@ -72,13 +86,17 @@ export class AdpPreview {
*
* @param config adp config
* @param project reference to the root of the project
+ * @param util middleware utilities provided by the UI5 CLI
* @param logger logger instance
*/
constructor(
private readonly config: AdpPreviewConfig,
private readonly project: ReaderCollection,
+ private readonly util: MiddlewareUtils,
private readonly logger: ToolsLogger
- ) {}
+ ) {
+ this.routesHandler = new RoutesHandler(project, util, logger);
+ }
/**
* Fetch all required configurations from the backend and initialize all configurations.
@@ -131,4 +149,17 @@ export class AdpPreview {
}
}
}
+
+ /**
+ * Add additional APIs to the router that are required for adaptation projects only.
+ *
+ * @param router router that is to be enhanced with the API
+ */
+ addApis(router: Router): void {
+ /**
+ * FRAGMENT Routes
+ */
+ router.get(ApiRoutes.FRAGMENT, this.routesHandler.handleReadAllFragments as RequestHandler);
+ router.post(ApiRoutes.FRAGMENT, express.json(), this.routesHandler.handleWriteFragment as RequestHandler);
+ }
}
diff --git a/packages/adp-tooling/src/preview/routes-handler.ts b/packages/adp-tooling/src/preview/routes-handler.ts
new file mode 100644
index 0000000000..dfd0e37cf6
--- /dev/null
+++ b/packages/adp-tooling/src/preview/routes-handler.ts
@@ -0,0 +1,115 @@
+import * as fs from 'fs';
+import * as path from 'path';
+import sanitize from 'sanitize-filename';
+import type { ReaderCollection } from '@ui5/fs';
+import type { ToolsLogger } from '@sap-ux/logger';
+import type { MiddlewareUtils } from '@ui5/server';
+import type { NextFunction, Request, Response } from 'express';
+
+import { FolderNames, TemplateFileName, HttpStatusCodes } from '../types';
+
+interface WriteFragmentBody {
+ fragmentName: string;
+}
+
+/**
+ * @description Handles API Routes
+ */
+export default class RoutesHandler {
+ /**
+ * Constructor taking project as input.
+ *
+ * @param project Reference to the root of the project
+ * @param util middleware utilities provided by the UI5 CLI
+ * @param logger Logger instance
+ */
+ constructor(
+ private readonly project: ReaderCollection,
+ private readonly util: MiddlewareUtils,
+ private readonly logger: ToolsLogger
+ ) {}
+
+ /**
+ * Handler for reading all fragment files from the workspace.
+ *
+ * @param _ Request
+ * @param res Response
+ * @param next Next Function
+ */
+ public handleReadAllFragments = async (_: Request, res: Response, next: NextFunction) => {
+ try {
+ const files = await this.project.byGlob('/**/changes/**/*.fragment.xml');
+
+ if (!files || files.length === 0) {
+ res.status(HttpStatusCodes.OK)
+ .contentType('application/json')
+ .send({ fragments: [], message: `No fragments found in the project workspace.` });
+ return;
+ }
+
+ const fragments = files.map((f) => ({
+ fragmentName: f.getName()
+ }));
+
+ res.status(HttpStatusCodes.OK)
+ .contentType('application/json')
+ .send({
+ fragments,
+ message: `${fragments.length} fragments found in the project workspace.`
+ });
+ this.logger.debug(`Read fragments ${JSON.stringify(fragments)}`);
+ } catch (e) {
+ this.logger.error(e.message);
+ res.status(HttpStatusCodes.INTERNAL_SERVER_ERROR).send({ message: e.message });
+ next(e);
+ }
+ };
+
+ /**
+ * Handler for writing a fragment file to the workspace.
+ *
+ * @param req Request
+ * @param res Response
+ * @param next Next Function
+ */
+ public handleWriteFragment = async (req: Request, res: Response, next: NextFunction) => {
+ try {
+ const data = req.body as WriteFragmentBody;
+
+ const fragmentName = sanitize(data.fragmentName);
+
+ const sourcePath = this.util.getProject().getSourcePath();
+
+ if (fragmentName) {
+ const fullPath = path.join(sourcePath, FolderNames.Changes, FolderNames.Fragments);
+ const filePath = path.join(fullPath, `${fragmentName}.fragment.xml`);
+
+ if (!fs.existsSync(fullPath)) {
+ fs.mkdirSync(fullPath);
+ }
+
+ if (fs.existsSync(filePath)) {
+ res.status(HttpStatusCodes.CONFLICT).send(`Fragment with name "${fragmentName}" already exists`);
+ this.logger.debug(`XML Fragment with name "${fragmentName}" was created`);
+ return;
+ }
+
+ // Copy the template XML Fragment to the project's workspace
+ const fragmentTemplatePath = path.join(__dirname, '../../templates/rta', TemplateFileName.Fragment);
+ fs.copyFileSync(fragmentTemplatePath, filePath);
+
+ const message = 'XML Fragment created';
+ res.status(HttpStatusCodes.CREATED).send(message);
+ this.logger.debug(`XML Fragment with name "${fragmentName}" was created`);
+ } else {
+ res.status(HttpStatusCodes.BAD_REQUEST).send('Fragment name was not provided!');
+ this.logger.debug('Bad request. Fragment name was not provided!');
+ }
+ } catch (e) {
+ const sanitizedMsg = sanitize(e.message);
+ this.logger.error(sanitizedMsg);
+ res.status(HttpStatusCodes.INTERNAL_SERVER_ERROR).send(sanitizedMsg);
+ next(e);
+ }
+ };
+}
diff --git a/packages/adp-tooling/src/types.ts b/packages/adp-tooling/src/types.ts
index f97f7b1d84..437dcbe851 100644
--- a/packages/adp-tooling/src/types.ts
+++ b/packages/adp-tooling/src/types.ts
@@ -39,3 +39,44 @@ export interface AdpWriterConfig {
description?: string;
};
}
+
+export interface ManifestAppdescr {
+ fileName: string;
+ layer: string;
+ fileType: string;
+ reference: string;
+ id: string;
+ namespace: string;
+ version: string;
+ content: Content[];
+}
+
+export interface Content {
+ changeType: string;
+ content: object;
+ texts?: object;
+}
+
+export const enum FolderNames {
+ Changes = 'changes',
+ Fragments = 'fragments'
+}
+
+export const enum TemplateFileName {
+ Fragment = 'fragment.xml'
+}
+
+export const enum HttpStatusCodes {
+ OK = 200,
+ CREATED = 201,
+ NO_CONTENT = 204,
+ BAD_REQUEST = 400,
+ UNAUTHORIZED = 401,
+ FORBIDDEN = 403,
+ NOT_FOUND = 404,
+ METHOD_NOT_ALLOWED = 405,
+ CONFLICT = 409,
+ INTERNAL_SERVER_ERROR = 500,
+ NOT_IMPLEMETED = 501,
+ SERVICE_UNAVAILABLE = 503
+}
diff --git a/packages/adp-tooling/src/writer/index.ts b/packages/adp-tooling/src/writer/index.ts
index 8cb1b236a8..586133d5bd 100644
--- a/packages/adp-tooling/src/writer/index.ts
+++ b/packages/adp-tooling/src/writer/index.ts
@@ -39,7 +39,7 @@ export async function generate(basePath: string, config: AdpWriterConfig, fs?: E
if (!fs) {
fs = create(createStorage());
}
- const tmplPath = join(__dirname, '../../templates');
+ const tmplPath = join(__dirname, '../../templates/project');
const fullConfig = setDefaults(config);
fs.copyTpl(join(tmplPath, '**/*.*'), join(basePath), fullConfig, undefined, {
diff --git a/packages/adp-tooling/templates/gitignore.tmpl b/packages/adp-tooling/templates/project/gitignore.tmpl
similarity index 100%
rename from packages/adp-tooling/templates/gitignore.tmpl
rename to packages/adp-tooling/templates/project/gitignore.tmpl
diff --git a/packages/adp-tooling/templates/package.json b/packages/adp-tooling/templates/project/package.json
similarity index 100%
rename from packages/adp-tooling/templates/package.json
rename to packages/adp-tooling/templates/project/package.json
diff --git a/packages/adp-tooling/templates/ui5.yaml b/packages/adp-tooling/templates/project/ui5.yaml
similarity index 100%
rename from packages/adp-tooling/templates/ui5.yaml
rename to packages/adp-tooling/templates/project/ui5.yaml
diff --git a/packages/adp-tooling/templates/webapp/i18n/i18n.properties b/packages/adp-tooling/templates/project/webapp/i18n/i18n.properties
similarity index 100%
rename from packages/adp-tooling/templates/webapp/i18n/i18n.properties
rename to packages/adp-tooling/templates/project/webapp/i18n/i18n.properties
diff --git a/packages/adp-tooling/templates/webapp/manifest.appdescr_variant b/packages/adp-tooling/templates/project/webapp/manifest.appdescr_variant
similarity index 100%
rename from packages/adp-tooling/templates/webapp/manifest.appdescr_variant
rename to packages/adp-tooling/templates/project/webapp/manifest.appdescr_variant
diff --git a/packages/adp-tooling/templates/rta/fragment.xml b/packages/adp-tooling/templates/rta/fragment.xml
new file mode 100644
index 0000000000..100d9955f6
--- /dev/null
+++ b/packages/adp-tooling/templates/rta/fragment.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/packages/adp-tooling/test/unit/preview/adp-preview.test.ts b/packages/adp-tooling/test/unit/preview/adp-preview.test.ts
index b3914ced5a..fcb87fc5a5 100644
--- a/packages/adp-tooling/test/unit/preview/adp-preview.test.ts
+++ b/packages/adp-tooling/test/unit/preview/adp-preview.test.ts
@@ -1,12 +1,17 @@
import { ToolsLogger } from '@sap-ux/logger';
import { AdpPreview } from '../../../src/preview/adp-preview';
-import { readFileSync } from 'fs';
import { join } from 'path';
import type { ReaderCollection } from '@ui5/fs';
import nock from 'nock';
import type { SuperTest, Test } from 'supertest';
import supertest from 'supertest';
import express from 'express';
+import { readFileSync, existsSync } from 'fs';
+
+interface GetFragmentsResponse {
+ fragments: { fragmentName: string }[];
+ message: string;
+}
jest.mock('@sap-ux/store', () => {
return {
@@ -23,6 +28,14 @@ const mockProject = {
byGlob: jest.fn().mockResolvedValue([])
};
+jest.mock('fs', () => ({
+ ...jest.requireActual('fs'),
+ existsSync: jest.fn(),
+ mkdirSync: jest.fn(),
+ copyFileSync: jest.fn()
+}));
+const mockExistsSync = existsSync as jest.Mock;
+
describe('AdaptationProject', () => {
const backend = 'https://sap.example';
const descriptorVariant = readFileSync(
@@ -50,6 +63,19 @@ describe('AdaptationProject', () => {
url: '/my/adaptation'
};
+ const middlewareUtil = {
+ getProject() {
+ return {
+ getRootPath() {
+ return '';
+ },
+ getSourcePath() {
+ return '/adp.project/webapp';
+ }
+ };
+ }
+ };
+
beforeAll(() => {
nock(backend)
.get((path) => path.startsWith('/sap/bc/lrep/dta_folder/'))
@@ -73,6 +99,7 @@ describe('AdaptationProject', () => {
}
},
mockProject as unknown as ReaderCollection,
+ middlewareUtil,
logger
);
@@ -98,6 +125,7 @@ describe('AdaptationProject', () => {
}
},
mockProject as unknown as ReaderCollection,
+ middlewareUtil,
logger
);
@@ -106,7 +134,7 @@ describe('AdaptationProject', () => {
});
});
describe('proxy', () => {
- let server!: SuperTest;
+ let server: SuperTest;
const next = jest.fn().mockImplementation((_req, res) => res.status(200).send());
beforeAll(async () => {
const adp = new AdpPreview(
@@ -116,6 +144,7 @@ describe('AdaptationProject', () => {
}
},
mockProject as unknown as ReaderCollection,
+ middlewareUtil,
logger
);
@@ -132,7 +161,7 @@ describe('AdaptationProject', () => {
app.get(`${mockMergedDescriptor.url}/original.file`, next);
app.use((req) => fail(`${req.path} should have been intercepted.`));
- server = await supertest(app);
+ server = supertest(app);
});
test('/manifest.json', async () => {
@@ -161,4 +190,96 @@ describe('AdaptationProject', () => {
expect(next).toBeCalled();
});
});
+ describe('addApis', () => {
+ let server: SuperTest;
+ beforeAll(async () => {
+ const adp = new AdpPreview(
+ {
+ target: {
+ url: backend
+ }
+ },
+ mockProject as unknown as ReaderCollection,
+ middlewareUtil,
+ logger
+ );
+
+ const app = express();
+ adp.addApis(app);
+ server = supertest(app);
+ });
+
+ afterEach(() => {
+ mockExistsSync.mockRestore();
+ });
+
+ test('GET /adp/api/fragment', async () => {
+ const expectedNames = [{ fragmentName: 'my.fragment.xml' }, { fragmentName: 'other.fragment.xml' }];
+ mockProject.byGlob.mockResolvedValueOnce([
+ {
+ getName: () => expectedNames[0].fragmentName
+ },
+ {
+ getName: () => expectedNames[1].fragmentName
+ }
+ ]);
+ const response = await server.get('/adp/api/fragment').expect(200);
+ const data: GetFragmentsResponse = JSON.parse(response.text);
+ expect(data.fragments).toEqual(expectedNames);
+ expect(data.message).toEqual(`${expectedNames.length} fragments found in the project workspace.`);
+ });
+
+ test('GET /adp/api/fragment - returns empty array of fragment', async () => {
+ const response = await server.get('/adp/api/fragment').expect(200);
+ const data: GetFragmentsResponse = JSON.parse(response.text);
+ expect(data.fragments.length).toEqual(0);
+ expect(data.message).toEqual(`No fragments found in the project workspace.`);
+ });
+
+ test('GET /adp/api/fragment - throws error', async () => {
+ const errorMsg = 'Could not get fragment name.';
+ mockProject.byGlob.mockResolvedValueOnce([
+ {
+ getName: () => {
+ throw new Error(errorMsg);
+ }
+ }
+ ]);
+ const response = await server.get('/adp/api/fragment').expect(500);
+ const data: GetFragmentsResponse = JSON.parse(response.text);
+ expect(data.message).toEqual(errorMsg);
+ });
+
+ test('POST /adp/api/fragment - creates fragment', async () => {
+ mockExistsSync.mockReturnValue(false);
+ const fragmentName = 'Share';
+ const response = await server.post('/adp/api/fragment').send({ fragmentName }).expect(201);
+
+ const message = response.text;
+ expect(message).toBe('XML Fragment created');
+ });
+
+ test('POST /adp/api/fragment - fragment already exists', async () => {
+ mockExistsSync.mockReturnValueOnce(false).mockReturnValueOnce(true);
+ const fragmentName = 'Share';
+ const response = await server.post('/adp/api/fragment').send({ fragmentName }).expect(409);
+
+ const message = response.text;
+ expect(message).toBe(`Fragment with name "${fragmentName}" already exists`);
+ });
+
+ test('POST /adp/api/fragment - fragmentName is not provided', async () => {
+ const response = await server.post('/adp/api/fragment').send({ fragmentName: '' }).expect(400);
+
+ const message = response.text;
+ expect(message).toBe('Fragment name was not provided!');
+ });
+
+ test('POST /adp/api/fragment - throws error when fragmentName is undefined', async () => {
+ const response = await server.post('/adp/api/fragment').send({ fragmentName: undefined }).expect(500);
+
+ const message = response.text;
+ expect(message).toBe('Input must be string');
+ });
+ });
});
diff --git a/packages/preview-middleware-client/.babelrc.json b/packages/preview-middleware-client/.babelrc.json
index 81523ffc76..1c62997d89 100644
--- a/packages/preview-middleware-client/.babelrc.json
+++ b/packages/preview-middleware-client/.babelrc.json
@@ -4,13 +4,5 @@
"transform-ui5",
"@babel/preset-typescript"
],
- "plugins": [
- [
- "transform-async-to-promises",
- {
- "inlineHelpers": true
- }
- ]
- ],
"sourceMaps": true
}
\ No newline at end of file
diff --git a/packages/preview-middleware-client/src/adp/api-handler.ts b/packages/preview-middleware-client/src/adp/api-handler.ts
new file mode 100644
index 0000000000..087b4faa7c
--- /dev/null
+++ b/packages/preview-middleware-client/src/adp/api-handler.ts
@@ -0,0 +1,102 @@
+import type { Layer } from 'sap/ui/fl';
+
+export const enum ApiEndpoints {
+ FRAGMENT = '/adp/api/fragment',
+ CONTROLLER = '/adp/api/controller',
+ MANIFEST_APP_DESCRIPTOR = '/manifest.appdescr_variant'
+}
+
+export const enum RequestMethod {
+ GET = 'GET',
+ PUT = 'PUT',
+ POST = 'POST',
+ PATCH = 'PATCH',
+ DELETE = 'DELETE'
+}
+
+type Fragments = { fragmentName: string }[];
+
+export interface FragmentsResponse {
+ fragments: Fragments;
+ message: string;
+}
+
+export interface ManifestAppdescr {
+ fileName: string;
+ layer: Layer;
+ fileType: string;
+ reference: string;
+ id: string;
+ namespace: string;
+ version: string;
+ content: object[];
+}
+
+/**
+ * Requests a given endpoint
+ *
+ * @param endpoint API Endpoint
+ * @param method RequestMethod
+ * @param data Data to be sent to the server
+ * @returns Data from the server request
+ */
+export async function request(endpoint: ApiEndpoints, method: RequestMethod, data?: unknown): Promise {
+ const config: RequestInit = {
+ method,
+ body: JSON.stringify(data),
+ headers: {
+ 'content-type': 'application/json'
+ }
+ };
+
+ try {
+ const response: Response = await fetch(endpoint, config);
+
+ if (!response.ok) {
+ throw new Error(`Request failed, status: ${response.status}.`);
+ }
+
+ switch (method) {
+ case RequestMethod.GET:
+ return response.json();
+ case RequestMethod.POST:
+ /**
+ * Since POST usually creates something
+ * and returns nothing (or a message) we just parse the text from res.send(msg)
+ */
+ return response.text() as T;
+ default:
+ return response.json();
+ }
+ } catch (e) {
+ throw new Error(e.message);
+ }
+}
+
+/**
+ * Retrieves all XML fragments from the project's workspace
+ *
+ * @returns Generic Promise
+ */
+export async function getFragments(): Promise {
+ return request(ApiEndpoints.FRAGMENT, RequestMethod.GET);
+}
+
+/**
+ * Writes an XML fragment to the project's workspace
+ *
+ * @param data Data to be send to the server
+ * @returns Generic Promise
+ */
+export async function writeFragment(data: T): Promise {
+ return request(ApiEndpoints.FRAGMENT, RequestMethod.POST, data);
+}
+
+/**
+ * Retrieves manifest.appdescr_variant from the project's workspace
+ *
+ * @returns Generic Promise
+ */
+export async function getManifestAppdescr(): Promise {
+ return request(ApiEndpoints.MANIFEST_APP_DESCRIPTOR, RequestMethod.GET);
+}
diff --git a/packages/preview-middleware-client/src/adp/command-executor.ts b/packages/preview-middleware-client/src/adp/command-executor.ts
new file mode 100644
index 0000000000..449c8d9583
--- /dev/null
+++ b/packages/preview-middleware-client/src/adp/command-executor.ts
@@ -0,0 +1,52 @@
+import MessageToast from 'sap/m/MessageToast';
+import type ManagedObject from 'sap/ui/base/ManagedObject';
+import CommandFactory from 'sap/ui/rta/command/CommandFactory';
+import type RuntimeAuthoring from 'sap/ui/rta/RuntimeAuthoring';
+import type { FlexSettings } from 'sap/ui/rta/RuntimeAuthoring';
+import type DesignTimeMetadata from 'sap/ui/dt/DesignTimeMetadata';
+
+/**
+ * Class responsible for handling rta calls
+ */
+export default class CommandExecutor {
+ /**
+ *
+ * @param rta Runtime Authoring
+ */
+ constructor(private readonly rta: RuntimeAuthoring) {}
+
+ /**
+ * Generates command based on given values and executes it
+ *
+ * @param runtimeControl Managed object
+ * @param commandName Command name
+ * @param modifiedValue Modified value/s
+ * @param designMetadata Design time metadata
+ * @param flexSettings Additional flex settings
+ */
+ public async generateAndExecuteCommand(
+ runtimeControl: ManagedObject,
+ commandName: 'addXML',
+ modifiedValue: object,
+ designMetadata: DesignTimeMetadata,
+ flexSettings: FlexSettings
+ ): Promise {
+ try {
+ const command = await CommandFactory.getCommandFor(
+ runtimeControl,
+ commandName,
+ modifiedValue,
+ designMetadata,
+ flexSettings
+ );
+
+ /**
+ * The change will have pending state and will only be saved to the workspace when the user clicks save icon
+ */
+ await this.rta.getCommandStack().pushAndExecute(command);
+ } catch (e) {
+ MessageToast.show(e.message);
+ throw new Error(e.message);
+ }
+ }
+}
diff --git a/packages/preview-middleware-client/src/adp/control-utils.ts b/packages/preview-middleware-client/src/adp/control-utils.ts
new file mode 100644
index 0000000000..da0e1676ae
--- /dev/null
+++ b/packages/preview-middleware-client/src/adp/control-utils.ts
@@ -0,0 +1,48 @@
+import type ElementOverlay from 'sap/ui/dt/ElementOverlay';
+import type ManagedObject from 'sap/ui/base/ManagedObject';
+
+/**
+ * Handles calling control specific functions for retrieving control data
+ */
+export default class ControlUtils {
+ /**
+ * Returns ManagedObject runtime control
+ *
+ * @param overlayControl Overlay
+ * @returns {ManagedObject} Managed Object instance
+ */
+ public static getRuntimeControl(overlayControl: ElementOverlay): ManagedObject {
+ let runtimeControl;
+ if (overlayControl.getElementInstance) {
+ runtimeControl = overlayControl.getElementInstance();
+ } else {
+ runtimeControl = overlayControl.getElement();
+ }
+ return runtimeControl;
+ }
+
+ /**
+ * Returns control aggregation names in an array
+ *
+ * @param control Managed Object runtime control
+ * @param name Aggregation name
+ * @returns Array of control aggregations
+ */
+ public static getControlAggregationByName(control: ManagedObject, name: string): unknown[] {
+ let result: unknown[] = [];
+ const aggregation = (control ? control.getMetadata().getAllAggregations() : {})[name] as unknown as object & {
+ _sGetter: string;
+ };
+
+ if (aggregation) {
+ // This executes a _sGetter function that can vary from control to control (_sGetter can be: getContent, getItems, etc)
+ const names =
+ (aggregation._sGetter &&
+ (control as ManagedObject & { [key: string]: () => unknown })[aggregation._sGetter]()) ||
+ [];
+
+ result = Array.isArray(names) ? names : [names];
+ }
+ return result;
+ }
+}
diff --git a/packages/preview-middleware-client/src/adp/controllers/AddFragment.controller.ts b/packages/preview-middleware-client/src/adp/controllers/AddFragment.controller.ts
new file mode 100644
index 0000000000..245aecd022
--- /dev/null
+++ b/packages/preview-middleware-client/src/adp/controllers/AddFragment.controller.ts
@@ -0,0 +1,377 @@
+/** sap.m */
+import Input from 'sap/m/Input';
+import Button from 'sap/m/Button';
+import type Dialog from 'sap/m/Dialog';
+import type ComboBox from 'sap/m/ComboBox';
+import MessageToast from 'sap/m/MessageToast';
+
+/** sap.ui.core */
+import { ValueState } from 'sap/ui/core/library';
+import type UI5Element from 'sap/ui/core/Element';
+import Controller from 'sap/ui/core/mvc/Controller';
+
+/** sap.ui.base */
+import type Event from 'sap/ui/base/Event';
+import type ManagedObject from 'sap/ui/base/ManagedObject';
+import type ManagedObjectMetadata from 'sap/ui/base/ManagedObjectMetadata';
+
+/** sap.ui.model */
+import JSONModel from 'sap/ui/model/json/JSONModel';
+
+/** sap.ui.rta */
+import type RuntimeAuthoring from 'sap/ui/rta/RuntimeAuthoring';
+
+/** sap.ui.dt */
+import OverlayRegistry from 'sap/ui/dt/OverlayRegistry';
+import type ElementOverlay from 'sap/ui/dt/ElementOverlay';
+
+import ControlUtils from '../control-utils';
+import CommandExecutor from '../command-executor';
+import { ManifestAppdescr, getFragments, getManifestAppdescr, writeFragment } from '../api-handler';
+
+interface CreateFragmentProps {
+ fragmentName: string;
+ index: string | number;
+ targetAggregation: string;
+}
+
+/**
+ * @namespace open.ux.preview.client.adp.controllers
+ */
+export default class AddFragment extends Controller {
+ /**
+ * Runtime control managed object
+ */
+ public runtimeControl: ManagedObject;
+ /**
+ * JSON Model that has the data
+ */
+ public model: JSONModel;
+ /**
+ * Dialog instance
+ */
+ public dialog: Dialog;
+ /**
+ * Runtime Authoring
+ */
+ private rta: RuntimeAuthoring;
+ /**
+ * Control Overlays
+ */
+ private overlays: UI5Element;
+ /**
+ * RTA Command Executor
+ */
+ private commandExecutor: CommandExecutor;
+
+ constructor(name: string, overlays: UI5Element, rta: RuntimeAuthoring) {
+ super(name);
+ this.rta = rta;
+ this.overlays = overlays;
+ this.commandExecutor = new CommandExecutor(this.rta);
+ }
+
+ /**
+ * Initializes controller, fills model with data and opens the dialog
+ */
+ async onInit() {
+ this.model = new JSONModel();
+
+ this.dialog = this.byId('addNewFragmentDialog') as unknown as Dialog;
+
+ await this.buildDialogData();
+
+ this.getView()?.setModel(this.model);
+
+ this.dialog.open();
+ }
+
+ /**
+ * Handles the change in aggregations
+ *
+ * @param event Event
+ */
+ onAggregationChanged(event: Event) {
+ const source = event.getSource();
+
+ const selectedKey = source.getSelectedKey();
+ const selectedItem = source.getSelectedItem();
+
+ let selectedItemText = '';
+ if (selectedItem) {
+ selectedItemText = selectedItem.getText();
+ }
+
+ this.model.setProperty('/selectedAggregation/key', selectedKey);
+ this.model.setProperty('/selectedAggregation/value', selectedItemText);
+
+ let newSelectedControlChildren: string[] | number[] = Object.keys(
+ ControlUtils.getControlAggregationByName(this.runtimeControl, selectedItemText)
+ );
+
+ newSelectedControlChildren = newSelectedControlChildren.map((key) => {
+ return parseInt(key);
+ });
+
+ const updatedIndexArray: { key: number; value: number }[] = this.fillIndexArray(newSelectedControlChildren);
+
+ this.model.setProperty('/index', updatedIndexArray);
+ this.model.setProperty('/selectedIndex', updatedIndexArray.length - 1);
+ }
+
+ /**
+ * Handles the change in target indexes
+ *
+ * @param event Event
+ */
+ onIndexChanged(event: Event) {
+ const source = event.getSource();
+ const selectedIndex = source.getSelectedItem()?.getText();
+ this.model.setProperty('/selectedIndex', selectedIndex);
+ }
+
+ /**
+ * Handles fragment name input change
+ *
+ * @param event Event
+ */
+ onFragmentNameInputChange(event: Event) {
+ const source = event.getSource();
+
+ const fragmentName: string = source.getValue().trim();
+ const fragmentList: { fragmentName: string }[] = this.model.getProperty('/fragmentList');
+
+ if (fragmentName.length <= 0) {
+ this.dialog.getBeginButton().setEnabled(false);
+ source.setValueState(ValueState.None);
+ this.model.setProperty('/newFragmentName', null);
+ } else {
+ const fileExists = fragmentList.find((f: { fragmentName: string }) => {
+ return f.fragmentName === `${fragmentName}.fragment.xml`;
+ });
+
+ const isValidName = /^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(fragmentName);
+
+ if (fileExists) {
+ source.setValueState(ValueState.Error);
+ source.setValueStateText(
+ 'Enter a different name. The fragment name that you entered already exists in your project.'
+ );
+ this.dialog.getBeginButton().setEnabled(false);
+ } else if (!isValidName) {
+ source.setValueState(ValueState.Error);
+ source.setValueStateText('A Fragment Name cannot contain white spaces or special characters.');
+ this.dialog.getBeginButton().setEnabled(false);
+ } else {
+ this.dialog.getBeginButton().setEnabled(true);
+ source.setValueState(ValueState.None);
+ this.model.setProperty('/newFragmentName', fragmentName);
+ }
+ }
+ }
+
+ /**
+ * Handles create button press
+ *
+ * @param event Event
+ */
+ async onCreateBtnPress(event: Event) {
+ const source = event.getSource