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