From ac0adb216ab86da5b20d00e4d155e226b305e398 Mon Sep 17 00:00:00 2001 From: Nikita B <39880364+nikmace@users.noreply.github.com> Date: Wed, 20 Sep 2023 17:08:24 +0300 Subject: [PATCH] feat(adaptation-project): Add Fragment enhancement (#1183) * initial crude implementation * Empty-Commit * typescript support for rta coding * removed odd change * removed hardcoded file hosting * enhanced example to also use a submodule * changing folder and export name * feat: added fragment class to test the src-rta folder * feat: added new api endpoint to get fragments from workspace * enable ui5 types * refactor: put code into new folder * enable ui5 transpiling * fix: script path issue * lock file * config again * linting * win issue * chore: added --glob flag to rimraf dist * fix tests * added info to readme * fix: typo * feat: add ADP API; * feat: enhance typings * feat: added helper functions to utility class * feat: separated code into classes * feat: changed initialization of new context item * linting errors * fix: tests * refactor: rollback to original method * added enum example * Linting auto fix commit * feat: added an api handler to client requests * fixed auto-completion in VSCode * feat: change sap imports * fix * fix: tests again * chore: added new package and script for copying files * feat: added templates for fragment and controller * feat: added enums for server code * feat: split router endpoints into separate functions * feat: added class for handling api requests * feat: added class that handles requests from client * refactor: sorted imports, cleanup * chore: added packages for sanitization and rate limiting * fix: rate limiting for routes * fix: sanitize strings to prevent accessing alt sources * chore: added node cache package * feat: added manifest descr interface * feat: add caching for new route * feat: add caching and new route handler * feat: added new route to client, fixed json bug * feat: add api request handler * removed wrong entry * refactor: made templates into two folders, fixed paths * enhanced the types module with SAPUI5 types and required enhancements * moved sapui5 types and enhancements into central types module and use it here * Linting auto fix commit * feat: implement search for fragments with change file * feat: improve typings * refactor: hungarian notations, add typings * refactor: remove and add missing types for any * fix: build issues * fix the build * Linting auto fix commit * fix tell jest to ignore ui5 files * fix: lint errors, jsdocs * fix: some major sonar issues * fix: some sonar code smells * type fixes * merging type fixes * Linting auto fix commit * Linting auto fix commit * fix: eslint parsing error for windows * chore: removed root and parser from eslintrc * chore: :fire: removed express-rate-limit package since the server runs only in dev mode * fix: some sonar major issues * refactor: change templates paths, remove script * fix: sonar issues, refactor fragment dialog code * fix: some eslint/ts errors * fix: regex backtracking issue * fix: typings for promise returning function * refactor: change manfiest descr route logic * set browserlist to remove polyfills from generated ui5 code * refactor: remove the class, export indiv functions * refactor: reduce cognitive complexity of a function * revert wrong comma * revert wrong comma * fix: some sonar code smells * fix: congnitive complexity * feat: change dialog init and refactor code * refactor: change folder name for all dialogs * fix: lint errors, sonar bug * refactor: change status codes to enum * fix: sanitize user provided input * refactor: remove unused code for filtering fragments with change file * enhanced the readme * chore: added --glob flag to rimraf dist * refactor: made templates into two folders, fixed paths * feat: add folders for fragments and controllers * refactor: remove node-cache module * feat: add requested changes * fix: controller loading * suggestion git push * added test for new functionality * setup for jest testing of client sources * turn-off contradicting rule * feat: add controller and change fragment structure * refactor: change initializing of a dialog * feat: add controller specific methods * feat: add base interface for all dialogs * feat: move code to controller and cleanup * feat: add rta border class to xml fragment * test: fixed existing tests * refactor: reorder imports, add missing jsdocs * refactor: doc strings * refactor: change method name * feat: start adding tests * fix: mocking of fs * real fix * fix: post test mock * more specific rule ignore * feat: add some tests * fix: add requested changes * feat: add more test suites * feat: add more tests * refactor: comment out broken test * feat: add api handler tests * test: add example test for controller * review feedback: supertest * forgetful me * mocked JSONModel * fix: mock * test: added controller tests * fix: mock * refactor: fragments map * fix: last controller test * test: add missing expect statements * simplify enablement * further cleanup * fix: tests * forgot the lockfile * moved unit test * last fixes after merge * more concise mocking * use more concise mocking * global sap mocking * use global sap * fix: eslint errors * refactor: change xml fragment * refactor: remove some ts-ignores * fix: ui bug in dialog * fix: remove classes for add fragment xml file * fixed merge errors * not needed * chore: added changeset * fix: add requested changes * refactor: remove bcp reference * refactor: remove all doc references in code * simplified tests * refactor: remove unused code * fix: tests after refactor * refactor: remove unnecessary assertion * feat: change fragment instantiation * refactor: remove file that does not belong to this PR * refactor: change initialization of controller and view * fix: existing tests * refactor: switch statement to nested ifs * refactor: move name check block inside else statement * replace any * refactor: remove code that is not used anywhere * refactor: change typing * refactor: change typings for get source method * removing any * typos * fix: linting error after type fix * fix: sonar code smells * test: add missing tests * chore: fix changeset message typo * test: improve tests * refactor: remove unused code, fix validation bug * test: add missing suite --------- Co-authored-by: Tobias Queck Co-authored-by: github-actions[bot] --- .changeset/loud-insects-tease.md | 7 + packages/adp-tooling/.eslintignore | 2 +- packages/adp-tooling/README.md | 15 +- packages/adp-tooling/jest.config.js | 2 +- packages/adp-tooling/package.json | 1 + .../adp-tooling/src/preview/adp-preview.ts | 41 +- .../adp-tooling/src/preview/routes-handler.ts | 115 ++++++ packages/adp-tooling/src/types.ts | 41 ++ packages/adp-tooling/src/writer/index.ts | 2 +- .../templates/{ => project}/gitignore.tmpl | 0 .../templates/{ => project}/package.json | 0 .../templates/{ => project}/ui5.yaml | 0 .../{ => project}/webapp/i18n/i18n.properties | 0 .../webapp/manifest.appdescr_variant | 0 .../adp-tooling/templates/rta/fragment.xml | 4 + .../test/unit/preview/adp-preview.test.ts | 127 +++++- .../preview-middleware-client/.babelrc.json | 8 - .../src/adp/api-handler.ts | 102 +++++ .../src/adp/command-executor.ts | 52 +++ .../src/adp/control-utils.ts | 48 +++ .../adp/controllers/AddFragment.controller.ts | 377 +++++++++++++++++ .../src/adp/init-dialogs.ts | 43 ++ .../preview-middleware-client/src/adp/init.ts | 12 +- .../src/adp/ui/AddFragment.view.xml | 59 +++ .../test/__mock__/sap/m/MessageToast.ts | 4 + .../test/__mock__/sap/ui/base/DataType.ts | 4 + .../test/__mock__/sap/ui/core/library.ts | 4 + .../__mock__/sap/ui/core/mvc/Controller.ts | 19 + .../test/__mock__/sap/ui/core/mvc/XMLView.ts | 4 + .../__mock__/sap/ui/dt/OverlayRegistry.ts | 10 + .../test/__mock__/sap/ui/fl/Utils.ts | 4 + .../__mock__/sap/ui/model/json/JSONModel.ts | 5 + .../__mock__/sap/ui/rta/RuntimeAuthoring.ts | 1 + .../sap/ui/rta/command/CommandFactory.ts | 4 + .../test/unit/adp/api-handler.test.ts | 125 ++++++ .../test/unit/adp/command-executor.test.ts | 53 +++ .../test/unit/adp/control-utils.test.ts | 78 ++++ .../AddFragment.controller.test.ts | 384 ++++++++++++++++++ .../test/unit/adp/init-dialogs.test.ts | 32 ++ .../test/unit/adp/init.test.ts | 15 +- .../types/sap.ui.fl.d.ts | 1 - .../types/sap.ui.rta.d.ts | 5 +- .../types/sap.ushell.ts | 2 +- packages/preview-middleware/package.json | 2 +- .../preview-middleware/src/ui5/middleware.ts | 17 +- pnpm-lock.yaml | 114 +++--- 46 files changed, 1854 insertions(+), 91 deletions(-) create mode 100644 .changeset/loud-insects-tease.md create mode 100644 packages/adp-tooling/src/preview/routes-handler.ts rename packages/adp-tooling/templates/{ => project}/gitignore.tmpl (100%) rename packages/adp-tooling/templates/{ => project}/package.json (100%) rename packages/adp-tooling/templates/{ => project}/ui5.yaml (100%) rename packages/adp-tooling/templates/{ => project}/webapp/i18n/i18n.properties (100%) rename packages/adp-tooling/templates/{ => project}/webapp/manifest.appdescr_variant (100%) create mode 100644 packages/adp-tooling/templates/rta/fragment.xml create mode 100644 packages/preview-middleware-client/src/adp/api-handler.ts create mode 100644 packages/preview-middleware-client/src/adp/command-executor.ts create mode 100644 packages/preview-middleware-client/src/adp/control-utils.ts create mode 100644 packages/preview-middleware-client/src/adp/controllers/AddFragment.controller.ts create mode 100644 packages/preview-middleware-client/src/adp/init-dialogs.ts create mode 100644 packages/preview-middleware-client/src/adp/ui/AddFragment.view.xml create mode 100644 packages/preview-middleware-client/test/__mock__/sap/m/MessageToast.ts create mode 100644 packages/preview-middleware-client/test/__mock__/sap/ui/base/DataType.ts create mode 100644 packages/preview-middleware-client/test/__mock__/sap/ui/core/library.ts create mode 100644 packages/preview-middleware-client/test/__mock__/sap/ui/core/mvc/Controller.ts create mode 100644 packages/preview-middleware-client/test/__mock__/sap/ui/core/mvc/XMLView.ts create mode 100644 packages/preview-middleware-client/test/__mock__/sap/ui/dt/OverlayRegistry.ts create mode 100644 packages/preview-middleware-client/test/__mock__/sap/ui/fl/Utils.ts create mode 100644 packages/preview-middleware-client/test/__mock__/sap/ui/model/json/JSONModel.ts create mode 100644 packages/preview-middleware-client/test/__mock__/sap/ui/rta/command/CommandFactory.ts create mode 100644 packages/preview-middleware-client/test/unit/adp/api-handler.test.ts create mode 100644 packages/preview-middleware-client/test/unit/adp/command-executor.test.ts create mode 100644 packages/preview-middleware-client/test/unit/adp/control-utils.test.ts create mode 100644 packages/preview-middleware-client/test/unit/adp/controllers/AddFragment.controller.test.ts create mode 100644 packages/preview-middleware-client/test/unit/adp/init-dialogs.test.ts 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