diff --git a/.changeset/long-dragons-hope.md b/.changeset/long-dragons-hope.md new file mode 100644 index 0000000000..017f5a8845 --- /dev/null +++ b/.changeset/long-dragons-hope.md @@ -0,0 +1,9 @@ +--- +'@sap-ux-private/control-property-editor-common': patch +'@sap-ux-private/preview-middleware-client': patch +'@sap-ux/preview-middleware': patch +'@sap-ux/adp-tooling': patch +'@sap-ux/types': patch +--- + +feat: Quick Action For Add New Annotation File diff --git a/.changeset/wet-oranges-do.md b/.changeset/wet-oranges-do.md new file mode 100644 index 0000000000..d6bbb4ea30 --- /dev/null +++ b/.changeset/wet-oranges-do.md @@ -0,0 +1,5 @@ +--- +'@sap-ux/control-property-editor': patch +--- + +feat: Quick Action For Add New Annotation File diff --git a/packages/adp-tooling/package.json b/packages/adp-tooling/package.json index 82fbc5c5b3..9ff38eaa01 100644 --- a/packages/adp-tooling/package.json +++ b/packages/adp-tooling/package.json @@ -43,6 +43,7 @@ "@sap-ux/project-input-validator": "workspace:*", "@sap-ux/system-access": "workspace:*", "@sap-ux/ui5-config": "workspace:*", + "@sap-ux/odata-service-writer": "workspace:*", "@sap-ux/i18n": "workspace:*", "adm-zip": "0.5.10", "ejs": "3.1.10", diff --git a/packages/adp-tooling/src/base/abap/manifest-service.ts b/packages/adp-tooling/src/base/abap/manifest-service.ts index 30b80d59d3..145e8a47db 100644 --- a/packages/adp-tooling/src/base/abap/manifest-service.ts +++ b/packages/adp-tooling/src/base/abap/manifest-service.ts @@ -7,7 +7,7 @@ import { isAxiosError, type AbapServiceProvider, type Ui5AppInfoContent } from ' import { getWebappFiles } from '../helper'; import type { DescriptorVariant } from '../../types'; -type DataSources = Record; +export type DataSources = Record; /** * Retrieves the inbound navigation configurations from the project's manifest. diff --git a/packages/adp-tooling/src/base/change-utils.ts b/packages/adp-tooling/src/base/change-utils.ts index eb12ea5047..656756a788 100644 --- a/packages/adp-tooling/src/base/change-utils.ts +++ b/packages/adp-tooling/src/base/change-utils.ts @@ -36,16 +36,18 @@ export function writeAnnotationChange( projectPath: string, timestamp: number, annotation: AnnotationsData['annotation'], - change: ManifestChangeProperties, + change: ManifestChangeProperties | undefined, fs: Editor ): void { try { - const changeFileName = `id_${timestamp}_addAnnotationsToOData.change`; const changesFolderPath = path.join(projectPath, DirName.Webapp, DirName.Changes); - const changeFilePath = path.join(changesFolderPath, changeFileName); const annotationsFolderPath = path.join(changesFolderPath, DirName.Annotations); - - writeChangeToFile(changeFilePath, change, fs); + if (change) { + const changeFileName = `id_${timestamp}_addAnnotationsToOData.change`; + const changeFilePath = path.join(changesFolderPath, changeFileName); + change.fileName = `${change.fileName}_addAnnotationsToOData`; + writeChangeToFile(changeFilePath, change, fs); + } if (!annotation.filePath) { const annotationsTemplate = path.join( diff --git a/packages/adp-tooling/src/preview/adp-preview.ts b/packages/adp-tooling/src/preview/adp-preview.ts index 9713ccf755..f71f0a1fee 100644 --- a/packages/adp-tooling/src/preview/adp-preview.ts +++ b/packages/adp-tooling/src/preview/adp-preview.ts @@ -11,7 +11,14 @@ import type { LayeredRepositoryService, MergedAppDescriptor } from '@sap-ux/axio import RoutesHandler from './routes-handler'; import type { AdpPreviewConfig, CommonChangeProperties, DescriptorVariant, OperationType } from '../types'; import type { Editor } from 'mem-fs-editor'; -import { addXmlFragment, isAddXMLChange, moduleNameContentMap, tryFixChange } from './change-handler'; +import { + addAnnotationFile, + addXmlFragment, + isAddAnnotationChange, + isAddXMLChange, + moduleNameContentMap, + tryFixChange +} from './change-handler'; declare global { // false positive, const can't be used here https://github.com/eslint/eslint/issues/15896 // eslint-disable-next-line no-var @@ -21,7 +28,8 @@ declare global { export const enum ApiRoutes { FRAGMENT = '/adp/api/fragment', CONTROLLER = '/adp/api/controller', - CODE_EXT = '/adp/api/code_ext/:controllerName' + CODE_EXT = '/adp/api/code_ext/:controllerName', + ANNOTATION = '/adp/api/annotation' } /** @@ -198,6 +206,11 @@ export class AdpPreview { router.post(ApiRoutes.CONTROLLER, this.routesHandler.handleWriteControllerExt as RequestHandler); router.get(ApiRoutes.CODE_EXT, this.routesHandler.handleGetControllerExtensionData as RequestHandler); + router.post(ApiRoutes.ANNOTATION, this.routesHandler.handleCreateAnnotationFile as RequestHandler); + router.get( + ApiRoutes.ANNOTATION, + this.routesHandler.handleGetAllAnnotationFilesMappedByDataSource as RequestHandler + ); } /** @@ -225,6 +238,15 @@ export class AdpPreview { if (isAddXMLChange(change)) { addXmlFragment(this.util.getProject().getSourcePath(), change, fs, logger); } + if (isAddAnnotationChange(change)) { + await addAnnotationFile( + this.util.getProject().getSourcePath(), + this.util.getProject().getRootPath(), + change, + fs, + logger + ); + } break; default: // no need to handle delete changes diff --git a/packages/adp-tooling/src/preview/change-handler.ts b/packages/adp-tooling/src/preview/change-handler.ts index 977138ee70..1700af5402 100644 --- a/packages/adp-tooling/src/preview/change-handler.ts +++ b/packages/adp-tooling/src/preview/change-handler.ts @@ -1,10 +1,16 @@ import type { Editor } from 'mem-fs-editor'; -import type { AddXMLChange, CommonChangeProperties, CodeExtChange } from '../types'; -import { join } from 'path'; -import { DirName } from '@sap-ux/project-access'; -import type { Logger } from '@sap-ux/logger'; +import type { AddXMLChange, CommonChangeProperties, CodeExtChange, AnnotationFileChange } from '../types'; +import { ChangeType } from '../types'; +import { basename, join } from 'path'; +import { DirName, FileName } from '@sap-ux/project-access'; +import type { Logger, ToolsLogger } from '@sap-ux/logger'; import { render } from 'ejs'; import { randomBytes } from 'crypto'; +import { ManifestService } from '../base/abap/manifest-service'; +import { getAdpConfig, getVariant } from '../base/helper'; +import { createAbapServiceProvider } from '@sap-ux/system-access'; +import { getAnnotationNamespaces } from '@sap-ux/odata-service-writer'; +import { generateChange } from '../writer/editors'; const OBJECT_PAGE_CUSTOM_SECTION = 'OBJECT_PAGE_CUSTOM_SECTION'; const CUSTOM_ACTION = 'CUSTOM_ACTION'; @@ -167,6 +173,17 @@ export function isAddXMLChange(change: CommonChangeProperties): change is AddXML return change.changeType === 'addXML' || change.changeType === 'addXMLAtExtensionPoint'; } +/** + * Determines whether a given change is of type `AnnotationFileChange`. + * + * @param {CommonChangeProperties} change - The change object to check. + * @returns {boolean} `true` if the `changeType` is either 'appdescr_app_addAnnotationsToOData', + * indicating the change is of type `AnnotationFileChange`. + */ +export function isAddAnnotationChange(change: CommonChangeProperties): change is AnnotationFileChange { + return change.changeType === 'appdescr_app_addAnnotationsToOData'; +} + /** * Asynchronously adds an XML fragment to the project if it doesn't already exist. * @@ -196,3 +213,63 @@ export function addXmlFragment(basePath: string, change: AddXMLChange, fs: Edito logger.error(`Failed to create XML Fragment "${fragmentPath}": ${error}`); } } + +/** + * Asynchronously adds an XML fragment to the project if it doesn't already exist. + * + * @param {string} basePath - The base path of the project. + * @param {string} projectRoot - The root path of the project. + * @param {AddXMLChange} change - The change data, including the fragment path. + * @param {Editor} fs - The mem-fs-editor instance. + * @param {Logger} logger - The logging instance. + */ +export async function addAnnotationFile( + basePath: string, + projectRoot: string, + change: AnnotationFileChange, + fs: Editor, + logger: Logger +): Promise { + const { dataSourceId, annotations, dataSource } = change.content; + const annotationDataSourceKey = annotations[0]; + const annotationUriSegments = dataSource[annotationDataSourceKey].uri.split('/'); + annotationUriSegments.shift(); + const fullPath = join(basePath, DirName.Changes, ...annotationUriSegments); + try { + const manifestService = await getManifestService(projectRoot, logger); + const metadata = await manifestService.getDataSourceMetadata(dataSourceId); + const datasoruces = await manifestService.getManifestDataSources(); + const namespaces = getAnnotationNamespaces({ metadata }); + await generateChange( + projectRoot, + ChangeType.ADD_ANNOTATIONS_TO_ODATA, + { + annotation: { + dataSource: dataSourceId, + namespaces, + serviceUrl: datasoruces[dataSourceId].uri, + fileName: basename(dataSource[annotationDataSourceKey].uri) + }, + variant: getVariant(projectRoot), + isCommand: false + }, + fs + ); + } catch (error) { + logger.error(`Failed to create Local Annotation File "${fullPath}": ${error}`); + } +} + +/** + * Returns manifest service. + * + * @param {string} basePath - The base path of the project. + * @param {Logger} logger - The logging instance. + * @returns Promise + */ +async function getManifestService(basePath: string, logger: Logger): Promise { + const variant = getVariant(basePath); + const { target, ignoreCertErrors = false } = await getAdpConfig(basePath, join(basePath, FileName.Ui5Yaml)); + const provider = await createAbapServiceProvider(target, { ignoreCertErrors }, true, logger); + return await ManifestService.initMergedManifest(provider, basePath, variant, logger as unknown as ToolsLogger); +} diff --git a/packages/adp-tooling/src/preview/routes-handler.ts b/packages/adp-tooling/src/preview/routes-handler.ts index c3286fcf34..4798fbf121 100644 --- a/packages/adp-tooling/src/preview/routes-handler.ts +++ b/packages/adp-tooling/src/preview/routes-handler.ts @@ -10,14 +10,35 @@ import type { ReaderCollection, Resource } from '@ui5/fs'; import type { NextFunction, Request, Response } from 'express'; import { TemplateFileName, HttpStatusCodes } from '../types'; -import { DirName } from '@sap-ux/project-access'; -import type { CodeExtChange } from '../types'; +import { DirName, FileName } from '@sap-ux/project-access'; +import { ChangeType, type CodeExtChange } from '../types'; +import { generateChange } from '../writer/editors'; +import { ManifestService } from '../base/abap/manifest-service'; +import type { DataSources } from '../base/abap/manifest-service'; +import { getAdpConfig, getVariant } from '../base/helper'; +import { getAnnotationNamespaces } from '@sap-ux/odata-service-writer'; +import { createAbapServiceProvider } from '@sap-ux/system-access'; interface WriteControllerBody { controllerName: string; projectId: string; } +interface AnnotationFileDetails { + fileName?: string; + annotationPath?: string; + annotationPathFromRoot?: string; + annotationExistsInWS: boolean; +} + +interface AnnotationDataSourceMap { + [key: string]: { + serviceUrl: string; + isRunningInBAS: boolean; + annotationDetails: AnnotationFileDetails; + }; +} + /** * @description Handles API Routes */ @@ -244,4 +265,140 @@ export default class RoutesHandler { next(e); } }; + /** + * Handler for writing an annotation file to the workspace. + * + * @param req Request + * @param res Response + * @param next Next Function + */ + public handleCreateAnnotationFile = async (req: Request, res: Response, next: NextFunction) => { + try { + const { dataSource, serviceUrl } = req.body as { dataSource: string; serviceUrl: string }; + + if (!dataSource) { + res.status(HttpStatusCodes.BAD_REQUEST).send('No datasource found in manifest!'); + this.logger.debug('Bad request. Could not find a datasource in manifest!'); + return; + } + const project = this.util.getProject(); + const projectRoot = project.getRootPath(); + const manifestService = await this.getManifestService(); + const metadata = await manifestService.getDataSourceMetadata(dataSource); + const namespaces = getAnnotationNamespaces({ metadata }); + const fsEditor = await generateChange( + projectRoot, + ChangeType.ADD_ANNOTATIONS_TO_ODATA, + { + annotation: { + dataSource, + namespaces, + serviceUrl: serviceUrl + }, + variant: getVariant(projectRoot), + isCommand: false + } + ); + fsEditor.commit((err) => this.logger.error(err)); + + const message = 'Annotation file created!'; + res.status(HttpStatusCodes.CREATED).send(message); + } catch (e) { + const sanitizedMsg = sanitize(e.message); + this.logger.error(sanitizedMsg); + res.status(HttpStatusCodes.INTERNAL_SERVER_ERROR).send(sanitizedMsg); + next(e); + } + }; + + /** + * Handler for mapping annotation files with datasoruce. + * + * @param _req Request + * @param res Response + * @param next Next Function + */ + public handleGetAllAnnotationFilesMappedByDataSource = async (_req: Request, res: Response, next: NextFunction) => { + try { + const isRunningInBAS = isAppStudio(); + + const manifestService = await this.getManifestService(); + const dataSources = manifestService.getManifestDataSources(); + const apiResponse: AnnotationDataSourceMap = {}; + + for (const dataSourceId in dataSources) { + if (dataSources[dataSourceId].type === 'OData') { + apiResponse[dataSourceId] = { + annotationDetails: { + annotationExistsInWS: false + }, + serviceUrl: dataSources[dataSourceId].uri, + isRunningInBAS: isRunningInBAS + }; + } + this.fillAnnotationDataSourceMap(dataSources, dataSourceId, apiResponse); + } + this.sendFilesResponse(res, apiResponse); + } catch (e) { + this.handleErrorMessage(res, next, e); + } + }; + + /** + * Add local annotation details to api response. + * + * @param dataSources DataSources + * @param dataSourceId string + * @param apiResponse AnnotationDataSourceMap + */ + private fillAnnotationDataSourceMap( + dataSources: DataSources, + dataSourceId: string, + apiResponse: AnnotationDataSourceMap + ): void { + const project = this.util.getProject(); + const getPath = (projectPath: string, relativePath: string): string => + path.join(projectPath, DirName.Changes, relativePath).split(path.sep).join(path.posix.sep); + const annotations = dataSources[dataSourceId].settings?.annotations + ? [...dataSources[dataSourceId].settings.annotations].reverse() + : []; + for (const annotation of annotations) { + const annotationSetting = dataSources[annotation]; + if (annotationSetting.type === 'ODataAnnotation') { + const ui5NamespaceUri = `ui5://${project.getNamespace()}`; + if (annotationSetting.uri.startsWith(ui5NamespaceUri)) { + const localAnnotationUri = annotationSetting.uri.replace(ui5NamespaceUri, ''); + const annotationPath = getPath(project.getSourcePath(), localAnnotationUri); + const annotationPathFromRoot = getPath(project.getName(), localAnnotationUri); + const annotationExists = fs.existsSync(annotationPath); + apiResponse[dataSourceId].annotationDetails = { + fileName: path.parse(localAnnotationUri).base, + annotationPath: os.platform() === 'win32' ? `/${annotationPath}` : annotationPath, + annotationPathFromRoot, + annotationExistsInWS: annotationExists + }; + } + if (apiResponse[dataSourceId].annotationDetails.annotationExistsInWS) { + break; + } + } + } + } + + /** + * Returns manifest service. + * + * @returns Promise + */ + private async getManifestService(): Promise { + const project = this.util.getProject(); + const basePath = project.getRootPath(); + const variant = getVariant(basePath); + const { target, ignoreCertErrors = false } = await getAdpConfig( + basePath, + path.join(basePath, FileName.Ui5Yaml) + ); + const provider = await createAbapServiceProvider(target, { ignoreCertErrors }, true, this.logger); + return await ManifestService.initMergedManifest(provider, basePath, variant, this.logger); + } } diff --git a/packages/adp-tooling/src/types.ts b/packages/adp-tooling/src/types.ts index c42d028878..311979601b 100644 --- a/packages/adp-tooling/src/types.ts +++ b/packages/adp-tooling/src/types.ts @@ -200,6 +200,22 @@ export interface CodeExtChange extends CommonChangeProperties { }; } +export interface AnnotationFileChange extends CommonChangeProperties { + changeType: 'appdescr_app_addAnnotationsToOData'; + creation: string; + content: { + dataSourceId: string; + annotations: string[]; + annotationsInsertPosition: 'END'; + dataSource: { + [fileName: string]: { + uri: string; + type: 'ODataAnnotation'; + }; + }; + }; +} + export interface ParamCheck { shouldApply: boolean; value: string | undefined; @@ -357,6 +373,8 @@ export type GeneratorData = T extends ChangeType.ADD_ANNOT export interface AnnotationsData { variant: DescriptorVariant; + /** Flag for differentiating the annotation creation call from CLI and from CPE */ + isCommand: boolean; annotation: { /** Optional name of the annotation file. */ fileName?: string; diff --git a/packages/adp-tooling/src/writer/changes/writers/annotations-writer.ts b/packages/adp-tooling/src/writer/changes/writers/annotations-writer.ts index 8a086bd520..91ced76724 100644 --- a/packages/adp-tooling/src/writer/changes/writers/annotations-writer.ts +++ b/packages/adp-tooling/src/writer/changes/writers/annotations-writer.ts @@ -70,7 +70,11 @@ export class AnnotationsWriter implements IWriter { } const content = this.constructContent(data); const timestamp = Date.now(); - const change = getChange(variant, timestamp, content, ChangeType.ADD_ANNOTATIONS_TO_ODATA); + let change; + // When created via command change need to be created else change is created via RTA. + if (data.isCommand) { + change = getChange(variant, timestamp, content, ChangeType.ADD_ANNOTATIONS_TO_ODATA); + } writeAnnotationChange(this.projectPath, timestamp, data.annotation, change, this.fs); } } 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 282f3e7316..386e905773 100644 --- a/packages/adp-tooling/test/unit/preview/adp-preview.test.ts +++ b/packages/adp-tooling/test/unit/preview/adp-preview.test.ts @@ -6,11 +6,16 @@ import type { Editor } from 'mem-fs-editor'; import { type Logger, ToolsLogger } from '@sap-ux/logger'; import type { ReaderCollection } from '@ui5/fs'; import type { SuperTest, Test } from 'supertest'; -import { readFileSync, existsSync, writeFileSync } from 'fs'; +import * as fs from 'fs'; import { AdpPreview } from '../../../src/preview/adp-preview'; import type { AdpPreviewConfig, CommonChangeProperties } from '../../../src'; +import * as helper from '../../../src/base/helper'; +import * as editors from '../../../src/writer/editors'; +import * as manifestService from '../../../src/base/abap/manifest-service'; import { addXmlFragment, tryFixChange } from '../../../src/preview/change-handler'; +import * as systemAccess from '@sap-ux/system-access/dist/base/connect'; +import * as serviceWriter from '@sap-ux/odata-service-writer/dist/data/annotations'; interface GetFragmentsResponse { fragments: { fragmentName: string }[]; @@ -65,12 +70,12 @@ jest.mock('fs', () => ({ copyFileSync: jest.fn() })); -const mockWriteFileSync = writeFileSync as jest.Mock; -const mockExistsSync = existsSync as jest.Mock; +const mockWriteFileSync = fs.writeFileSync as jest.Mock; +const mockExistsSync = fs.existsSync as jest.Mock; describe('AdaptationProject', () => { const backend = 'https://sap.example'; - const descriptorVariant = readFileSync( + const descriptorVariant = fs.readFileSync( join(__dirname, '../../fixtures/adaptation-project/webapp', 'manifest.appdescr_variant'), 'utf-8' ); @@ -115,6 +120,9 @@ describe('AdaptationProject', () => { }, getName() { return 'adp.project'; + }, + getNamespace() { + return 'adp/project'; } }; } @@ -468,7 +476,61 @@ describe('AdaptationProject', () => { middlewareUtil, logger ); + jest.spyOn(helper, 'getVariant').mockReturnValue({ + content: [], + id: 'adp/project', + layer: 'VENDOR', + namespace: 'test', + reference: 'adp/project' + }); + jest.spyOn(helper, 'getAdpConfig').mockResolvedValue({ + target: { + destination: 'testDestination' + }, + ignoreCertErrors: false + }); + jest.spyOn(systemAccess, 'createAbapServiceProvider').mockResolvedValue({} as any); + jest.spyOn(manifestService.ManifestService, 'initMergedManifest').mockResolvedValue({ + getDataSourceMetadata: jest.fn().mockResolvedValue(` + + + + + + +`), + getManifestDataSources: jest.fn().mockReturnValue({ + mainService: { + type: 'OData', + uri: 'main/service/uri', + settings: { + annotations: ['annotation0'] + } + }, + annotation0: { + type: 'ODataAnnotation', + uri: `ui5://adp/project/annotation0.xml` + }, + secondaryService: { + type: 'OData', + uri: 'secondary/service/uri', + settings: { + annotations: [] + } + } + }) + } as any); + jest.spyOn(fs, 'existsSync').mockReturnValueOnce(true).mockReturnValue(false); + jest.spyOn(serviceWriter, 'getAnnotationNamespaces').mockReturnValue([ + { + namespace: 'com.sap.gateway.srvd.c_salesordermanage_sd.v0001', + alias: 'test' + } + ]); + jest.spyOn(editors, 'generateChange').mockResolvedValue({ + commit: jest.fn().mockResolvedValue('commited') + } as any); const app = express(); app.use(express.json()); adp.addApis(app); @@ -647,5 +709,27 @@ describe('AdaptationProject', () => { expect(e.message).toEqual(errorMsg); } }); + test('POST /adp/api/annotation - throws error when controller name is undefined', async () => { + const response = await server.post('/adp/api/annotation').send({}).expect(400); + const message = response.text; + expect(message).toBe('No datasource found in manifest!'); + }); + test('POST /adp/api/annotation', async () => { + const response = await server + .post('/adp/api/annotation') + .send({ dataSource: 'exampleDataSource', serviceUrl: 'sap/opu/data' }) + .expect(201); + + const message = response.text; + expect(message).toBe('Annotation file created!'); + }); + test('GET /adp/api/annotation', async () => { + const response = await server.get('/adp/api/annotation').send().expect(200); + + const message = response.text; + expect(message).toMatchInlineSnapshot( + `"{\\"mainService\\":{\\"annotationDetails\\":{\\"fileName\\":\\"annotation0.xml\\",\\"annotationPath\\":\\"//adp.project/webapp/changes/annotation0.xml\\",\\"annotationPathFromRoot\\":\\"adp.project/changes/annotation0.xml\\"},\\"serviceUrl\\":\\"main/service/uri\\",\\"isRunningInBAS\\":false},\\"secondaryService\\":{\\"annotationDetails\\":{\\"annotationExistsInWS\\":false},\\"serviceUrl\\":\\"secondary/service/uri\\",\\"isRunningInBAS\\":false}}"` + ); + }); }); }); diff --git a/packages/adp-tooling/test/unit/preview/change-handler.test.ts b/packages/adp-tooling/test/unit/preview/change-handler.test.ts index d3e53eefd1..c56c6a3514 100644 --- a/packages/adp-tooling/test/unit/preview/change-handler.test.ts +++ b/packages/adp-tooling/test/unit/preview/change-handler.test.ts @@ -7,12 +7,19 @@ import type { Editor } from 'mem-fs-editor'; import * as crypto from 'crypto'; import { + addAnnotationFile, addXmlFragment, + isAddAnnotationChange, isAddXMLChange, moduleNameContentMap, tryFixChange } from '../../../src/preview/change-handler'; -import type { AddXMLChange, CommonChangeProperties } from '../../../src'; +import type { AddXMLChange, CommonChangeProperties, AnnotationFileChange } from '../../../src'; +import * as manifestService from '../../../src/base/abap/manifest-service'; +import * as helper from '../../../src/base/helper'; +import * as editors from '../../../src/writer/editors'; +import * as systemAccess from '@sap-ux/system-access/dist/base/connect'; +import * as serviceWriter from '@sap-ux/odata-service-writer/dist/data/annotations'; describe('change-handler', () => { describe('moduleNameContentMap', () => { @@ -467,4 +474,142 @@ id=\\"btn-30303030\\"" }); }); }); + + describe('isAddAnnotationChange', () => { + it('should return true for change objects with changeType "addXML"', () => { + const addAnnotationChange = { + changeType: 'appdescr_app_addAnnotationsToOData', + content: { + serviceUrl: 'test/service/mainService' + } + } as unknown as CommonChangeProperties; + + expect(isAddAnnotationChange(addAnnotationChange)).toBe(true); + }); + + it('should return false for change objects with a different changeType', () => { + const addXMLChange = { + changeType: 'addXML', + content: { + fragmentPath: 'fragments/share.fragment.xml' + } + } as unknown as CommonChangeProperties; + + expect(isAddAnnotationChange(addXMLChange)).toBe(false); + }); + + it('should return false for change objects without a changeType', () => { + const unknownChange = { + content: {} + } as unknown as CommonChangeProperties; + + expect(isAddAnnotationChange(unknownChange as any)).toBe(false); + }); + }); + + describe('addAnnotationFile', () => { + jest.spyOn(serviceWriter, 'getAnnotationNamespaces').mockReturnValue([ + { + namespace: 'com.sap.test.serviceorder.v0001', + alias: 'test' + } + ]); + jest.spyOn(systemAccess, 'createAbapServiceProvider').mockResolvedValue({} as any); + jest.spyOn(manifestService.ManifestService, 'initMergedManifest').mockResolvedValue({ + getDataSourceMetadata: jest.fn().mockResolvedValue(` + + + + + + +`), + getManifestDataSources: jest.fn().mockReturnValue({ + mainService: { + type: 'OData', + uri: 'main/service/uri', + settings: { + annotations: ['annotation0'] + } + }, + annotation0: { + type: 'ODataAnnotation', + uri: `ui5://adp/project/annotation0.xml` + }, + secondaryService: { + type: 'OData', + uri: 'secondary/service/uri', + settings: { + annotations: [] + } + } + }) + } as any); + jest.spyOn(helper, 'getVariant').mockReturnValue({ + content: [], + id: 'adp/project', + layer: 'VENDOR', + namespace: 'test', + reference: 'adp/project' + }); + jest.spyOn(helper, 'getAdpConfig').mockResolvedValue({ + target: { + destination: 'testDestination' + }, + ignoreCertErrors: false + }); + const generateChangeSpy = jest.spyOn(editors, 'generateChange').mockResolvedValue({ + commit: jest.fn().mockResolvedValue('commited') + } as any); + const mockFs = { + exists: jest.fn(), + copy: jest.fn(), + read: jest.fn(), + write: jest.fn() + }; + + const mockLogger = { + info: jest.fn(), + error: jest.fn() + }; + + const fragmentName = 'Share'; + const change = { + changeType: 'appdescr_app_addAnnotationsToOData', + content: { + annotationsInsertPosition: `END`, + annotations: ['annotations.annotation13434343'], + dataSource: { + 'annotations.annotation13434343': { + type: 'ODataAnnotation', + uri: 'test/mainService/$metadata' + } + }, + dataSourceId: 'mainService' + } + } as unknown as AnnotationFileChange; + + beforeEach(() => { + mockFs.exists.mockClear(); + mockFs.copy.mockClear(); + mockFs.read.mockClear(); + mockFs.write.mockClear(); + mockLogger.info.mockClear(); + mockLogger.error.mockClear(); + }); + + it('should call the geneate change', async () => { + mockFs.exists.mockReturnValue(false); + + await addAnnotationFile( + 'projectRoot/webapp', + 'projectRoot', + change, + mockFs as unknown as Editor, + mockLogger as unknown as Logger + ); + + expect(generateChangeSpy).toHaveBeenCalled(); + }); + }); }); diff --git a/packages/adp-tooling/test/unit/writer/changes/writers/index.test.ts b/packages/adp-tooling/test/unit/writer/changes/writers/index.test.ts index 2406681628..11f31772f2 100644 --- a/packages/adp-tooling/test/unit/writer/changes/writers/index.test.ts +++ b/packages/adp-tooling/test/unit/writer/changes/writers/index.test.ts @@ -59,7 +59,8 @@ describe('AnnotationsWriter', () => { fileName: '', dataSource: '/sap/opu/odata/source', filePath: '/mock/path/to/annotation/file.xml' - } + }, + isCommand: true }; const writer = new AnnotationsWriter({} as Editor, mockProjectPath); @@ -87,7 +88,8 @@ describe('AnnotationsWriter', () => { fileName: '', dataSource: '/sap/opu/odata/source', filePath: '' - } + }, + isCommand: true }; const writer = new AnnotationsWriter({} as Editor, mockProjectPath); @@ -115,7 +117,8 @@ describe('AnnotationsWriter', () => { fileName: '', dataSource: '/sap/opu/odata/source', filePath: 'file.xml' - } + }, + isCommand: true }; const writer = new AnnotationsWriter({} as Editor, mockProjectPath); diff --git a/packages/adp-tooling/tsconfig.json b/packages/adp-tooling/tsconfig.json index f3a4da0f1b..a3cd92f5ba 100644 --- a/packages/adp-tooling/tsconfig.json +++ b/packages/adp-tooling/tsconfig.json @@ -26,6 +26,9 @@ { "path": "../logger" }, + { + "path": "../odata-service-writer" + }, { "path": "../project-access" }, diff --git a/packages/control-property-editor-common/src/api.ts b/packages/control-property-editor-common/src/api.ts index cdd0762a03..ad01be69aa 100644 --- a/packages/control-property-editor-common/src/api.ts +++ b/packages/control-property-editor-common/src/api.ts @@ -160,6 +160,7 @@ export interface PendingOtherChange { type: typeof PENDING_CHANGE_TYPE; kind: typeof UNKNOWN_CHANGE_KIND; isActive: boolean; + title?: string; changeType: string; fileName: string; } @@ -171,6 +172,7 @@ export interface PendingControlChange { changeType: string; controlId: string; fileName: string; + title?: string; } export type PendingChange = @@ -199,6 +201,7 @@ export interface UnknownSavedChange { kind: typeof UNKNOWN_CHANGE_KIND; fileName: string; changeType: string; + title?: string; controlId?: string; timestamp?: number; } @@ -209,6 +212,7 @@ export interface SavedControlChange { controlId: string; fileName: string; changeType: string; + title?: string; timestamp?: number; } diff --git a/packages/control-property-editor/src/panels/changes/ChangeStack.tsx b/packages/control-property-editor/src/panels/changes/ChangeStack.tsx index 14e3a77a26..bae77777fb 100644 --- a/packages/control-property-editor/src/panels/changes/ChangeStack.tsx +++ b/packages/control-property-editor/src/panels/changes/ChangeStack.tsx @@ -202,6 +202,7 @@ function handleUnknownChange(change: Change): Item { return { fileName: change.fileName, header: true, + ...(change?.kind === 'unknown' && change.type === 'saved' && change.title && { title: change.title }), timestamp: change.type === SAVED_CHANGE_TYPE ? change.timestamp : undefined, isActive: change.type === SAVED_CHANGE_TYPE ? true : change.isActive }; @@ -214,12 +215,14 @@ function handleUnknownChange(change: Change): Item { * @returns {Item} An item object containing the filename, controlId, type, and optional timestamp. */ function handleControlChange(change: SavedControlChange | PendingControlChange): Item { + const title = change.title; return { fileName: change.fileName, controlId: change.controlId, timestamp: change.type === SAVED_CHANGE_TYPE ? change.timestamp : undefined, isActive: change.type === SAVED_CHANGE_TYPE ? true : change.isActive, - type: change.type + type: change.type, + ...(title && { title }) }; } diff --git a/packages/control-property-editor/src/panels/changes/ControlChange.tsx b/packages/control-property-editor/src/panels/changes/ControlChange.tsx index a21b2b4e1a..eb4dd01a66 100644 --- a/packages/control-property-editor/src/panels/changes/ControlChange.tsx +++ b/packages/control-property-editor/src/panels/changes/ControlChange.tsx @@ -23,6 +23,7 @@ export interface ControlItemProps { controlId: string; isActive: boolean; type: typeof SAVED_CHANGE_TYPE | typeof PENDING_CHANGE_TYPE; + title?: string; } /** @@ -41,7 +42,8 @@ export function ControlChange({ fileName, timestamp, type, - isActive + isActive, + title }: Readonly): ReactElement { const { t } = useTranslation(); const dispatch = useDispatch(); @@ -63,7 +65,7 @@ export function ControlChange({ const onCancelDelete = (): void => { setDialogState(undefined); }; - + const headerTitle = title ?? `${name} ${t('CHANGE')}`; return ( <> @@ -85,7 +87,7 @@ export function ControlChange({ overflowX: 'hidden', lineHeight: '18px' }}> - {name} {t('CHANGE')} + {headerTitle} diff --git a/packages/control-property-editor/src/panels/changes/UnknownChange.tsx b/packages/control-property-editor/src/panels/changes/UnknownChange.tsx index 84e43a4b82..d671a71e35 100644 --- a/packages/control-property-editor/src/panels/changes/UnknownChange.tsx +++ b/packages/control-property-editor/src/panels/changes/UnknownChange.tsx @@ -11,6 +11,7 @@ import { getFormattedDateAndTime } from './utils'; export interface UnknownChangeProps { fileName: string; + title?: string; timestamp?: number; controlId?: string; isActive: boolean; @@ -24,7 +25,7 @@ export interface UnknownChangeProps { * @returns ReactElement */ export function UnknownChange(unknownChangeProps: UnknownChangeProps): ReactElement { - const { fileName, timestamp, header, controlId, isActive } = unknownChangeProps; + const { fileName, timestamp, header, controlId, isActive, title } = unknownChangeProps; const { t } = useTranslation(); const dispatch = useDispatch(); const [dialogState, setDialogState] = useState(undefined); @@ -43,17 +44,14 @@ export function UnknownChange(unknownChangeProps: UnknownChangeProps): ReactElem const parts = fileName.split('_'); const changeName = parts[parts.length - 1]; const name = convertCamelCaseToPascalCase(changeName); + const headerText = title ?? `${name} ${t('CHANGE')}`; return ( <> - {header && ( - - {name} {t('CHANGE')} - - )} + {header && {headerText}} {t('FILE')} diff --git a/packages/create/src/cli/add/annotations-to-odata.ts b/packages/create/src/cli/add/annotations-to-odata.ts index bbe7206733..4cd513a98c 100644 --- a/packages/create/src/cli/add/annotations-to-odata.ts +++ b/packages/create/src/cli/add/annotations-to-odata.ts @@ -73,7 +73,8 @@ async function addAnnotationsToOdata(basePath: string, simulate: boolean, yamlPa filePath: answers.filePath, namespaces, serviceUrl: dataSources[answers.id].uri - } + }, + isCommand: true } ); diff --git a/packages/preview-middleware-client/src/adp/api-handler.ts b/packages/preview-middleware-client/src/adp/api-handler.ts index 5145d058da..67c82d6701 100644 --- a/packages/preview-middleware-client/src/adp/api-handler.ts +++ b/packages/preview-middleware-client/src/adp/api-handler.ts @@ -6,6 +6,7 @@ export const enum ApiEndpoints { FRAGMENT = '/adp/api/fragment', CONTROLLER = '/adp/api/controller', CODE_EXT = '/adp/api/code_ext', + ANNOTATION_FILE = '/adp/api/annotation', MANIFEST_APP_DESCRIPTOR = '/manifest.appdescr_variant' } @@ -33,6 +34,17 @@ export interface CodeExtResponse { isRunningInBAS: boolean; } +export interface AnnotationFileResponse { + annotationExistsInWS: boolean; + annotationPath: string; + annotationPathFromRoot: string; + isRunningInBAS: boolean; +} + +interface DataSoruceAnnotationMap { + [key: string]: { serviceUrl: string; isRunningInBAS: boolean; annotationDetails: AnnotationFileResponse }; +} + export interface ControllersResponse { controllers: Controllers; message: string; @@ -139,6 +151,25 @@ export async function writeController(data: T): Promise { return request(ApiEndpoints.CONTROLLER, RequestMethod.POST, data); } +/** + * Writes a new annotation file to the project's workspace + * + * @param data Data to be send to the server + * @returns Generic Promise + */ +export async function writeAnnotationFile(data: T): Promise { + return request(ApiEndpoints.ANNOTATION_FILE, RequestMethod.POST, data); +} + +/** + * Writes a new annotation file to the project's workspace + * + * @returns Generic Promise + */ +export async function getDataSourceAnnotationFileMap(): Promise { + return request(ApiEndpoints.ANNOTATION_FILE, RequestMethod.GET); +} + /** * Checks for existing controller in the project's workspace * diff --git a/packages/preview-middleware-client/src/adp/controllers/BaseDialog.controller.ts b/packages/preview-middleware-client/src/adp/controllers/BaseDialog.controller.ts index fa8a6a1c9c..4f000bcf2f 100644 --- a/packages/preview-middleware-client/src/adp/controllers/BaseDialog.controller.ts +++ b/packages/preview-middleware-client/src/adp/controllers/BaseDialog.controller.ts @@ -56,7 +56,7 @@ export default abstract class BaseDialog | void; - abstract buildDialogData(): Promise; + abstract buildDialogData(): Promise | void; /** * Method is used in add fragment dialog controllers to get current control metadata which are needed on the dialog @@ -246,5 +246,4 @@ export default abstract class BaseDialog { + private options: FileExistsDialogOptions; + public model: JSONModel; + private readonly _name: string; + constructor(name: string, options: FileExistsDialogOptions) { + super(name); + this.model = new JSONModel(); + this.options = options; + } + + /** + * Setups the Dialog and the JSON Model + * + * @param {Dialog} dialog - Dialog instance + */ + async setup(dialog: Dialog): Promise { + this.dialog = dialog; + + this.model.setProperty('/filePath', this.options.filePath); + this.model.setProperty('/filePathFromRoot', this.options.fileName); + this.model.setProperty('/isRunningInBAS', this.options.isRunningInBAS); + this.buildDialogData(); + const resourceModel = await getResourceModel(); + this.dialog.setModel(this.model); + this.dialog.setModel(resourceModel, 'i18n'); + this.dialog.open(); + } + + /** + * Handles create button press + * + * @param _event Event + */ + onShowFileInVscodeBtn(_event: Event) { + const annotationPath = this.model.getProperty('/filePath'); + window.open(`vscode://file${annotationPath}`); + + this.handleDialogClose(); + } + + handleDialogClose() { + this.dialog.close(); + this.dialog.destroy(); + } + + /** + * Builds data that is used in the dialog. + */ + buildDialogData(): void { + const content = this.dialog.getContent(); + + const messageForm = content[0] as SimpleForm; + messageForm.setVisible(true); + + const isRunningInBAS = this.model.getProperty('/isRunningInBAS'); + if (isRunningInBAS) { + this.dialog.getBeginButton().setVisible(false); + } + } + + /** + * Handles create button press + * + * @param _event Event + */ + onCreateBtnPress(_event: Event): Promise | void {} +} diff --git a/packages/preview-middleware-client/src/adp/dialog-factory.ts b/packages/preview-middleware-client/src/adp/dialog-factory.ts index b5bd79c776..5f70483c82 100644 --- a/packages/preview-middleware-client/src/adp/dialog-factory.ts +++ b/packages/preview-middleware-client/src/adp/dialog-factory.ts @@ -11,15 +11,17 @@ import ControllerExtension from './controllers/ControllerExtension.controller'; import ExtensionPoint from './controllers/ExtensionPoint.controller'; import { ExtensionPointData } from './extension-point'; +import FileExistsDialog, { FileExistsDialogOptions } from './controllers/FileExistsDialog.controller'; export const enum DialogNames { ADD_FRAGMENT = 'AddFragment', ADD_TABLE_COLUMN_FRAGMENTS = 'AddTableColumnFragments', CONTROLLER_EXTENSION = 'ControllerExtension', - ADD_FRAGMENT_AT_EXTENSION_POINT = 'ExtensionPoint' + ADD_FRAGMENT_AT_EXTENSION_POINT = 'ExtensionPoint', + FILE_EXISTS = 'FileExistsDialog' } -type Controller = AddFragment | AddTableColumnFragments | ControllerExtension | ExtensionPoint; +type Controller = AddFragment | AddTableColumnFragments | ControllerExtension | ExtensionPoint | FileExistsDialog; export const OPEN_DIALOG_STATUS_CHANGED = 'OPEN_DIALOG_STATUS_CHANGED'; @@ -48,7 +50,7 @@ export class DialogFactory { rta: RuntimeAuthoring, dialogName: DialogNames, extensionPointData?: ExtensionPointData, - options: Partial = {} + options: Partial | Partial = {} ): Promise { if (this.isDialogOpen) { return; @@ -59,7 +61,7 @@ export class DialogFactory { switch (dialogName) { case DialogNames.ADD_FRAGMENT: controller = new AddFragment(`open.ux.preview.client.adp.controllers.${dialogName}`, overlay, rta, { - aggregation: options.aggregation, + ...('aggregation' in options && { aggregation: options.aggregation }), title: resources.getText(options.title ?? 'ADP_ADD_FRAGMENT_DIALOG_TITLE') }); break; @@ -69,7 +71,7 @@ export class DialogFactory { overlay, rta, { - aggregation: options.aggregation, + ...('aggregation' in options && { aggregation: options.aggregation }), title: resources.getText(options.title ?? 'ADP_ADD_FRAGMENT_DIALOG_TITLE') } ); @@ -89,6 +91,12 @@ export class DialogFactory { extensionPointData! ); break; + case DialogNames.FILE_EXISTS: + controller = new FileExistsDialog( + `open.ux.preview.client.adp.controllers.${dialogName}`, + options as FileExistsDialogOptions + ); + break; } const id = dialogName === DialogNames.ADD_FRAGMENT_AT_EXTENSION_POINT ? `dialog--${dialogName}` : undefined; @@ -110,7 +118,7 @@ export class DialogFactory { /** * Updates open dialog status. - * + * * @param isDialogOpen Flag indicating if there is an open dialog. */ private static updateStatus(isDialogOpen: boolean) { @@ -121,7 +129,7 @@ export class DialogFactory { /** * Attach event handler for OPEN_DIALOG_STATUS_CHANGED event. - * + * * @param handler Event handler. * @returns Function that removes listener. */ diff --git a/packages/preview-middleware-client/src/adp/quick-actions/common/add-new-annotation-file.ts b/packages/preview-middleware-client/src/adp/quick-actions/common/add-new-annotation-file.ts new file mode 100644 index 0000000000..06d1d34c00 --- /dev/null +++ b/packages/preview-middleware-client/src/adp/quick-actions/common/add-new-annotation-file.ts @@ -0,0 +1,141 @@ +import FlexCommand from 'sap/ui/rta/command/FlexCommand'; + +import { QuickActionContext, NestedQuickActionDefinition } from '../../../cpe/quick-actions/quick-action-definition'; +import { getDataSourceAnnotationFileMap } from '../../api-handler'; +import { + NESTED_QUICK_ACTION_KIND, + NestedQuickAction, + NestedQuickActionChild +} from '@sap-ux-private/control-property-editor-common'; +import { DialogFactory, DialogNames } from '../../dialog-factory'; +import OverlayRegistry from 'sap/ui/dt/OverlayRegistry'; +import { QuickActionDefinitionBase } from '../quick-action-base'; +import { DIALOG_ENABLEMENT_VALIDATOR } from '../dialog-enablement-validator'; +import CommandFactory from 'sap/ui/rta/command/CommandFactory'; +import { getUi5Version, isLowerThanMinimalUi5Version } from '../../../utils/version'; + +export const ADD_NEW_ANNOTATION_FILE = 'add-new-annotation-file'; + +/** + * Add New Annotation File. + */ +export class AddNewAnnotationFile + extends QuickActionDefinitionBase + implements NestedQuickActionDefinition +{ + public children: NestedQuickActionChild[] = []; + readonly kind = NESTED_QUICK_ACTION_KIND; + readonly type = ADD_NEW_ANNOTATION_FILE; + readonly forceRefreshAfterExecution = true; + public isApplicable = true; + public get id(): string { + return `${this.context.key}-${this.type}`; + } + constructor(protected readonly context: QuickActionContext) { + super(ADD_NEW_ANNOTATION_FILE, NESTED_QUICK_ACTION_KIND, 'QUICK_ACTION_ADD_NEW_ANNOTATION_FILE', context, [ + DIALOG_ENABLEMENT_VALIDATOR + ]); + } + + async initialize(): Promise { + const version = await getUi5Version(); + if (isLowerThanMinimalUi5Version(version, { major: 1, minor: 132, patch: 0 })) { + this.isApplicable = false; + return; + } + const dataSourceAnnotationFileMap = await getDataSourceAnnotationFileMap(); + if (!dataSourceAnnotationFileMap) { + throw new Error('No data sources found in the manifest'); + } + for (const key in dataSourceAnnotationFileMap) { + if (Object.prototype.hasOwnProperty.call(dataSourceAnnotationFileMap, key)) { + const source = dataSourceAnnotationFileMap[key]; + this.children.push({ + enabled: true, + label: source.annotationDetails.annotationExistsInWS + ? this.context.resourceBundle.getText('SHOW_ANNOTATION_FILE', [key]) + : this.context.resourceBundle.getText('ODATA_SOURCE', [key]), + children: [] + }); + } + } + } + async execute(path: string): Promise { + const index = Number(path); + if (index >= 0) { + // Do not cache the result of getDataSourceAnnotationFileMap api, + // as annotation file or datasource can be added outside using create command. + // So refresh would be required for the cache to be updated. + const dataSourceAnnotationFileMap = await getDataSourceAnnotationFileMap(); + const dataSourceId = Object.keys(dataSourceAnnotationFileMap)[index]; + const dataSource = dataSourceAnnotationFileMap?.[dataSourceId]; + if (dataSource?.annotationDetails.annotationExistsInWS) { + const annotationFileDetails = dataSource.annotationDetails; + const { annotationPath, annotationPathFromRoot } = annotationFileDetails; + await DialogFactory.createDialog( + OverlayRegistry.getOverlay(this.context.view), // this passed only because, for method param is required. + this.context.rta, // same as above + DialogNames.FILE_EXISTS, + undefined, + { + fileName: annotationPathFromRoot, + filePath: annotationPath, + isRunningInBAS: dataSource.isRunningInBAS + } + ); + } + // Create annotation file only, if no file exists already for datasource id or if the change file exist and but no annotation file exists in file system. + else if (dataSource) { + const timestamp = Date.now(); + const annotationFileNameWithoutExtension = `annotation_${timestamp}`; + const annotationFileName = `${annotationFileNameWithoutExtension}.xml`; + const annotationNameSpace = + this.context.flexSettings.layer === 'CUSTOMER_BASE' + ? `customer.annotation.${annotationFileNameWithoutExtension}` + : `annotation.${annotationFileNameWithoutExtension}`; + const content = { + dataSourceId: dataSourceId, + annotations: [annotationNameSpace], + annotationsInsertPosition: 'END', + dataSource: { + [annotationNameSpace]: { + uri: `../annotations/${annotationFileName}`, + type: 'ODataAnnotation' + } + } + }; + const modifiedValue = { + changeType: 'appdescr_app_addAnnotationsToOData', + generator: this.context.flexSettings.generator, + reference: this.context.flexSettings.projectId, + fileName: `id_${timestamp}_addAnnotationsToOData`, + content: content, + serviceUrl: dataSource.serviceUrl + }; + const command = await CommandFactory.getCommandFor( + this.context.view, + 'annotation', + modifiedValue, + null, + this.context.flexSettings + ); + return [command]; + } + } + return []; + } + + /** + * Prepares nested quick action object + * @returns action instance + */ + getActionObject(): NestedQuickAction { + return { + kind: NESTED_QUICK_ACTION_KIND, + id: this.id, + enabled: this.isApplicable, + title: this.context.resourceBundle.getText('QUICK_ACTION_ADD_NEW_ANNOTATION_FILE'), + children: this.children + }; + } +} diff --git a/packages/preview-middleware-client/src/adp/quick-actions/fe-v2/registry.ts b/packages/preview-middleware-client/src/adp/quick-actions/fe-v2/registry.ts index d3fb8f6bb3..f767723fb2 100644 --- a/packages/preview-middleware-client/src/adp/quick-actions/fe-v2/registry.ts +++ b/packages/preview-middleware-client/src/adp/quick-actions/fe-v2/registry.ts @@ -20,6 +20,7 @@ import { AddPageActionQuickAction } from '../common/create-page-action'; import { EnableTableFilteringQuickAction } from './lr-enable-table-filtering'; import { ToggleSemanticDateRangeFilterBar } from './lr-enable-semantic-date-range-filter-bar'; import { EnableTableEmptyRowModeQuickAction } from './op-enable-empty-row-mode'; +import { AddNewAnnotationFile } from '../common/add-new-annotation-file'; type PageName = 'listReport' | 'objectPage' | 'analyticalListPage'; const OBJECT_PAGE_TYPE = 'sap.suite.ui.generic.template.ObjectPage.view.Details'; @@ -51,7 +52,8 @@ export default class FEV2QuickActionRegistry extends QuickActionDefinitionRegist ChangeTableColumnsQuickAction, AddTableActionQuickAction, AddTableCustomColumnQuickAction, - EnableTableFilteringQuickAction + EnableTableFilteringQuickAction, + AddNewAnnotationFile ], view, key: name + index @@ -67,7 +69,8 @@ export default class FEV2QuickActionRegistry extends QuickActionDefinitionRegist ChangeTableColumnsQuickAction, AddTableActionQuickAction, AddTableCustomColumnQuickAction, - EnableTableEmptyRowModeQuickAction + EnableTableEmptyRowModeQuickAction, + AddNewAnnotationFile ], view, key: name + index diff --git a/packages/preview-middleware-client/src/adp/quick-actions/fe-v4/registry.ts b/packages/preview-middleware-client/src/adp/quick-actions/fe-v4/registry.ts index 5e20947e3f..8d7173c882 100644 --- a/packages/preview-middleware-client/src/adp/quick-actions/fe-v4/registry.ts +++ b/packages/preview-middleware-client/src/adp/quick-actions/fe-v4/registry.ts @@ -15,6 +15,7 @@ import { AddTableActionQuickAction } from './create-table-action'; import { EnableTableFilteringQuickAction } from './lr-enable-table-filtering'; import { ToggleSemanticDateRangeFilterBar } from './lr-enable-semantic-date-range-filter-bar'; import { EnableTableEmptyRowModeQuickAction } from './op-enable-empty-row-mode'; +import { AddNewAnnotationFile } from '../common/add-new-annotation-file'; type PageName = 'listReport' | 'objectPage'; @@ -46,7 +47,8 @@ export default class FEV4QuickActionRegistry extends QuickActionDefinitionRegist ChangeTableColumnsQuickAction, AddTableActionQuickAction, AddTableCustomColumnQuickAction, - EnableTableFilteringQuickAction + EnableTableFilteringQuickAction, + AddNewAnnotationFile ], view, key: name + index @@ -62,7 +64,8 @@ export default class FEV4QuickActionRegistry extends QuickActionDefinitionRegist ChangeTableColumnsQuickAction, AddTableActionQuickAction, AddTableCustomColumnQuickAction, - EnableTableEmptyRowModeQuickAction + EnableTableEmptyRowModeQuickAction, + AddNewAnnotationFile ], view, key: name + index diff --git a/packages/preview-middleware-client/src/adp/ui/FileExistsDialog.fragment.xml b/packages/preview-middleware-client/src/adp/ui/FileExistsDialog.fragment.xml new file mode 100644 index 0000000000..406f31e2e0 --- /dev/null +++ b/packages/preview-middleware-client/src/adp/ui/FileExistsDialog.fragment.xml @@ -0,0 +1,33 @@ + + + + + + + diff --git a/packages/preview-middleware-client/src/cpe/changes/service.ts b/packages/preview-middleware-client/src/cpe/changes/service.ts index 31576306e7..c2f62c1828 100644 --- a/packages/preview-middleware-client/src/cpe/changes/service.ts +++ b/packages/preview-middleware-client/src/cpe/changes/service.ts @@ -39,6 +39,10 @@ import { getControlById, isA } from '../../utils/core'; import UI5Element from 'sap/ui/core/Element'; import { getConfigMapControlIdMap } from '../../utils/fe-v4'; +const TITLE_MAP: { [key: string]: string } = { + appdescr_app_addAnnotationsToOData: 'Add New Annotation File' +}; + interface ChangeContent { property: string; newValue: string; @@ -317,6 +321,7 @@ export class ChangeService extends EventTarget { } } catch (error) { // Gracefully handle change files with invalid content + const title = TITLE_MAP[change.changeType] ?? ''; if (change.fileName) { this.changedFiles[change.fileName] = change; const unknownChange: UnknownSavedChange = { @@ -324,7 +329,8 @@ export class ChangeService extends EventTarget { kind: 'unknown', changeType: change.changeType, fileName: change.fileName, - timestamp: new Date(change.creation).getTime() + timestamp: new Date(change.creation).getTime(), + ...(title && { title }) }; if (change.creation) { unknownChange.timestamp = new Date(change.creation).getTime(); @@ -431,7 +437,8 @@ export class ChangeService extends EventTarget { const changesRequiringReload = this.pendingChanges.reduce( (sum, change) => change.kind === CONFIGURATION_CHANGE_KIND || - change.changeType === 'appdescr_ui_generic_app_changePageConfiguration' + change.changeType === 'appdescr_ui_generic_app_changePageConfiguration' || + change.changeType === 'appdescr_app_addAnnotationsToOData' ? sum + 1 : sum, 0 @@ -628,7 +635,9 @@ export class ChangeService extends EventTarget { return undefined; } - const { fileName } = change.getDefinition(); + const { fileName } = change.getDefinition + ? change.getDefinition() + : (change.getJson() as { fileName: string }); if ((changeType === 'propertyChange' || changeType === 'propertyBindingChange') && selectorId) { let value = ''; switch (changeType) { @@ -660,9 +669,11 @@ export class ChangeService extends EventTarget { } else if (changeType === 'appdescr_ui_generic_app_changePageConfiguration') { return this.prepareV2ConfigurationChange(command, fileName, index, inactiveCommandCount); } else { + const title = TITLE_MAP[changeType] ?? ''; let result: PendingChange = { type: PENDING_CHANGE_TYPE, kind: UNKNOWN_CHANGE_KIND, + ...(title && { title }), changeType, isActive: index >= inactiveCommandCount, fileName diff --git a/packages/preview-middleware-client/src/messagebundle.properties b/packages/preview-middleware-client/src/messagebundle.properties index fb97dab963..344aa9f748 100644 --- a/packages/preview-middleware-client/src/messagebundle.properties +++ b/packages/preview-middleware-client/src/messagebundle.properties @@ -1,3 +1,5 @@ +CANCEL_BUTTON_LABEL=Cancel + QUICK_ACTION_ADD_PAGE_CONTROLLER=Add Controller to Page QUICK_ACTION_SHOW_PAGE_CONTROLLER=Show Page Controller QUICK_ACTION_OP_ADD_HEADER_FIELD=Add Header Field @@ -5,6 +7,7 @@ QUICK_ACTION_OP_ADD_CUSTOM_SECTION=Add Custom Section QUICK_ACTION_ADD_CUSTOM_PAGE_ACTION=Add Custom Page Action QUICK_ACTION_ADD_CUSTOM_TABLE_ACTION=Add Custom Table Action QUICK_ACTION_ADD_CUSTOM_TABLE_COLUMN=Add Custom Table Column +QUICK_ACTION_ADD_NEW_ANNOTATION_FILE=Add New Annotation File QUICK_ACTION_ENABLE_TABLE_FILTERING=Enable Table Filtering for Page Variants QUICK_ACTION_ENABLE_TABLE_EMPTY_ROW_MODE=Enable Empty Row Mode for Tables @@ -43,3 +46,9 @@ TABLE_CUSTOM_COLUMN_ACTION_NOT_AVAILABLE=This action has been disabled because t TABLE_FILTERING_CHANGE_HAS_ALREADY_BEEN_MADE=This option is disabled because table filtering for page variants is already enabled EMPTY_ROW_MODE_IS_ALREADY_ENABLED=This option has been disabled because empty row mode is already enabled for this table EMPTY_ROW_MODE_IS_NOT_SUPPORTED=This action is disabled because empty row mode is not supported for analytical and tree tables + +ODATA_SOURCE=''{0}'' datasource +SHOW_ANNOTATION_FILE=Show ''{0}'' annotation file +ANNOTATION_FILE_EXISTS=Annotation File Exists +ANNOTATION_FILE_HAS_BEEN_FOUND=An Annotation file has been found. +SHOW_FILE_IN_VSCODE=Show File in VSCode \ No newline at end of file diff --git a/packages/preview-middleware-client/test/__mock__/sap/ui/fl/apply/_internal/flexObjects/FlexObjectFactory.ts b/packages/preview-middleware-client/test/__mock__/sap/ui/fl/apply/_internal/flexObjects/FlexObjectFactory.ts index 400096b410..fecf2d4281 100644 --- a/packages/preview-middleware-client/test/__mock__/sap/ui/fl/apply/_internal/flexObjects/FlexObjectFactory.ts +++ b/packages/preview-middleware-client/test/__mock__/sap/ui/fl/apply/_internal/flexObjects/FlexObjectFactory.ts @@ -15,6 +15,7 @@ export default { getChangeType: jest.fn().mockReturnValue(oFileContent.changeType), getLayer: jest.fn().mockReturnValue(oFileContent.layer), getDefinition: jest.fn(), + getJson: jest.fn(), getContent: jest.fn(), setContent: jest.fn() }; diff --git a/packages/preview-middleware-client/test/unit/adp/api-handler.test.ts b/packages/preview-middleware-client/test/unit/adp/api-handler.test.ts index 19fd13f345..280fad2600 100644 --- a/packages/preview-middleware-client/test/unit/adp/api-handler.test.ts +++ b/packages/preview-middleware-client/test/unit/adp/api-handler.test.ts @@ -1,9 +1,11 @@ import { ApiEndpoints, RequestMethod, + getDataSourceAnnotationFileMap, getFragments, getManifestAppdescr, request, + writeAnnotationFile, writeFragment } from '../../../src/adp/api-handler'; import { fetchMock } from 'mock/window'; @@ -122,4 +124,65 @@ describe('API Handler', () => { expect(data.layer).toBe('VENDOR'); }); }); + + describe('writeAnnotationFile', () => { + afterEach(() => { + fetchMock.mockRestore(); + }); + + test('request is called and message is recieved from the backend', async () => { + fetchMock.mockResolvedValue({ + text: jest.fn().mockReturnValue('Annotation File Created'), + ok: true + }); + + const data = await writeAnnotationFile({ + dataSource: 'mainService', + serviceUrl: 'main/service/url' + }); + + expect(data).toBe('Annotation File Created'); + }); + }); + + describe('getDataSourceAnnotationFileMap', () => { + afterEach(() => { + fetchMock.mockRestore(); + }); + + test('request is called and correct data is returned', async () => { + fetchMock.mockResolvedValue({ + json: jest.fn().mockReturnValue( + JSON.stringify({ + mainService: { + serviceUrl: 'main/service/url', + annotationDetails: { + annotationExistsInWS: false, + annotationPath: 'c/drive/main/service/url', + annotationPathFromRoot: '/main/service/url', + isRunningInBAS: false + } + } + }) + ), + ok: true + }); + + const data = await getDataSourceAnnotationFileMap(); + + expect(data).toEqual( + JSON.stringify({ + mainService: { + serviceUrl: 'main/service/url', + annotationDetails: { + annotationExistsInWS: false, + annotationPath: 'c/drive/main/service/url', + annotationPathFromRoot: '/main/service/url', + isRunningInBAS: false + } + } + }) + ); + }); + }); }); diff --git a/packages/preview-middleware-client/test/unit/adp/controllers/FileExistDialog.controller.test.ts b/packages/preview-middleware-client/test/unit/adp/controllers/FileExistDialog.controller.test.ts new file mode 100644 index 0000000000..5c8e310409 --- /dev/null +++ b/packages/preview-middleware-client/test/unit/adp/controllers/FileExistDialog.controller.test.ts @@ -0,0 +1,159 @@ +import ControlUtils from '../../../../src/adp/control-utils'; +import { fetchMock, sapCoreMock } from 'mock/window'; +import OverlayRegistry from 'mock/sap/ui/dt/OverlayRegistry'; +import type Dialog from 'sap/m/Dialog'; +import FileExistsDialog from '../../../../src/adp/controllers/FileExistsDialog.controller'; +import JSONModel from 'sap/ui/model/json/JSONModel'; + +describe('FileExistsDialog', () => { + beforeAll(() => { + fetchMock.mockResolvedValue({ + json: jest.fn().mockReturnValue({ fragments: [] }), + text: jest.fn(), + ok: true + }); + }); + + describe('setup', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('fills json model with data - show file in vscode button', async () => { + const testModel = { + setProperty: jest.fn(), + getProperty: jest.fn().mockReturnValue(false) + } as unknown as JSONModel; + ControlUtils.getRuntimeControl = jest.fn().mockReturnValue({ + getMetadata: jest.fn().mockReturnValue({ + getAllAggregations: jest.fn().mockReturnValue({ + 'tooltip': {}, + 'customData': {}, + 'layoutData': {}, + 'dependents': {}, + 'dragDropConfig': {}, + 'content': {} + }), + getDefaultAggregationName: jest.fn().mockReturnValue('content'), + getName: jest.fn().mockReturnValue('Toolbar') + }) + }); + + ControlUtils.getControlAggregationByName = jest + .fn() + .mockReturnValue({ 0: {}, 1: {}, 2: {}, 3: {}, 4: {}, 5: {}, 6: {} }); + + const overlayControl = { + getDesignTimeMetadata: jest.fn().mockReturnValue({ + getData: jest.fn().mockReturnValue({ + aggregations: { content: { actions: { move: null }, domRef: ':sap-domref' } } + }) + }) + }; + sapCoreMock.byId.mockReturnValue(overlayControl); + + OverlayRegistry.getOverlay = jest.fn().mockReturnValue({ + getDesignTimeMetadata: jest.fn().mockReturnValue({ + getData: jest.fn().mockReturnValue({ + aggregations: {} + }) + }) + }); + + const fileExistDialog = new FileExistsDialog('adp.extension.controllers.FileExists', { + fileName: 'annotation_123434343.xml', + filePath: 'adp.demo.app/changes/annnotation/annotation_123434343.xml', + isRunningInBAS: false, + title: '' + }); + fileExistDialog.model = testModel; + const openSpy = jest.fn(); + + await fileExistDialog.setup({ + setEscapeHandler: jest.fn(), + destroy: jest.fn(), + setModel: jest.fn(), + open: openSpy, + close: jest.fn(), + getContent: jest.fn().mockReturnValue([ + { + setVisible: jest.fn() + } + ]) + } as unknown as Dialog); + + expect(openSpy).toHaveBeenCalledTimes(1); + }); + + test('fills json model with data - hide file in vscode button in SBAS', async () => { + const testModel = { + setProperty: jest.fn(), + getProperty: jest.fn().mockReturnValue(true) + } as unknown as JSONModel; + ControlUtils.getRuntimeControl = jest.fn().mockReturnValue({ + getMetadata: jest.fn().mockReturnValue({ + getAllAggregations: jest.fn().mockReturnValue({ + 'tooltip': {}, + 'customData': {}, + 'layoutData': {}, + 'dependents': {}, + 'dragDropConfig': {}, + 'content': {} + }), + getDefaultAggregationName: jest.fn().mockReturnValue('content'), + getName: jest.fn().mockReturnValue('Toolbar') + }) + }); + + const overlayControl = { + getDesignTimeMetadata: jest.fn().mockReturnValue({ + getData: jest.fn().mockReturnValue({ + aggregations: { content: { actions: { move: null }, domRef: ':sap-domref' } } + }) + }) + }; + sapCoreMock.byId.mockReturnValue(overlayControl); + + OverlayRegistry.getOverlay = jest.fn().mockReturnValue({ + getDesignTimeMetadata: jest.fn().mockReturnValue({ + getData: jest.fn().mockReturnValue({ + aggregations: {} + }) + }) + }); + + const fileExistDialog = new FileExistsDialog('adp.extension.controllers.FileExists', { + fileName: 'annotation_123434343.xml', + filePath: 'adp.demo.app/changes/annnotation/annotation_123434343.xml', + isRunningInBAS: false, + title: '' + }); + fileExistDialog.model = testModel; + const openSpy = jest.fn(); + const showInVsCodeSetVisibleSpy = jest.fn(); + const endButtonSetTextSpy = jest.fn(); + + await fileExistDialog.setup({ + setEscapeHandler: jest.fn(), + destroy: jest.fn(), + setModel: jest.fn(), + open: openSpy, + close: jest.fn(), + getContent: jest.fn().mockReturnValue([ + { + setVisible: jest.fn() + } + ]), + getBeginButton: jest.fn().mockReturnValue({ + setVisible: showInVsCodeSetVisibleSpy.mockReturnValue({ + setEnabled: jest.fn() + }) + }), + getEndButton: jest.fn().mockReturnValue({ setText: endButtonSetTextSpy }) + } as unknown as Dialog); + + expect(openSpy).toHaveBeenCalledTimes(1); + expect(showInVsCodeSetVisibleSpy).toHaveBeenCalledWith(false); + }); + }); +}); diff --git a/packages/preview-middleware-client/test/unit/adp/dialog-factory.test.ts b/packages/preview-middleware-client/test/unit/adp/dialog-factory.test.ts index fc5b910fdf..9a9c8a5eea 100644 --- a/packages/preview-middleware-client/test/unit/adp/dialog-factory.test.ts +++ b/packages/preview-middleware-client/test/unit/adp/dialog-factory.test.ts @@ -11,6 +11,7 @@ import AddFragment from '../../../src/adp/controllers/AddFragment.controller'; import ControllerExtension from '../../../src/adp/controllers/ControllerExtension.controller'; import ExtensionPoint from '../../../src/adp/controllers/ExtensionPoint.controller'; import AddTableColumnFragments from 'open/ux/preview/client/adp/controllers/AddTableColumnFragments.controller'; +import FileExistsDialog from '../../../src/adp/controllers/FileExistsDialog.controller'; describe('DialogFactory', () => { afterEach(() => { @@ -136,4 +137,24 @@ describe('DialogFactory', () => { expect(DialogFactory.canOpenDialog).toBe(false); }); + + test('Show File Exists Dialog', async () => { + const controller = { overlays: {}, rta: { 'yes': 'no' } }; + Controller.create.mockResolvedValue(controller); + const rtaMock = new RuntimeAuthoringMock({} as RTAOptions); + + FileExistsDialog.prototype.setup = jest.fn(); + + await DialogFactory.createDialog( + {} as unknown as UI5Element, + rtaMock as unknown as RuntimeAuthoring, + DialogNames.FILE_EXISTS + ); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + expect(Fragment.load.mock.calls[0][0].name).toStrictEqual('open.ux.preview.client.adp.ui.FileExistsDialog'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + expect(Fragment.load.mock.calls[0][0].id).toStrictEqual(undefined); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + expect(Fragment.load.mock.calls[0][0].controller).toBeInstanceOf(FileExistsDialog); + }); }); diff --git a/packages/preview-middleware-client/test/unit/adp/quick-actions/fe-v2.test.ts b/packages/preview-middleware-client/test/unit/adp/quick-actions/fe-v2.test.ts index a5fb71b4f4..5293fc8de2 100644 --- a/packages/preview-middleware-client/test/unit/adp/quick-actions/fe-v2.test.ts +++ b/packages/preview-middleware-client/test/unit/adp/quick-actions/fe-v2.test.ts @@ -1262,6 +1262,198 @@ describe('FE V2 quick actions', () => { ); }); }); + + describe('create new annotation file', () => { + const pageView = new XMLView(); + let rtaMock: RuntimeAuthoring; + beforeEach(async () => { + jest.clearAllMocks(); + jest.spyOn(versionUtils, 'getUi5Version').mockResolvedValue({ major: 1, minor: 132, patch: 0 }); + FlexUtils.getViewForControl.mockImplementation(() => { + return { + getId: () => 'MyView', + getController: () => { + return { + getMetadata: () => { + return { + getName: () => 'MyController' + }; + } + }; + } + }; + }); + fetchMock.mockResolvedValue({ + json: jest.fn().mockReturnValue({ + mainService: { + serviceUrl: 'main/service/url', + isRunningInBAS: false, + annotationDetails: { + annotationExistsInWS: false + } + }, + dataService: { + serviceUrl: 'data/service/url', + isRunningInBAS: false, + annotationDetails: { + annotationExistsInWS: true, + annotationPath: 'mock/adp/project/annotation/path', + annotationPathFromRoot: 'mock/adp.project.annotation/path' + } + } + }), + text: jest.fn(), + ok: true + }); + sapCoreMock.byId.mockImplementation((id) => { + if (id == 'DynamicPage') { + return { + getDomRef: () => ({}), + getParent: () => pageView + }; + } + if (id == 'NavContainer') { + const container = new NavContainer(); + const component = new UIComponentMock(); + const view = new XMLView(); + pageView.getDomRef.mockImplementation(() => { + return { + contains: () => true + }; + }); + pageView.getViewName.mockImplementation( + () => 'sap.suite.ui.generic.template.ListReport.view.ListReport' + ); + const componentContainer = new ComponentContainer(); + const spy = jest.spyOn(componentContainer, 'getComponent'); + spy.mockImplementation(() => { + return 'component-id'; + }); + jest.spyOn(Component, 'getComponentById').mockImplementation((id: string | undefined) => { + if (id === 'component-id') { + return component; + } + }); + view.getContent.mockImplementation(() => { + return [componentContainer]; + }); + container.getCurrentPage.mockImplementation(() => { + return view; + }); + component.getRootControl.mockImplementation(() => { + return pageView; + }); + return container; + } + }); + + rtaMock = new RuntimeAuthoringMock({} as RTAOptions) as unknown as RuntimeAuthoring; + const registry = new FEV2QuickActionRegistry(); + const service = new QuickActionService( + rtaMock, + new OutlineService(rtaMock, mockChangeService), + [registry], + { onStackChange: jest.fn() } as any + ); + await service.init(sendActionMock, subscribeMock); + + await service.reloadQuickActions({ + 'sap.f.DynamicPage': [ + { + controlId: 'DynamicPage' + } as any + ], + 'sap.m.NavContainer': [ + { + controlId: 'NavContainer' + } as any + ], + 'sap.ui.core.XMLView': [ + { + controlId: 'ListReportView' + } as any + ] + }); + }); + test('initialize and execute action', async () => { + jest.spyOn(Date, 'now').mockReturnValue(1736143853603); + expect(sendActionMock).toHaveBeenCalledWith( + quickActionListChanged([ + { + title: 'LIST REPORT', + actions: [ + { + 'kind': 'simple', + id: 'listReport0-add-controller-to-page', + title: 'Add Controller to Page', + enabled: true + }, + { + 'kind': 'nested', + id: 'listReport0-add-new-annotation-file', + title: 'Add New Annotation File', + enabled: true, + children: [ + { + children: [], + enabled: true, + label: '\'\'{0}\'\' datasource' + }, + { + children: [], + enabled: true, + label: 'Show \'\'{0}\'\' annotation file' + } + ] + } + ] + } + ]) + ); + + await subscribeMock.mock.calls[0][0]( + executeQuickAction({ id: 'listReport0-add-new-annotation-file', kind: 'nested', path: '0' }) + ); + expect(rtaMock.getCommandStack().pushAndExecute).toHaveBeenCalledWith({ + settings: {}, + type: 'annotation', + value: { + changeType: 'appdescr_app_addAnnotationsToOData', + content: { + annotations: ['annotation.annotation_1736143853603'], + annotationsInsertPosition: 'END', + dataSource: { + 'annotation.annotation_1736143853603': { + type: 'ODataAnnotation', + uri: '../annotations/annotation_1736143853603.xml' + } + }, + dataSourceId: 'mainService', + reference: undefined + }, + fileName: 'id_1736143853603_addAnnotationsToOData', + generator: undefined, + serviceUrl: 'main/service/url' + } + }); + }); + test('initialize and execute action - when file exists', async () => { + await subscribeMock.mock.calls[0][0]( + executeQuickAction({ id: 'listReport0-add-new-annotation-file', kind: 'nested', path: '1' }) + ); + expect(DialogFactory.createDialog).toHaveBeenCalledWith( + mockOverlay, + rtaMock, + 'FileExistsDialog', + undefined, + { + fileName: 'mock/adp.project.annotation/path', + filePath: 'mock/adp/project/annotation/path', + isRunningInBAS: false + } + ); + }); + }); }); describe('ObjectPage', () => { describe('add header field', () => { diff --git a/packages/preview-middleware-client/types/sap.ui.fl.d.ts b/packages/preview-middleware-client/types/sap.ui.fl.d.ts index 34fd8b9009..464c661e73 100644 --- a/packages/preview-middleware-client/types/sap.ui.fl.d.ts +++ b/packages/preview-middleware-client/types/sap.ui.fl.d.ts @@ -53,6 +53,7 @@ declare module 'sap/ui/fl/Change' { class Change { constructor(file: object): void; getDefinition: () => ChangeDefinition; + getJson: () => unknown; getSelector: () => Selector; getChangeType: () => string; getLayer: () => Layer; diff --git a/packages/preview-middleware/src/base/flex.ts b/packages/preview-middleware/src/base/flex.ts index 3432e8dcb0..3ffe8507db 100644 --- a/packages/preview-middleware/src/base/flex.ts +++ b/packages/preview-middleware/src/base/flex.ts @@ -1,8 +1,8 @@ import type { Logger } from '@sap-ux/logger'; import type { ReaderCollection } from '@ui5/fs'; import type { Editor } from 'mem-fs-editor'; -import { existsSync, readdirSync, unlinkSync } from 'fs'; -import { join, parse } from 'path'; +import { existsSync, readdirSync, statSync, unlinkSync } from 'fs'; +import { join, parse, sep } from 'path'; import type { CommonChangeProperties } from '@sap-ux/adp-tooling'; /** @@ -17,7 +17,7 @@ export async function readChanges( logger: Logger ): Promise> { const changes: Record = {}; - const files = await project.byGlob('/**/changes/*.*'); + const files = await project.byGlob('/**/changes/**/*.*'); for (const file of files) { try { changes[`sap.ui.fl.${parse(file.getName()).name}`] = JSON.parse( @@ -79,15 +79,36 @@ export function deleteChange( if (fileName) { const path = join(webappPath, 'changes'); if (existsSync(path)) { - const files = readdirSync(path); - const file = files.find((element) => element.includes(fileName)); - if (file) { - logger.debug(`Write change ${file}`); - const filePath = join(path, file); + // Changes can be in subfolders of changes directory. For eg: New Annotation File Change + const files: string[] = []; + readDirectoriesRecursively(path, files); + const filePath = files.find((element) => element.includes(fileName)); + if (filePath) { + const fileNameWithExt = filePath.split(sep).pop(); + logger.debug(`Write change ${fileNameWithExt}`); unlinkSync(filePath); - return { success: true, message: `FILE_DELETED ${file}` }; + return { success: true, message: `FILE_DELETED ${fileNameWithExt}` }; } } } return { success: false }; } + +/** + * Recursively find all files in the given folder. + * + * @param path path to the folder. + * @param files all files in the given folder and subfolders. + */ +function readDirectoriesRecursively(path: string, files: string[] = []): void { + const items = readdirSync(path); + items.forEach((item) => { + const fullPath = join(path, item); + const stats = statSync(fullPath); + if (stats.isDirectory()) { + readDirectoriesRecursively(fullPath, files); + } else if (stats.isFile()) { + files.push(fullPath); + } + }); +} diff --git a/packages/preview-middleware/test/unit/base/flex.test.ts b/packages/preview-middleware/test/unit/base/flex.test.ts index dc2e4303dd..1869e594c1 100644 --- a/packages/preview-middleware/test/unit/base/flex.test.ts +++ b/packages/preview-middleware/test/unit/base/flex.test.ts @@ -21,9 +21,9 @@ describe('flex', () => { const project = { byGlob: byGlobMock } as unknown as ReaderCollection; - function mockChange(id: string, ext: string = 'change', content?: object) { + function mockChange(id: string, subfolderPath: string = '', ext: string = 'change', content?: object) { return { - getPath: () => `test/changes/${id}.${ext}`, + getPath: () => `test/changes/${subfolderPath}/${id}.${ext}`, getName: () => `${id}.${ext}`, getString: () => Promise.resolve(JSON.stringify(content ?? { id })) }; @@ -35,19 +35,24 @@ describe('flex', () => { }); test('valid changes', async () => { - byGlobMock.mockResolvedValueOnce([mockChange('id1'), mockChange('id2', 'ctrl_variant_management_change')]); + byGlobMock.mockResolvedValueOnce([ + mockChange('id1'), + mockChange('id2', '', 'ctrl_variant_management_change'), + mockChange('id3', 'manifest', 'appdescr_app_addAnnotationsToOData') + ]); const changes = await readChanges(project, logger); - expect(Object.keys(changes)).toHaveLength(2); + expect(Object.keys(changes)).toHaveLength(3); expect(changes).toEqual({ 'sap.ui.fl.id1': { id: 'id1' }, - 'sap.ui.fl.id2': { id: 'id2' } + 'sap.ui.fl.id2': { id: 'id2' }, + 'sap.ui.fl.id3': { id: 'id3' } }); }); test('mix of valid and invalid changes', async () => { byGlobMock.mockResolvedValueOnce([ mockChange('id1'), // valid - mockChange('id2', 'change', { changeType: 'addXML' }), // valid but moduleName cannot be replaced + mockChange('id2', '', 'change', { changeType: 'addXML' }), // valid but moduleName cannot be replaced { invalid: 'change' } // invalid ]); const changes = await readChanges(project, logger); @@ -63,7 +68,7 @@ describe('flex', () => { codeRef: 'controller/MyExtension.js' } }; - byGlobMock.mockResolvedValueOnce([mockChange('id1', 'change', change)]); + byGlobMock.mockResolvedValueOnce([mockChange('id1', '', 'change', change)]); const changes = await readChanges(project, logger); expect(changes['sap.ui.fl.id1'].changeType).toBe('codeExt'); }); @@ -76,7 +81,7 @@ describe('flex', () => { fragmentPath: 'fragment/MyFragment.xml' } }; - byGlobMock.mockResolvedValueOnce([mockChange('id1', 'change', change)]); + byGlobMock.mockResolvedValueOnce([mockChange('id1', '', 'change', change)]); const changes = await readChanges(project, logger); expect(changes['sap.ui.fl.id1'].changeType).toBe('addXML'); }); diff --git a/packages/reload-middleware/src/base/livereload.ts b/packages/reload-middleware/src/base/livereload.ts index 78482e9634..0013c64496 100644 --- a/packages/reload-middleware/src/base/livereload.ts +++ b/packages/reload-middleware/src/base/livereload.ts @@ -75,7 +75,8 @@ export function watchManifestChanges(livereload: LiveReloadServer): void { } else if (fileExtension === '.change') { if ( path.endsWith('appdescr_fe_changePageConfiguration.change') || - path.endsWith('appdescr_ui_generic_app_changePageConfiguration.change') + path.endsWith('appdescr_ui_generic_app_changePageConfiguration.change') || + path.endsWith('appdescr_app_addAnnotationsToOData.change') ) { global.__SAP_UX_MANIFEST_SYNC_REQUIRED__ = true; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5854cdbe10..a972173ddb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -495,6 +495,9 @@ importers: '@sap-ux/logger': specifier: workspace:* version: link:../logger + '@sap-ux/odata-service-writer': + specifier: workspace:* + version: link:../odata-service-writer '@sap-ux/project-access': specifier: workspace:* version: link:../project-access diff --git a/types/ui5.d.ts b/types/ui5.d.ts index c0956076da..e7cb615d1f 100644 --- a/types/ui5.d.ts +++ b/types/ui5.d.ts @@ -119,6 +119,11 @@ declare module '@ui5/server' { * Get the name of the project. */ getName(): string; + + /** + * Gets the app id. + */ + getNamespace(): string; }; }