diff --git a/examples/fe-fpm-cli/CHANGELOG.md b/examples/fe-fpm-cli/CHANGELOG.md index e21a5ca85e..7ef69576aa 100644 --- a/examples/fe-fpm-cli/CHANGELOG.md +++ b/examples/fe-fpm-cli/CHANGELOG.md @@ -1,5 +1,31 @@ # @sap-ux/fe-fpm-cli +## 0.0.19 + +### Patch Changes + +- Updated dependencies [177cdc8] + - @sap-ux/fe-fpm-writer@0.29.0 + +## 0.0.18 + +### Patch Changes + +- @sap-ux/fe-fpm-writer@0.28.3 + +## 0.0.17 + +### Patch Changes + +- @sap-ux/fe-fpm-writer@0.28.2 + +## 0.0.16 + +### Patch Changes + +- Updated dependencies [b10e3fd] + - @sap-ux/fe-fpm-writer@0.28.1 + ## 0.0.15 ### Patch Changes diff --git a/examples/fe-fpm-cli/package.json b/examples/fe-fpm-cli/package.json index f8daef43d4..4fa321adf0 100644 --- a/examples/fe-fpm-cli/package.json +++ b/examples/fe-fpm-cli/package.json @@ -1,6 +1,6 @@ { "name": "@sap-ux/fe-fpm-cli", - "version": "0.0.15", + "version": "0.0.19", "description": "A simple CLI to prompt required information to create a building block using the fe-fpm-writer module's prompt and generate functions.", "license": "Apache-2.0", "private": true, diff --git a/examples/prompting-ui/CHANGELOG.md b/examples/prompting-ui/CHANGELOG.md index 8abbf0cc36..e46fbda0bd 100644 --- a/examples/prompting-ui/CHANGELOG.md +++ b/examples/prompting-ui/CHANGELOG.md @@ -1,5 +1,29 @@ # @sap-ux/fe-fpm-writer-ui +## 0.0.10 + +### Patch Changes + +- Updated dependencies [ea0674c] + - @sap-ux/ui-components@1.17.9 + - @sap-ux/ui-prompting@0.1.8 + +## 0.0.9 + +### Patch Changes + +- Updated dependencies [b124873] + - @sap-ux/ui-components@1.17.8 + - @sap-ux/ui-prompting@0.1.7 + +## 0.0.8 + +### Patch Changes + +- Updated dependencies [73f905f] + - @sap-ux/ui-components@1.17.7 + - @sap-ux/ui-prompting@0.1.6 + ## 0.0.7 ### Patch Changes diff --git a/examples/prompting-ui/package.json b/examples/prompting-ui/package.json index 76da74fc65..3b9321ee81 100644 --- a/examples/prompting-ui/package.json +++ b/examples/prompting-ui/package.json @@ -1,6 +1,6 @@ { "name": "@sap-ux/prompting-ui", - "version": "0.0.7", + "version": "0.0.10", "description": "This project contains UI storybook stories with exampleS with prompt ui and FPM based building blocks.", "license": "Apache-2.0", "private": true, diff --git a/examples/simple-generator/CHANGELOG.md b/examples/simple-generator/CHANGELOG.md index 0d11a01b68..a635031060 100644 --- a/examples/simple-generator/CHANGELOG.md +++ b/examples/simple-generator/CHANGELOG.md @@ -1,5 +1,43 @@ # @sap-ux/generator-simple-fe +## 1.0.39 + +### Patch Changes + +- @sap-ux/fiori-elements-writer@1.1.14 + +## 1.0.38 + +### Patch Changes + +- @sap-ux/fiori-elements-writer@1.1.13 +- @sap-ux/fiori-freestyle-writer@1.0.22 +- @sap-ux/axios-extension@1.16.5 +- @sap-ux/system-access@0.5.10 + +## 1.0.37 + +### Patch Changes + +- @sap-ux/axios-extension@1.16.5 +- @sap-ux/system-access@0.5.10 +- @sap-ux/fiori-elements-writer@1.1.12 +- @sap-ux/fiori-freestyle-writer@1.0.21 + +## 1.0.36 + +### Patch Changes + +- Updated dependencies [8cfd71a] + - @sap-ux/fiori-elements-writer@1.1.11 + - @sap-ux/fiori-freestyle-writer@1.0.20 + +## 1.0.35 + +### Patch Changes + +- @sap-ux/fiori-elements-writer@1.1.10 + ## 1.0.34 ### Patch Changes diff --git a/examples/simple-generator/package.json b/examples/simple-generator/package.json index 5ac7026f4a..f0a94b0707 100644 --- a/examples/simple-generator/package.json +++ b/examples/simple-generator/package.json @@ -1,6 +1,6 @@ { "name": "@sap-ux/generator-simple-fe", - "version": "1.0.34", + "version": "1.0.39", "description": "Simple example of a yeoman generator for Fiori elements.", "license": "Apache-2.0", "private": true, diff --git a/packages/abap-deploy-config-inquirer/CHANGELOG.md b/packages/abap-deploy-config-inquirer/CHANGELOG.md index ff0f4180b4..904b1132e8 100644 --- a/packages/abap-deploy-config-inquirer/CHANGELOG.md +++ b/packages/abap-deploy-config-inquirer/CHANGELOG.md @@ -1,5 +1,39 @@ # @sap-ux/abap-deploy-config-inquirer +## 0.0.12 + +### Patch Changes + +- 40ce4ca: remove inquirer package + +## 0.0.11 + +### Patch Changes + +- @sap-ux/axios-extension@1.16.5 +- @sap-ux/fiori-generator-shared@0.3.19 +- @sap-ux/system-access@0.5.10 + +## 0.0.10 + +### Patch Changes + +- 7926d8c: Add missing dependencies + +## 0.0.9 + +### Patch Changes + +- 99d7394: adds create command add deploy-config + +## 0.0.8 + +### Patch Changes + +- @sap-ux/axios-extension@1.16.5 +- @sap-ux/fiori-generator-shared@0.3.18 +- @sap-ux/system-access@0.5.10 + ## 0.0.7 ### Patch Changes diff --git a/packages/abap-deploy-config-inquirer/package.json b/packages/abap-deploy-config-inquirer/package.json index 94322d2744..efb566e4fc 100644 --- a/packages/abap-deploy-config-inquirer/package.json +++ b/packages/abap-deploy-config-inquirer/package.json @@ -6,7 +6,7 @@ "url": "https://github.com/SAP/open-ux-tools.git", "directory": "packages/abap-deploy-config-inquirer" }, - "version": "0.0.7", + "version": "0.0.12", "license": "Apache-2.0", "main": "dist/index.js", "scripts": { @@ -38,11 +38,12 @@ "@sap-ux/store": "workspace:*", "@sap-ux/system-access": "workspace:*", "@sap-ux/ui5-config": "workspace:*", - "i18next": "23.5.1" + "i18next": "23.5.1", + "@sap-devx/yeoman-ui-types": "1.14.4", + "inquirer-autocomplete-prompt": "2.0.1" }, "devDependencies": { "@types/inquirer": "8.2.6", - "@types/inquirer-autocomplete-prompt": "2.0.1", - "@sap-devx/yeoman-ui-types": "1.14.4" + "@types/inquirer-autocomplete-prompt": "2.0.1" } } diff --git a/packages/abap-deploy-config-inquirer/src/index.ts b/packages/abap-deploy-config-inquirer/src/index.ts index c373203052..bf8f3a4989 100644 --- a/packages/abap-deploy-config-inquirer/src/index.ts +++ b/packages/abap-deploy-config-inquirer/src/index.ts @@ -61,15 +61,13 @@ async function prompt( const abapDeployConfigPrompts = (await getPrompts(promptOptions, logger, isYUI)).prompts; const answers = await adapter.prompt(abapDeployConfigPrompts); - // Add dervied service answers to the answers object - Object.assign(answers, PromptState.abapDeployConfig); - - return reconcileAnswers(answers); + return reconcileAnswers(answers, PromptState.abapDeployConfig); } export { getPrompts, prompt, + reconcileAnswers, TargetSystemType, PackageInputChoices, TransportChoices, diff --git a/packages/abap-deploy-config-inquirer/src/prompts/conditions.ts b/packages/abap-deploy-config-inquirer/src/prompts/conditions.ts index 4299b3418e..f6a9a36759 100644 --- a/packages/abap-deploy-config-inquirer/src/prompts/conditions.ts +++ b/packages/abap-deploy-config-inquirer/src/prompts/conditions.ts @@ -185,8 +185,11 @@ function defaultOrShowPackageQuestion(): boolean { * @returns boolean */ export function showPackageInputChoiceQuestion(useAutocomplete = false): boolean { - // Only show the input choice (manual/search) when the autocomplete option is true, the prompt is supported; CLI or YUI specific version - return Boolean((!PromptState.isYUI || useAutocomplete) && defaultOrShowPackageQuestion()); + if (!useAutocomplete) { + return false; + } + const isPromptSupported = !PromptState.isYUI || (PromptState.isYUI && useAutocomplete); + return isPromptSupported && defaultOrShowPackageQuestion(); } /** @@ -202,7 +205,6 @@ export function defaultOrShowManualPackageQuestion( packageInputChoice?: string, useAutocomplete = false ): boolean { - // Until the version of YUI installed supports auto-complete we must continue to show a manual input for packages return ( (!isCli || packageInputChoice === PackageInputChoices.EnterManualChoice || !useAutocomplete) && defaultOrShowPackageQuestion() diff --git a/packages/abap-deploy-config-inquirer/src/prompts/questions/abap-target.ts b/packages/abap-deploy-config-inquirer/src/prompts/questions/abap-target.ts index d52bec31c1..2290cd8455 100644 --- a/packages/abap-deploy-config-inquirer/src/prompts/questions/abap-target.ts +++ b/packages/abap-deploy-config-inquirer/src/prompts/questions/abap-target.ts @@ -47,6 +47,7 @@ function getDestinationPrompt( name: abapDeployConfigInternalPromptNames.destination, message: t('prompts.target.destination.message'), guiOptions: { + mandatory: true, breadcrumb: true }, default: (): string | undefined => backendTarget?.abapTarget?.destination, @@ -97,6 +98,7 @@ function getTargetSystemPrompt( name: abapDeployConfigInternalPromptNames.targetSystem, message: t('prompts.target.targetSystem.message'), guiOptions: { + mandatory: true, breadcrumb: t('prompts.target.targetSystem.breadcrumb') }, choices: (): AbapSystemChoice[] => choices, @@ -199,7 +201,7 @@ function getClientChoicePrompt( if (!PromptState.isYUI) { prompts.push({ - when: async (answers: AbapDeployConfigAnswersInternal): Promise => { + when: (answers: AbapDeployConfigAnswersInternal): boolean => { const clientChoice = answers[abapDeployConfigInternalPromptNames.clientChoice]; if (clientChoice) { validateClientChoiceQuestion(clientChoice as ClientChoiceValue, backendTarget?.abapTarget?.client); diff --git a/packages/abap-deploy-config-inquirer/src/prompts/questions/config/package.ts b/packages/abap-deploy-config-inquirer/src/prompts/questions/config/package.ts index 5f28f4c4f6..df9a4cec14 100644 --- a/packages/abap-deploy-config-inquirer/src/prompts/questions/config/package.ts +++ b/packages/abap-deploy-config-inquirer/src/prompts/questions/config/package.ts @@ -28,7 +28,7 @@ export function getPackagePrompts(options: AbapDeployConfigPromptOptions): Quest let morePackageResultsMsg = ''; const isCli = !PromptState.isYUI; - const questions: Question[] = [ + const questions: Question[] = [ { when: (): boolean => showPackageInputChoiceQuestion(options.useAutocomplete), type: 'list', @@ -71,7 +71,7 @@ export function getPackagePrompts(options: AbapDeployConfigPromptOptions): Quest }, type: 'input', name: abapDeployConfigInternalPromptNames.packageCliExecution - }, + } as InputQuestion, { when: (previousAnswers: AbapDeployConfigAnswersInternal): boolean => defaultOrShowManualPackageQuestion(isCli, previousAnswers.packageInputChoice, options.useAutocomplete), diff --git a/packages/abap-deploy-config-inquirer/src/prompts/validators.ts b/packages/abap-deploy-config-inquirer/src/prompts/validators.ts index 10fdb5b8ce..7f5621cea1 100644 --- a/packages/abap-deploy-config-inquirer/src/prompts/validators.ts +++ b/packages/abap-deploy-config-inquirer/src/prompts/validators.ts @@ -47,22 +47,26 @@ export function validateDestinationQuestion(destination: string, destinations?: * @param props.client - client * @param props.isS4HC - is S/4HANA Cloud * @param props.scp - is SCP + * @param props.target - target system */ function updatePromptState({ url, client, isS4HC, - scp + scp, + target }: { url: string; client?: string; isS4HC?: boolean; scp?: boolean; + target?: string; }): void { PromptState.abapDeployConfig.url = url; PromptState.abapDeployConfig.client = client; PromptState.abapDeployConfig.isS4HC = isS4HC; PromptState.abapDeployConfig.scp = scp; + PromptState.abapDeployConfig.targetSystem = target; } /** @@ -104,7 +108,8 @@ export function validateTargetSystem(target?: string, choices?: AbapSystemChoice url: choice.value, client: choice.client ?? '', scp: choice.scp, - isS4HC: choice.isS4HC + isS4HC: choice.isS4HC, + target: target }); } } @@ -419,7 +424,7 @@ async function handleCreateNewTransportChoice( const description = `For ABAP repository ${previousAnswers?.ui5AbapRepo?.toUpperCase()}, created by SAP Fiori Tools`; PromptState.transportAnswers.newTransportNumber = await createTransportNumber( { - packageName: getPackageAnswer(previousAnswers), + packageName: getPackageAnswer(previousAnswers, PromptState.abapDeployConfig.package), ui5AppName: previousAnswers?.ui5AbapRepo ?? '', description: description.length > 60 ? description.slice(0, 57) + '...' : description }, @@ -486,7 +491,7 @@ export async function validateTransportChoiceInput( prevTransportInputChoice?: TransportChoices, backendTarget?: BackendTarget ): Promise { - const packageAnswer = getPackageAnswer(previousAnswers); + const packageAnswer = getPackageAnswer(previousAnswers, PromptState.abapDeployConfig.package); const systemConfig: SystemConfig = { url: PromptState.abapDeployConfig.url, client: PromptState.abapDeployConfig.client, @@ -500,7 +505,6 @@ export async function validateTransportChoiceInput( case TransportChoices.CreateNewChoice: { return handleCreateNewTransportChoice( packageAnswer, - systemConfig, input, previousAnswers, diff --git a/packages/abap-deploy-config-inquirer/src/types.ts b/packages/abap-deploy-config-inquirer/src/types.ts index fc5e45324e..9cc080e856 100644 --- a/packages/abap-deploy-config-inquirer/src/types.ts +++ b/packages/abap-deploy-config-inquirer/src/types.ts @@ -101,14 +101,14 @@ export interface TransportAnswers { } export interface AbapDeployConfigAnswers { + url: string; destination?: string; targetSystem?: string; - url?: string; client?: string; scp?: boolean; ui5AbapRepo?: string; description?: string; - package?: string; + package: string; transport?: string; index?: boolean; overwrite?: boolean; diff --git a/packages/abap-deploy-config-inquirer/src/utils.ts b/packages/abap-deploy-config-inquirer/src/utils.ts index 3fd56a570c..c813d6bcdf 100644 --- a/packages/abap-deploy-config-inquirer/src/utils.ts +++ b/packages/abap-deploy-config-inquirer/src/utils.ts @@ -17,7 +17,6 @@ import type { import type { BackendSystem, BackendSystemKey } from '@sap-ux/store'; import type { Destinations, Destination } from '@sap-ux/btp-utils'; import { CREATE_TR_DURING_DEPLOY } from './constants'; -import { PromptState } from './prompts/prompt-state'; let cachedDestinations: Destinations = {}; let cachedBackendSystems: BackendSystem[] = []; @@ -170,12 +169,12 @@ export async function queryPackages( * Determines the package from the various package related prompts. * * @param previousAnswers - previous answers + * @param statePackage - package from state * @returns package name */ -export function getPackageAnswer(previousAnswers?: AbapDeployConfigAnswersInternal): string { +export function getPackageAnswer(previousAnswers?: AbapDeployConfigAnswersInternal, statePackage?: string): string { // Older versions of YUI do not have a packageInputChoice question - return PromptState.abapDeployConfig.package ?? - previousAnswers?.packageInputChoice === PackageInputChoices.ListExistingChoice + return statePackage || previousAnswers?.packageInputChoice === PackageInputChoices.ListExistingChoice ? previousAnswers?.packageAutocomplete ?? '' : previousAnswers?.packageManual ?? ''; } @@ -212,25 +211,36 @@ export function useCreateTrDuringDeploy(existingDeployTaskConfig?: DeployTaskCon * Determines the url from the various sources. * * @param answers - internal abap deploy config answers + * @param stateUrl - url from state * @returns url if found */ -function getUrlAnswer(answers: AbapDeployConfigAnswersInternal): string | undefined { - let url; - if (answers.targetSystem && answers.targetSystem === TargetSystemType.Url && answers.url) { - url = answers.url; - } else if (PromptState.abapDeployConfig.url) { - url = PromptState.abapDeployConfig.url; +function getUrlAnswer(answers: AbapDeployConfigAnswersInternal, stateUrl?: string): string { + let url = answers.url; + if (stateUrl) { + url = stateUrl; } return url; } + /** * Convert internal answers to external answers to be used for writing deploy config. * - * @param answers - internal abap deploy config answers + * @param answers - internal abap deploy config answers, direct from prompting + * @param state - partial internal abap deploy config answers derived from the state * @returns - external abap deploy config answers */ -export function reconcileAnswers(answers: AbapDeployConfigAnswersInternal): AbapDeployConfigAnswers { - const reconciledAnswers: AbapDeployConfigAnswers = {}; +export function reconcileAnswers( + answers: AbapDeployConfigAnswersInternal, + state: Partial +): AbapDeployConfigAnswers { + // Add dervied service answers to the answers object + answers = Object.assign(answers, state); + + const reconciledAnswers: AbapDeployConfigAnswers = { + url: getUrlAnswer(answers, state.url), + package: getPackageAnswer(answers, state.package) + }; + if (answers.destination) { reconciledAnswers.destination = answers.destination; } @@ -239,16 +249,11 @@ export function reconcileAnswers(answers: AbapDeployConfigAnswersInternal): Abap reconciledAnswers.url = answers.targetSystem; } - const url = getUrlAnswer(answers); - if (url) { - reconciledAnswers.url = url; + if (answers.client || state.client) { + reconciledAnswers.client = answers.client || state.client; } - if (answers.client || PromptState.abapDeployConfig.client) { - reconciledAnswers.client = answers.client || PromptState.abapDeployConfig.client; - } - - if (answers.scp || PromptState.abapDeployConfig.scp) { + if (answers.scp || state.scp) { reconciledAnswers.scp = true; } @@ -260,11 +265,6 @@ export function reconcileAnswers(answers: AbapDeployConfigAnswersInternal): Abap reconciledAnswers.description = answers.description; } - const packageAnswer = getPackageAnswer(answers); - if (packageAnswer) { - reconciledAnswers.package = packageAnswer; - } - const transportAnswer = getTransportAnswer(answers); if (transportAnswer) { reconciledAnswers.transport = transportAnswer; diff --git a/packages/abap-deploy-config-inquirer/test/index.test.ts b/packages/abap-deploy-config-inquirer/test/index.test.ts index ff55d5789a..c2117863b3 100644 --- a/packages/abap-deploy-config-inquirer/test/index.test.ts +++ b/packages/abap-deploy-config-inquirer/test/index.test.ts @@ -25,8 +25,10 @@ describe('index', () => { }); const answers: AbapDeployConfigAnswersInternal = { + url: '', targetSystem: 'https://mock.url.target1.com', client: '000', + package: '', ui5AbapRepo: 'mockRepo', packageManual: 'mockPackage', transportManual: 'mockTransport' diff --git a/packages/abap-deploy-config-inquirer/test/prompts/conditions.test.ts b/packages/abap-deploy-config-inquirer/test/prompts/conditions.test.ts index d474575695..492fd6ff29 100644 --- a/packages/abap-deploy-config-inquirer/test/prompts/conditions.test.ts +++ b/packages/abap-deploy-config-inquirer/test/prompts/conditions.test.ts @@ -50,9 +50,9 @@ describe('Test abap deploy config inquirer conditions', () => { test('should not show scp question', async () => { // 1 target not chosen - expect(showScpQuestion({})).toBe(false); + expect(showScpQuestion({ url: '', package: '' })).toBe(false); // 2 url target chosen but no url provided - expect(showScpQuestion({ targetSystem: 'Url', url: '' })).toBe(false); + expect(showScpQuestion({ targetSystem: 'Url', url: '', package: '' })).toBe(false); // 3 scp value has been retrieved from existing system jest.spyOn(utils, 'findBackendSystemByUrl').mockReturnValue({ @@ -60,12 +60,12 @@ describe('Test abap deploy config inquirer conditions', () => { url: 'http://saved.target.url', client: '100' }); - expect(showScpQuestion({ url: 'http://saved.target.url' })).toBe(false); + expect(showScpQuestion({ url: 'http://saved.target.url', package: '' })).toBe(false); }); test('should show scp question', async () => { jest.spyOn(utils, 'findBackendSystemByUrl').mockReturnValue(undefined); - expect(showScpQuestion({ targetSystem: 'Url', url: 'http://new.target.url' })).toBe(true); + expect(showScpQuestion({ targetSystem: 'Url', url: 'http://new.target.url', package: '' })).toBe(true); }); test('should show client choice question', () => { @@ -128,7 +128,7 @@ describe('Test abap deploy config inquirer conditions', () => { test('should show package input choice question', () => { // cli PromptState.isYUI = false; - expect(showPackageInputChoiceQuestion()).toBe(true); + expect(showPackageInputChoiceQuestion(true)).toBe(true); }); test('should not show package input choice question', () => { diff --git a/packages/abap-deploy-config-inquirer/test/prompts/helpers.test.ts b/packages/abap-deploy-config-inquirer/test/prompts/helpers.test.ts index 9d9e039ffb..6a2f84e070 100644 --- a/packages/abap-deploy-config-inquirer/test/prompts/helpers.test.ts +++ b/packages/abap-deploy-config-inquirer/test/prompts/helpers.test.ts @@ -73,7 +73,7 @@ describe('helpers', () => { }); it('should update prompt state url (destination)', () => { - updatePromptStateUrl({ destination: 'Dest1' }, mockDestinations); + updatePromptStateUrl({ url: '', package: '', destination: 'Dest1' }, mockDestinations); expect(PromptState.abapDeployConfig.url).toBe('https://mock.url.dest1.com'); }); }); @@ -81,7 +81,7 @@ describe('helpers', () => { describe('getPackageChoices', () => { it('should return package choices and empty message', async () => { mockQueryPackages.mockResolvedValueOnce(['package1', 'package2']); - const result = await getPackageChoices(true, 'pack', {}); + const result = await getPackageChoices(true, 'pack', { url: '', package: '' }); expect(result).toEqual({ packages: ['package1', 'package2'], morePackageResultsMsg: '' @@ -96,7 +96,7 @@ describe('helpers', () => { } mockQueryPackages.mockResolvedValueOnce(packages); - const result = await getPackageChoices(true, 'pack', {}); + const result = await getPackageChoices(true, 'pack', { url: '', package: '' }); expect(result).toEqual({ packages, morePackageResultsMsg: t('prompts.config.package.packageAutocomplete.sourceMessage', { @@ -108,7 +108,11 @@ describe('helpers', () => { it('should return package choices and have previous answer to the top', async () => { mockQueryPackages.mockResolvedValueOnce(['package1', 'package2', 'package3']); - const result = await getPackageChoices(true, 'pack', { packageAutocomplete: 'package3' }); + const result = await getPackageChoices(true, 'pack', { + url: '', + package: '', + packageAutocomplete: 'package3' + }); expect(result).toEqual({ packages: ['package3', 'package1', 'package2'], morePackageResultsMsg: '' diff --git a/packages/abap-deploy-config-inquirer/test/prompts/questions/abap-target.test.ts b/packages/abap-deploy-config-inquirer/test/prompts/questions/abap-target.test.ts index dba5467ba3..d8d7474e79 100644 --- a/packages/abap-deploy-config-inquirer/test/prompts/questions/abap-target.test.ts +++ b/packages/abap-deploy-config-inquirer/test/prompts/questions/abap-target.test.ts @@ -53,6 +53,7 @@ describe('getAbapTargetPrompts', () => { "filter": [Function], "guiOptions": Object { "breadcrumb": true, + "mandatory": true, }, "message": "Destination", "name": "destination", @@ -65,6 +66,7 @@ describe('getAbapTargetPrompts', () => { "default": [Function], "guiOptions": Object { "breadcrumb": "Target System", + "mandatory": true, }, "message": "Select Target system", "name": "targetSystem", diff --git a/packages/abap-deploy-config-inquirer/test/prompts/validators.test.ts b/packages/abap-deploy-config-inquirer/test/prompts/validators.test.ts index 75d27056c7..70c93411ea 100644 --- a/packages/abap-deploy-config-inquirer/test/prompts/validators.test.ts +++ b/packages/abap-deploy-config-inquirer/test/prompts/validators.test.ts @@ -20,13 +20,7 @@ import { validateUrl } from '../../src/prompts/validators'; import * as validatorUtils from '../../src/validator-utils'; -import { - AbapDeployConfigAnswersInternal, - ClientChoiceValue, - PackageInputChoices, - TargetSystemType, - TransportChoices -} from '../../src/types'; +import { ClientChoiceValue, PackageInputChoices, TargetSystemType, TransportChoices } from '../../src/types'; import * as utils from '../../src/utils'; import { mockDestinations } from '../fixtures/destinations'; import * as serviceProviderUtils from '../../src/service-provider-utils'; @@ -36,6 +30,11 @@ jest.mock('../../src/service-provider-utils', () => ({ })); describe('Test validators', () => { + const previousAnswers = { + url: 'https://mock.url.target1.com', + package: 'ZPACKAGE' + }; + beforeAll(async () => { await initI18n(); }); @@ -78,7 +77,8 @@ describe('Test validators', () => { client: '001', destination: undefined, isS4HC: false, - scp: false + scp: false, + targetSystem: 'https://mock.url.target1.com' }); expect(result).toBe(true); }); @@ -190,7 +190,7 @@ describe('Test validators', () => { describe('validateCredentials', () => { it('should return error for no credentials', async () => { - expect(await validateCredentials('', {})).toBe(t('errors.requireCredentials')); + expect(await validateCredentials('', previousAnswers)).toBe(t('errors.requireCredentials')); }); it('should return true for valid credentials', async () => { @@ -198,7 +198,7 @@ describe('Test validators', () => { transportConfig: {} as any, transportConfigNeedsCreds: false }); - expect(await validateCredentials('pass1', { username: 'user1' })).toBe(true); + expect(await validateCredentials('pass1', { ...previousAnswers, username: 'user1' })).toBe(true); }); it('should return error message for invalid credentials', async () => { @@ -206,7 +206,9 @@ describe('Test validators', () => { transportConfig: {} as any, transportConfigNeedsCreds: true }); - expect(await validateCredentials('pass1', { username: 'user1' })).toBe(t('errors.incorrectCredentials')); + expect(await validateCredentials('pass1', { ...previousAnswers, username: 'user1' })).toBe( + t('errors.incorrectCredentials') + ); }); }); @@ -284,13 +286,14 @@ describe('Test validators', () => { const getTransportListFromServiceSpy = jest.spyOn(serviceProviderUtils, 'getTransportListFromService'); const result = await validatePackage('zpackage', { + ...previousAnswers, ui5AbapRepo: 'ZUI5REPO' }); expect(result).toBe(true); expect(getTransportListFromServiceSpy).toBeCalledWith('ZPACKAGE', 'ZUI5REPO', {}, undefined); }); it('should return error for invalid package input', async () => { - const result = await validatePackage(' ', {}); + const result = await validatePackage(' ', previousAnswers); expect(result).toBe(t('warnings.providePackage')); }); }); @@ -301,46 +304,51 @@ describe('Test validators', () => { }); it('should return error for invalid package / ui5 abap repo name', async () => { - const previousAnswers: AbapDeployConfigAnswersInternal = {}; let result = await validateTransportChoiceInput(TransportChoices.ListExistingChoice, previousAnswers); expect(result).toBe(t('errors.validators.transportListPreReqs')); - previousAnswers.packageManual = 'ZPACKAGE'; - result = await validateTransportChoiceInput(TransportChoices.ListExistingChoice, previousAnswers); + result = await validateTransportChoiceInput(TransportChoices.ListExistingChoice, { + ...previousAnswers, + packageManual: 'ZPACKAGE' + }); expect(result).toBe(t('errors.validators.transportListPreReqs')); }); it('should return true for listing transport when transport request found for given config', async () => { - const previousAnswers: AbapDeployConfigAnswersInternal = { - packageManual: 'ZPACKAGE', - ui5AbapRepo: 'ZUI5REPO' - }; jest.spyOn(validatorUtils, 'getTransportList').mockResolvedValueOnce([ { transportReqNumber: 'K123456', transportReqDescription: 'Mock transport request' } ]); - const result = await validateTransportChoiceInput(TransportChoices.ListExistingChoice, previousAnswers); + const result = await validateTransportChoiceInput(TransportChoices.ListExistingChoice, { + ...previousAnswers, + packageManual: 'ZPACKAGE', + ui5AbapRepo: 'ZUI5REPO' + }); expect(result).toBe(true); }); it('should return errors messages for listing transport when transport request empty or undefined', async () => { - const previousAnswers: AbapDeployConfigAnswersInternal = { + jest.spyOn(validatorUtils, 'getTransportList').mockResolvedValueOnce([]); + + let result = await validateTransportChoiceInput(TransportChoices.ListExistingChoice, { + ...previousAnswers, packageManual: 'ZPACKAGE', ui5AbapRepo: 'ZUI5REPO' - }; - jest.spyOn(validatorUtils, 'getTransportList').mockResolvedValueOnce([]); - let result = await validateTransportChoiceInput(TransportChoices.ListExistingChoice, previousAnswers); + }); expect(result).toBe(t('warnings.noTransportReqs')); jest.spyOn(validatorUtils, 'getTransportList').mockResolvedValueOnce(undefined); - result = await validateTransportChoiceInput(TransportChoices.ListExistingChoice, previousAnswers); + result = await validateTransportChoiceInput(TransportChoices.ListExistingChoice, { + ...previousAnswers, + packageManual: 'ZPACKAGE', + ui5AbapRepo: 'ZUI5REPO' + }); expect(result).toBe(t('warnings.noExistingTransportReqList')); }); it('should return true if transport request is same as previous', async () => { const result = await validateTransportChoiceInput( TransportChoices.CreateNewChoice, - {}, - + previousAnswers, true, TransportChoices.CreateNewChoice ); @@ -354,8 +362,7 @@ describe('Test validators', () => { const result = await validateTransportChoiceInput( TransportChoices.CreateNewChoice, - {}, - + previousAnswers, true, undefined ); @@ -368,8 +375,7 @@ describe('Test validators', () => { const result = await validateTransportChoiceInput( TransportChoices.CreateNewChoice, - {}, - + previousAnswers, false, undefined ); @@ -382,8 +388,7 @@ describe('Test validators', () => { const result = await validateTransportChoiceInput( TransportChoices.CreateNewChoice, - {}, - + previousAnswers, false, undefined ); @@ -394,8 +399,7 @@ describe('Test validators', () => { it('should return error if creating a new transport request returns undefined', async () => { const result = await validateTransportChoiceInput( TransportChoices.EnterManualChoice, - {}, - + previousAnswers, false, undefined ); diff --git a/packages/abap-deploy-config-inquirer/test/utils.test.ts b/packages/abap-deploy-config-inquirer/test/utils.test.ts index a3a40c1e99..405da2c874 100644 --- a/packages/abap-deploy-config-inquirer/test/utils.test.ts +++ b/packages/abap-deploy-config-inquirer/test/utils.test.ts @@ -181,6 +181,8 @@ describe('Test utils', () => { it('should get package answer', () => { const previousAnswers = { + url: 'https://target.url', + package: '', packageInputChoice: PackageInputChoices.ListExistingChoice, packageAutocomplete: 'package1', packageManual: '' @@ -220,9 +222,11 @@ describe('Test utils', () => { }; const internalAnswers: AbapDeployConfigAnswersInternal = { + url: '', destination: 'Dest1', ui5AbapRepo: 'Mock Repo', description: 'Mock Description', + package: '', packageInputChoice: PackageInputChoices.EnterManualChoice, packageManual: 'PKGMOCK', transportInputChoice: TransportChoices.ListExistingChoice, @@ -230,7 +234,7 @@ describe('Test utils', () => { index: true }; - expect(reconcileAnswers(internalAnswers)).toStrictEqual(expectedAnswers); + expect(reconcileAnswers(internalAnswers, PromptState.abapDeployConfig)).toStrictEqual(expectedAnswers); }); it('should return reconciled answers for target system', () => { @@ -251,9 +255,11 @@ describe('Test utils', () => { }; const internalAnswers: AbapDeployConfigAnswersInternal = { + url: 'http://dest.btp.url', targetSystem: 'htpp://target.url', ui5AbapRepo: 'Mock Repo', description: 'Mock Description', + package: '', packageInputChoice: PackageInputChoices.ListExistingChoice, packageAutocomplete: 'PKGMOCK', transportInputChoice: TransportChoices.CreateDuringDeployChoice, @@ -261,6 +267,6 @@ describe('Test utils', () => { overwrite: false }; - expect(reconcileAnswers(internalAnswers)).toStrictEqual(expectedAnswers); + expect(reconcileAnswers(internalAnswers, PromptState.abapDeployConfig)).toStrictEqual(expectedAnswers); }); }); diff --git a/packages/abap-deploy-config-writer/CHANGELOG.md b/packages/abap-deploy-config-writer/CHANGELOG.md index c1085828b9..e796058b69 100644 --- a/packages/abap-deploy-config-writer/CHANGELOG.md +++ b/packages/abap-deploy-config-writer/CHANGELOG.md @@ -1,5 +1,21 @@ # @sap-ux/abap-deploy-config-writer +## 0.0.40 + +### Patch Changes + +- Updated dependencies [d962ce1] + - @sap-ux/project-access@1.27.1 + - @sap-ux/system-access@0.5.10 + +## 0.0.39 + +### Patch Changes + +- Updated dependencies [df29368] + - @sap-ux/project-access@1.27.0 + - @sap-ux/system-access@0.5.10 + ## 0.0.38 ### Patch Changes diff --git a/packages/abap-deploy-config-writer/package.json b/packages/abap-deploy-config-writer/package.json index b490782543..afa467a746 100644 --- a/packages/abap-deploy-config-writer/package.json +++ b/packages/abap-deploy-config-writer/package.json @@ -6,7 +6,7 @@ "url": "https://github.com/SAP/open-ux-tools.git", "directory": "packages/abap-deploy-config-writer" }, - "version": "0.0.38", + "version": "0.0.40", "license": "Apache-2.0", "main": "dist/index.js", "scripts": { diff --git a/packages/adp-tooling/CHANGELOG.md b/packages/adp-tooling/CHANGELOG.md index 9d1add5d5f..70e44bf539 100644 --- a/packages/adp-tooling/CHANGELOG.md +++ b/packages/adp-tooling/CHANGELOG.md @@ -1,5 +1,29 @@ # @sap-ux/adp-tooling +## 0.12.45 + +### Patch Changes + +- Updated dependencies [d962ce1] + - @sap-ux/project-access@1.27.1 + - @sap-ux/axios-extension@1.16.5 + - @sap-ux/system-access@0.5.10 + +## 0.12.44 + +### Patch Changes + +- 1294b1c: fix: URI validation not executed for Replace OData prompting + +## 0.12.43 + +### Patch Changes + +- Updated dependencies [df29368] + - @sap-ux/project-access@1.27.0 + - @sap-ux/axios-extension@1.16.5 + - @sap-ux/system-access@0.5.10 + ## 0.12.42 ### Patch Changes diff --git a/packages/adp-tooling/package.json b/packages/adp-tooling/package.json index 44dfde1083..878f467e38 100644 --- a/packages/adp-tooling/package.json +++ b/packages/adp-tooling/package.json @@ -9,7 +9,7 @@ "bugs": { "url": "https://github.com/SAP/open-ux-tools/issues?q=is%3Aopen+is%3Aissue+label%3Abug+label%3Aadp-tooling" }, - "version": "0.12.42", + "version": "0.12.45", "license": "Apache-2.0", "author": "@SAP/ux-tools-team", "main": "dist/index.js", diff --git a/packages/adp-tooling/src/prompts/change-data-source/index.ts b/packages/adp-tooling/src/prompts/change-data-source/index.ts index c3ef3a340b..56d1a98364 100644 --- a/packages/adp-tooling/src/prompts/change-data-source/index.ts +++ b/packages/adp-tooling/src/prompts/change-data-source/index.ts @@ -3,7 +3,45 @@ import type { ManifestNamespace } from '@sap-ux/project-access'; import type { ChangeDataSourceAnswers } from '../../types'; import { t } from '../../i18n'; import { filterDataSourcesByType } from '@sap-ux/project-access'; -import { validateEmptyString } from '@sap-ux/project-input-validator'; +import { validateEmptyString, isDataSourceURI } from '@sap-ux/project-input-validator'; + +/** + * Validates the OData Source URI prompt. + * + * @param value The value to validate. + * @returns {boolean | string} True if the value is a valid URI, or an error message if not a valid URI or empty. + */ +function validatePromptOdataURI(value: string): boolean | string { + const validationResult = validateEmptyString(value); + if (typeof validationResult === 'string') { + return validationResult; + } + + if (!isDataSourceURI(value)) { + return t('validators.errorInvalidDataSourceURI'); + } + + return true; +} + +/** + * Validates the Annotation URI prompt. + * + * @param value The value to validate. + * @returns {boolean | string} True if the value is a valid URI or empty, or an error message if not a valid URI. + */ +function validatePromptAnnotationURI(value: string): boolean | string { + const validationResult = validateEmptyString(value); + if (typeof validationResult === 'string') { + return true; + } + + if (!isDataSourceURI(value)) { + return t('validators.errorInvalidDataSourceURI'); + } + + return true; +} /** * Gets the prompts for changing the data source. @@ -36,7 +74,7 @@ export function getPrompts( mandatory: true, hint: t('prompts.oDataSourceURITooltip') }, - validate: validateEmptyString, + validate: validatePromptOdataURI, when: !!dataSourceIds.length, store: false } as InputQuestion, @@ -55,7 +93,8 @@ export function getPrompts( message: t('prompts.oDataAnnotationSourceURILabel'), guiOptions: { hint: t('prompts.oDataAnnotationSourceURITooltip') - } + }, + validate: validatePromptAnnotationURI } as InputQuestion ]; } diff --git a/packages/adp-tooling/src/translations/adp-tooling.i18n.json b/packages/adp-tooling/src/translations/adp-tooling.i18n.json index 6c4d3f4ad3..a1e3c488bd 100644 --- a/packages/adp-tooling/src/translations/adp-tooling.i18n.json +++ b/packages/adp-tooling/src/translations/adp-tooling.i18n.json @@ -3,32 +3,32 @@ "oDataSourceLabel": "Target OData Service", "oDataSourceTooltip": "Select the OData service you want to replace", "oDataSourceURILabel": "OData Source URI", - "oDataSourceURITooltip": "Enter the URI for the new OData source", + "oDataSourceURITooltip": "Enter the URI for the new OData source. The URI should start and end with '/' and should not contain any whitespaces or parameters", "maxAgeLabel": "New maxAge", "maxAgeTooltip": "(Optional) Enter the time in seconds it takes for a cached response of the service to expire.", "oDataAnnotationSourceURILabel": "OData Annotation Data Source URI", - "oDataAnnotationSourceURITooltip": "Server side annotations have been detected for the selected OData service. If needed, enter new annotation data source URI for the changed service", + "oDataAnnotationSourceURITooltip": "Server side annotations have been detected for the selected OData service. If needed, enter new annotation data source URI for the changed service. The URI should start and end with '/' and should not contain any whitespaces or parameters", "fileSelectOptionLabel": "Annotation XML", "fileSelectOptionTooltip": "Select the annotation file source", "filePathLabel": "Annotation File path", "filePathTooltip": "Select the annotation file from your workspace", - "addAnnotationOdataSourceTooltip": "Select the annotation file from your workspace", + "addAnnotationOdataSourceTooltip": "Select the OData service you want to add annotation file to", "oDataServiceNameLabel": "OData Service Name", "oDataServiceNameTooltip": "Enter a name for the OData service you want to add", "oDataServiceUriLabel": "OData Service URI", - "oDataServiceUriTooltip": "Enter the URI for the OData service you want to add, e.g. /path/to/odata/", + "oDataServiceUriTooltip": "Enter the URI for the OData service you want to add. The URI should start and end with '/' and should not contain any whitespaces or parameters", "oDataServiceVersionLabel": "OData Version", "oDataServiceVersionTooltip": "Select the version of OData of the service you want to add", "oDataServiceModelNameLabel": "OData Service SAPUI5 Model Name", "oDataServiceModelNameTooltip": "Enter a name for the SAPUI5 model you want to use from the service", "oDataServiceModelSettingsLabel": "OData Service SAPUI5 Model Settings", - "oDataServiceModelSettingsTooltip": "If needed enter any additional model settings in the 'key1':'value1','key2':'value2' format", + "oDataServiceModelSettingsTooltip": "If needed enter any additional model settings in the \"key1\":\"value1\",\"key2\":\"value2\" format", "oDataAnnotationDataSourceNameLabel": "OData Annotation Data Source Name", "oDataAnnotationDataSourceNameTooltip": "Enter a name for the OData annotation data source", "oDataAnnotationDataSourceUriLabel": "OData Annotation Data Source URI", - "oDataAnnotationDataSourceUriTooltip": "Enter URI for the OData annotation data source", + "oDataAnnotationDataSourceUriTooltip": "Enter URI for the OData annotation data source. The URI should start and end with '/' and should not contain any whitespaces or parameters", "oDataAnnotationSettingsLabel": "OData Annotation Settings", - "oDataAnnotationSettingsTooltip": "If needed enter any additional {{value}} settings in the 'key':'value1','key2':'value2' format", + "oDataAnnotationSettingsTooltip": "If needed enter any additional {{value}} settings in the \"key1\":\"value1\",\"key2\":\"value2\" format", "component": { "usage": "Component usage", "usageIdLabel": "Component Usage ID", diff --git a/packages/adp-tooling/test/unit/prompts/change-data-source/index.test.ts b/packages/adp-tooling/test/unit/prompts/change-data-source/index.test.ts index eebb9607d4..a83c0f5951 100644 --- a/packages/adp-tooling/test/unit/prompts/change-data-source/index.test.ts +++ b/packages/adp-tooling/test/unit/prompts/change-data-source/index.test.ts @@ -75,7 +75,8 @@ describe('getPrompts', () => { message: i18n.t('prompts.oDataAnnotationSourceURILabel'), guiOptions: { hint: i18n.t('prompts.oDataAnnotationSourceURITooltip') - } + }, + validate: expect.any(Function) } ]); const maxAgeCondition = (prompts[2] as any).when; @@ -126,10 +127,77 @@ describe('getPrompts', () => { message: i18n.t('prompts.oDataAnnotationSourceURILabel'), guiOptions: { hint: i18n.t('prompts.oDataAnnotationSourceURITooltip') - } + }, + validate: expect.any(Function) } ]); const maxAgeCondition = (prompts[2] as any).when; expect(maxAgeCondition({ uri: '' })).toBeFalsy(); }); + + describe('Validations', () => { + describe('Odata URI validation', () => { + test('should return true for valid URI', () => { + const uri = '/sap/test/odata/'; + const result = (getPrompts({})[1].validate as Function)(uri); + expect(result).toBe(true); + }); + + test('should return error message for empty URI', () => { + const uri = ''; + const result = (getPrompts({})[1].validate as Function)(uri); + expect(result).toBe('general.inputCannotBeEmpty'); + }); + + test('should return error message for URI with whitespaces', () => { + const uri = 'sap/test /odata/'; + const result = (getPrompts({})[1].validate as Function)(uri); + expect(result).toBe("Invalid URI. Should start and end with '/' and contain no spaces"); + }); + + test('should return error message for URI without "/" at the end', () => { + const uri = '/sap/test'; + const result = (getPrompts({})[1].validate as Function)(uri); + expect(result).toBe("Invalid URI. Should start and end with '/' and contain no spaces"); + }); + + test('should return error message for URI without "/" at the beginning', () => { + const uri = 'sap/test/'; + const result = (getPrompts({})[1].validate as Function)(uri); + expect(result).toBe("Invalid URI. Should start and end with '/' and contain no spaces"); + }); + }); + + describe('OData Annotation URI validation', () => { + test('should return true for valid URI', () => { + const uri = '/sap/test/odata/'; + const result = (getPrompts({})[3].validate as Function)(uri); + expect(result).toBe(true); + }); + + test('should return true for empty URI', () => { + const uri = ''; + const result = (getPrompts({})[3].validate as Function)(uri); + expect(result).toBe(true); + }); + + test('should return error message for URI with whitespaces', () => { + const uri = 'sap/test /odata/'; + const result = (getPrompts({})[3].validate as Function)(uri); + expect(result).toBe("Invalid URI. Should start and end with '/' and contain no spaces"); + }); + + test('should return error message for URI without "/" at the end', () => { + const uri = '/sap/test'; + const result = (getPrompts({})[3].validate as Function)(uri); + expect(result).toBe("Invalid URI. Should start and end with '/' and contain no spaces"); + }); + + test('should return error message for URI without "/" at the beginning', () => { + const uri = 'sap/test/'; + const result = (getPrompts({})[3].validate as Function)(uri); + expect(result).toBe("Invalid URI. Should start and end with '/' and contain no spaces"); + }); + }); + }); }); diff --git a/packages/annotation-generator/CHANGELOG.md b/packages/annotation-generator/CHANGELOG.md index 2a22aec64e..8d21901bc9 100644 --- a/packages/annotation-generator/CHANGELOG.md +++ b/packages/annotation-generator/CHANGELOG.md @@ -1,5 +1,21 @@ # @sap-ux/annotation-generator +## 0.1.30 + +### Patch Changes + +- Updated dependencies [d962ce1] + - @sap-ux/project-access@1.27.1 + - @sap-ux/fiori-annotation-api@0.1.40 + +## 0.1.29 + +### Patch Changes + +- Updated dependencies [df29368] + - @sap-ux/project-access@1.27.0 + - @sap-ux/fiori-annotation-api@0.1.39 + ## 0.1.28 ### Patch Changes diff --git a/packages/annotation-generator/package.json b/packages/annotation-generator/package.json index 5623829f40..c88c35ad4d 100644 --- a/packages/annotation-generator/package.json +++ b/packages/annotation-generator/package.json @@ -1,6 +1,6 @@ { "name": "@sap-ux/annotation-generator", - "version": "0.1.28", + "version": "0.1.30", "description": "Library that provides API for generation of annotations by SAP Fiori App Generator", "publisher": "SAPSE", "author": "SAP SE", diff --git a/packages/app-config-writer/CHANGELOG.md b/packages/app-config-writer/CHANGELOG.md index ccb4b73a69..8debd6b38a 100644 --- a/packages/app-config-writer/CHANGELOG.md +++ b/packages/app-config-writer/CHANGELOG.md @@ -1,5 +1,21 @@ # @sap-ux/app-config-writer +## 0.4.31 + +### Patch Changes + +- Updated dependencies [d962ce1] + - @sap-ux/project-access@1.27.1 + - @sap-ux/axios-extension@1.16.5 + +## 0.4.30 + +### Patch Changes + +- Updated dependencies [df29368] + - @sap-ux/project-access@1.27.0 + - @sap-ux/axios-extension@1.16.5 + ## 0.4.29 ### Patch Changes diff --git a/packages/app-config-writer/package.json b/packages/app-config-writer/package.json index e1bf5d24e1..a3e1bc9052 100644 --- a/packages/app-config-writer/package.json +++ b/packages/app-config-writer/package.json @@ -1,7 +1,7 @@ { "name": "@sap-ux/app-config-writer", "description": "Add or update configuration for SAP Fiori tools application", - "version": "0.4.29", + "version": "0.4.31", "repository": { "type": "git", "url": "https://github.com/SAP/open-ux-tools.git", diff --git a/packages/cap-config-writer/CHANGELOG.md b/packages/cap-config-writer/CHANGELOG.md index 199d42b0b2..e6d43500e3 100644 --- a/packages/cap-config-writer/CHANGELOG.md +++ b/packages/cap-config-writer/CHANGELOG.md @@ -1,5 +1,37 @@ # @sap-ux/cap-config-writer +## 0.7.36 + +### Patch Changes + +- Updated dependencies [d962ce1] + - @sap-ux/project-access@1.27.1 + - @sap-ux/odata-service-inquirer@0.5.36 + - @sap-ux/fiori-generator-shared@0.3.19 + +## 0.7.35 + +### Patch Changes + +- Updated dependencies [eb958a1] + - @sap-ux/odata-service-inquirer@0.5.35 + +## 0.7.34 + +### Patch Changes + +- Updated dependencies [8a84adf] + - @sap-ux/odata-service-inquirer@0.5.34 + +## 0.7.33 + +### Patch Changes + +- Updated dependencies [df29368] + - @sap-ux/project-access@1.27.0 + - @sap-ux/fiori-generator-shared@0.3.18 + - @sap-ux/odata-service-inquirer@0.5.33 + ## 0.7.32 ### Patch Changes diff --git a/packages/cap-config-writer/package.json b/packages/cap-config-writer/package.json index c90fe57af4..eff4ddfa63 100644 --- a/packages/cap-config-writer/package.json +++ b/packages/cap-config-writer/package.json @@ -1,7 +1,7 @@ { "name": "@sap-ux/cap-config-writer", "description": "Add or update configuration for SAP CAP projects", - "version": "0.7.32", + "version": "0.7.36", "repository": { "type": "git", "url": "https://github.com/SAP/open-ux-tools.git", diff --git a/packages/cards-editor-middleware/CHANGELOG.md b/packages/cards-editor-middleware/CHANGELOG.md index b1676a9471..4decc8c716 100644 --- a/packages/cards-editor-middleware/CHANGELOG.md +++ b/packages/cards-editor-middleware/CHANGELOG.md @@ -1,5 +1,19 @@ # @sap-ux/cards-editor-middleware +## 0.4.22 + +### Patch Changes + +- Updated dependencies [d962ce1] + - @sap-ux/project-access@1.27.1 + +## 0.4.21 + +### Patch Changes + +- Updated dependencies [df29368] + - @sap-ux/project-access@1.27.0 + ## 0.4.20 ### Patch Changes diff --git a/packages/cards-editor-middleware/package.json b/packages/cards-editor-middleware/package.json index 76ec6600ec..8b5a8292ed 100644 --- a/packages/cards-editor-middleware/package.json +++ b/packages/cards-editor-middleware/package.json @@ -1,6 +1,6 @@ { "name": "@sap-ux/cards-editor-middleware", - "version": "0.4.20", + "version": "0.4.22", "repository": { "type": "git", "url": "https://github.com/SAP/open-ux-tools.git", diff --git a/packages/control-property-editor-common/CHANGELOG.md b/packages/control-property-editor-common/CHANGELOG.md index bdb01adb36..67fb805efe 100644 --- a/packages/control-property-editor-common/CHANGELOG.md +++ b/packages/control-property-editor-common/CHANGELOG.md @@ -1,5 +1,11 @@ # @sap-ux-private/control-property-editor-common +## 0.5.0 + +### Minor Changes + +- b1628da: Add quick actions to adaptation editor + ## 0.4.0 ### Minor Changes diff --git a/packages/control-property-editor-common/package.json b/packages/control-property-editor-common/package.json index 4ced17ad26..93a2f887ea 100644 --- a/packages/control-property-editor-common/package.json +++ b/packages/control-property-editor-common/package.json @@ -3,7 +3,7 @@ "displayName": "Control Property Editor Common", "description": "A common module for Control Property Editor react app and ui5", "license": "Apache-2.0", - "version": "0.4.0", + "version": "0.5.0", "main": "dist/index.js", "repository": { "type": "git", diff --git a/packages/control-property-editor-common/src/api.ts b/packages/control-property-editor-common/src/api.ts index 577a8ee39e..e8d5aefa89 100644 --- a/packages/control-property-editor-common/src/api.ts +++ b/packages/control-property-editor-common/src/api.ts @@ -167,6 +167,48 @@ export interface ShowMessage { shouldHideIframe: boolean; } +export const SIMPLE_QUICK_ACTION_KIND = 'simple'; +export interface SimpleQuickAction { + kind: typeof SIMPLE_QUICK_ACTION_KIND; + id: string; + title: string; + enabled: boolean; +} + +export const NESTED_QUICK_ACTION_KIND = 'nested'; +export interface NestedQuickAction { + kind: typeof NESTED_QUICK_ACTION_KIND; + id: string; + title: string; + enabled: boolean; + children: NestedQuickActionChild[]; +} + +export interface NestedQuickActionChild { + label: string; + children: NestedQuickActionChild[]; +} + +export type QuickAction = SimpleQuickAction | NestedQuickAction; + +export interface QuickActionGroup { + title: string; + actions: QuickAction[]; +} + +export interface SimpleQuickActionExecutionPayload { + kind: typeof SIMPLE_QUICK_ACTION_KIND; + id: string; +} + +export interface NestedQuickActionExecutionPayload { + kind: typeof NESTED_QUICK_ACTION_KIND; + id: string; + path: string; +} + +export type QuickActionExecutionPayload = SimpleQuickActionExecutionPayload | NestedQuickActionExecutionPayload; + /** * ACTIONS */ @@ -243,6 +285,9 @@ export const appLoaded = createExternalAction('app-loaded'); export const undo = createExternalAction('undo'); export const redo = createExternalAction('redo'); export const save = createExternalAction('save'); +export const quickActionListChanged = createExternalAction('quick-action-list-changed'); +export const updateQuickAction = createExternalAction('update-quick-action'); +export const executeQuickAction = createExternalAction('execute-quick-action'); export type ExternalAction = | ReturnType @@ -264,4 +309,7 @@ export type ExternalAction = | ReturnType | ReturnType | ReturnType - | ReturnType; + | ReturnType + | ReturnType + | ReturnType + | ReturnType; diff --git a/packages/control-property-editor/CHANGELOG.md b/packages/control-property-editor/CHANGELOG.md index feff4d95c2..956557167b 100644 --- a/packages/control-property-editor/CHANGELOG.md +++ b/packages/control-property-editor/CHANGELOG.md @@ -1,5 +1,23 @@ # @sap-ux/control-property-editor +## 0.5.1 + +### Patch Changes + +- 0b7af6a: remove z-index for sticky Search and filter bar and added updating highlighting control logic + +## 0.5.0 + +### Minor Changes + +- b1628da: Add quick actions to adaptation editor + +## 0.4.30 + +### Patch Changes + +- 2b6daf3: Changed color of chevron on selection + ## 0.4.29 ### Patch Changes diff --git a/packages/control-property-editor/package.json b/packages/control-property-editor/package.json index 57a9a9ddd0..82015d4158 100644 --- a/packages/control-property-editor/package.json +++ b/packages/control-property-editor/package.json @@ -3,7 +3,7 @@ "displayName": "Control Property Editor", "description": "Control Property Editor", "license": "Apache-2.0", - "version": "0.4.29", + "version": "0.5.1", "main": "dist/app.js", "repository": { "type": "git", diff --git a/packages/control-property-editor/src/App.tsx b/packages/control-property-editor/src/App.tsx index b2666e416b..32e9498171 100644 --- a/packages/control-property-editor/src/App.tsx +++ b/packages/control-property-editor/src/App.tsx @@ -4,7 +4,7 @@ import { useSelector } from 'react-redux'; import { useTranslation } from 'react-i18next'; import { UIDialog, UILink, UIToggle } from '@sap-ux/ui-components'; import type { Scenario, ShowMessage } from '@sap-ux-private/control-property-editor-common'; -import { LeftPanel, PropertiesList } from './panels'; +import { LeftPanel, RightPanel } from './panels'; import { Toolbar } from './toolbar'; import { useLocalStorage } from './use-local-storage'; import type { RootState } from './store'; @@ -131,7 +131,7 @@ export default function App(appProps: AppProps): ReactElement {
- +
{isAdpProject && shouldHideIframe && dialogQueue.length > 0 && ( + ), + chevronLeft: ( + + + ) } }); diff --git a/packages/control-property-editor/src/middleware.ts b/packages/control-property-editor/src/middleware.ts index 1dd3732a88..3da47de19e 100644 --- a/packages/control-property-editor/src/middleware.ts +++ b/packages/control-property-editor/src/middleware.ts @@ -12,7 +12,8 @@ import { undo, redo, save, - setAppMode + setAppMode, + executeQuickAction } from '@sap-ux-private/control-property-editor-common'; import { changeProperty } from './slice'; @@ -49,6 +50,7 @@ export const communicationMiddleware: Middleware> = (st sendAction(externalChangeProperty(action.payload)); break; } + case executeQuickAction.type: case reloadApplication.type: case deletePropertyChanges.type: case setAppMode.type: diff --git a/packages/control-property-editor/src/panels/LeftPanel.tsx b/packages/control-property-editor/src/panels/LeftPanel.tsx index 59bc8cd6c8..ca5545beb9 100644 --- a/packages/control-property-editor/src/panels/LeftPanel.tsx +++ b/packages/control-property-editor/src/panels/LeftPanel.tsx @@ -26,13 +26,14 @@ export function LeftPanel(): ReactElement { sizesAsPercents={true} animation={true}> ((state) => state.quickActions.length); + const scenario = useSelector((state) => state.scenario); + + if (scenario !== 'ADAPTATION_PROJECT' || actionsCount === 0) { + return ; + } + + const rowSize = 100; + const header = 50; + const initialSize = actionsCount * rowSize + header; + return ( + + + + + + + + + ); +} diff --git a/packages/control-property-editor/src/panels/changes/ChangesPanel.module.scss b/packages/control-property-editor/src/panels/changes/ChangesPanel.module.scss index 1616c002bb..34a869dd59 100644 --- a/packages/control-property-editor/src/panels/changes/ChangesPanel.module.scss +++ b/packages/control-property-editor/src/panels/changes/ChangesPanel.module.scss @@ -3,9 +3,6 @@ padding: 15px 15px 15px 15px; flex-direction: row; align-items: center; - position: sticky; - top: 0px; - z-index: 1; background-color: var(--vscode-sideBar-background); } @@ -23,4 +20,4 @@ .infoIcon { margin-left: 15px; margin-top: 5px; -} \ No newline at end of file +} diff --git a/packages/control-property-editor/src/panels/changes/ChangesPanel.tsx b/packages/control-property-editor/src/panels/changes/ChangesPanel.tsx index 87381e9de1..8c1ec363fe 100644 --- a/packages/control-property-editor/src/panels/changes/ChangesPanel.tsx +++ b/packages/control-property-editor/src/panels/changes/ChangesPanel.tsx @@ -120,7 +120,7 @@ export function ChangesPanel(): ReactElement { onChange={onFilterChange} /> - {renderChanges()} +
{renderChanges()}
); } diff --git a/packages/control-property-editor/src/panels/index.ts b/packages/control-property-editor/src/panels/index.ts index 43c1e38a05..976c9f2d99 100644 --- a/packages/control-property-editor/src/panels/index.ts +++ b/packages/control-property-editor/src/panels/index.ts @@ -1,2 +1,2 @@ export { LeftPanel } from './LeftPanel'; -export { PropertiesList } from './properties'; +export { RightPanel } from './RightPanel'; diff --git a/packages/control-property-editor/src/panels/outline/OutlinePanel.scss b/packages/control-property-editor/src/panels/outline/OutlinePanel.scss index 36c77ba26f..937eeecb19 100644 --- a/packages/control-property-editor/src/panels/outline/OutlinePanel.scss +++ b/packages/control-property-editor/src/panels/outline/OutlinePanel.scss @@ -3,12 +3,13 @@ padding: 15px 14px 15px 14px; flex-direction: row; align-items: center; - position: sticky; - top: 0px; - z-index: 1; background-color: var(--vscode-sideBar-background); } +.auto-element-scroller { + height: calc(100% - 55px); +} + .funnel-icon { margin-left: 16px; i { @@ -140,4 +141,11 @@ color: var(--vscode-list-activeSelectionForeground); background-color: var(--vscode-list-activeSelectionBackground); outline: 1px solid var(--vscode-contrastActiveBorder); + i { + svg { + path { + fill: var(--vscode-list-activeSelectionForeground) + } + } + } } diff --git a/packages/control-property-editor/src/panels/outline/Tree.tsx b/packages/control-property-editor/src/panels/outline/Tree.tsx index 72419714a4..cd87a13101 100644 --- a/packages/control-property-editor/src/panels/outline/Tree.tsx +++ b/packages/control-property-editor/src/panels/outline/Tree.tsx @@ -22,6 +22,15 @@ interface OutlineNodeItem extends OutlineNode { } export const Tree = (): ReactElement => { + // padding + height of `Search` bar + const SEARCH_HEIGHT = 56; + + // height of the tree row in a outline + const TREE_ROW_HEIGHT = 28; + + // margin of the highlighted control from the top including `Search` bar height and tree row height, it doesn't include the height of main toolbar + const HIGHLIGHTED_CONTROL_TOP_MARGIN = SEARCH_HEIGHT + TREE_ROW_HEIGHT; + const dispatch = useDispatch(); const { t } = useTranslation(); @@ -74,8 +83,10 @@ export const Tree = (): ReactElement => { setTimeout(() => { // make sure that tree is fully rendered const rect = node.getBoundingClientRect(); - const outlineContainer = document.getElementsByClassName('section--scrollable')[0]; - if (rect.top <= 20 || rect.bottom >= outlineContainer?.clientHeight) { + const outlineContainer = document.getElementsByClassName('auto-element-scroller')[0]; + + // check if highlighted control is behind the `Search` bar or check if it is outside of viewport from bottom + if (rect.top <= HIGHLIGHTED_CONTROL_TOP_MARGIN || rect.bottom >= outlineContainer?.clientHeight) { node.scrollIntoView(true); } }, 0); @@ -301,7 +312,6 @@ export const Tree = (): ReactElement => { const cellName = hasDefaultContent ? t('EXTENSION_POINT_HAS_DEFAULT_CONTENT_TEXT', { name: item?.name }) : item?.name; - return item && typeof itemIndex === 'number' && itemIndex > -1 ? (
{ const headerName = hasDefaultContent ? t('EXTENSION_POINT_HAS_DEFAULT_CONTENT_TEXT', { name: groupName }) : groupName; - return (
{ }; return ( -
+
; + /** + * Action line item index. + */ + actionIndex: number; +} + +/** + * Component for rendering Nested Quick Action. + * + * @param props Component props. + * @param props.action + * @param props.actionIndex + * @returns ReactElement + */ +export function NestedQuickActionListItem({ + action, + actionIndex +}: Readonly): ReactElement { + const dispatch = useDispatch(); + const [showContextualMenu, setShowContextualMenu] = useState(false); + const [target, setTarget] = useState<(EventTarget & (HTMLAnchorElement | HTMLElement | HTMLButtonElement)) | null>( + null + ); + + /** + * Build menu items for nested quick actions. + * + * @param children Node children. + * @param nestedLevel Nested Level. + * @returns ReactElement + */ + const buildMenuItems = function ( + children: NestedQuickActionChild[], + nestedLevel: number[] = [] + ): UIContextualMenuItem[] { + return children.map((child, index) => { + const hasChildren = child?.children?.length > 1; + const value = child?.children?.length === 1 ? `${child.label}-${child.children[0].label}` : child.label; + return { + key: `${value}-${index}`, + text: value, + title: value, + subMenuProps: hasChildren + ? { + directionalHint: UIDirectionalHint.leftTopEdge, + items: buildMenuItems(child.children, [...nestedLevel, index]) + } + : undefined, + onClick(): void { + dispatch( + executeQuickAction({ + kind: action.kind, + path: nestedLevel.length ? `${nestedLevel.join('/')}/${index}` : index.toString(), + id: action.id + }) + ); + } + }; + }); + }; + + return ( +
+ {action.children.length === 1 && ( + { + dispatch( + executeQuickAction({ + kind: action.kind, + id: action.id, + path: [0].join('/') + }) + ); + }}> + {`${action.title} - ${action.children[0].label}`} + + )} + {action.children.length > 1 && ( + <> + { + setShowContextualMenu(true); + setTarget(document.getElementById(`quick-action-children-button${actionIndex}`)); + }}> + {action.title} + { + setShowContextualMenu(true); + setTarget(document.getElementById(`quick-action-children-button${actionIndex}`)); + }} + /> + + + {showContextualMenu && ( + setShowContextualMenu(false)} + iconToLeft={true} + /> + )} + + )} +
+ ); +} diff --git a/packages/control-property-editor/src/panels/quick-actions/QuickAction.scss b/packages/control-property-editor/src/panels/quick-actions/QuickAction.scss new file mode 100644 index 0000000000..076c3d9b72 --- /dev/null +++ b/packages/control-property-editor/src/panels/quick-actions/QuickAction.scss @@ -0,0 +1,32 @@ +.quick-action-item { + display: flex; + flex-direction: row; + line-height: 24px; + button.ms-Link { + display: flex; + align-items: center; + max-width: 200px; + overflow: hidden; + padding-right: 2px; + color: var(--vscode-textLink-foreground); + font-size: 13px; + font-weight: bold; + height: 24px; + line-height: 24px; + .link-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + direction: ltr; + } + i.ts-icon { + display: flex; + svg { + align-self: center; + path { + fill: var(--vscode-textLink-foreground); + } + } + } + } +} diff --git a/packages/control-property-editor/src/panels/quick-actions/QuickActionList.tsx b/packages/control-property-editor/src/panels/quick-actions/QuickActionList.tsx new file mode 100644 index 0000000000..15dab7127f --- /dev/null +++ b/packages/control-property-editor/src/panels/quick-actions/QuickActionList.tsx @@ -0,0 +1,72 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Label, Stack } from '@fluentui/react'; +import { useSelector } from 'react-redux'; + +import type { QuickActionGroup } from '@sap-ux-private/control-property-editor-common'; +import { NESTED_QUICK_ACTION_KIND, SIMPLE_QUICK_ACTION_KIND } from '@sap-ux-private/control-property-editor-common'; + +import type { RootState } from '../../store'; +import { sectionHeaderFontSize } from '../properties/constants'; + +import { SimpleQuickActionListItem } from './SimpleQuickAction'; +import { NestedQuickActionListItem } from './NestedQuickAction'; + +import './QuickAction.scss'; + +/** + * React element for quick action list. + * + * @returns ReactElement + */ +export function QuickActionList(): ReactElement { + const { t } = useTranslation(); + const groups = useSelector((state) => state.quickActions); + + return ( +
+ + {groups.flatMap((group) => { + const groupTitle = t('QUICK_ACTIONS', { title: group.title }); + return [ + , + + ...group.actions.map((quickAction, idx) => { + if (quickAction.kind === SIMPLE_QUICK_ACTION_KIND) { + return ( + + + + ); + } + if (quickAction.kind === NESTED_QUICK_ACTION_KIND) { + return ( + + + + ); + } + return <>; + }) + ]; + })} + +
+ ); +} diff --git a/packages/control-property-editor/src/panels/quick-actions/SimpleQuickAction.tsx b/packages/control-property-editor/src/panels/quick-actions/SimpleQuickAction.tsx new file mode 100644 index 0000000000..07f50859ee --- /dev/null +++ b/packages/control-property-editor/src/panels/quick-actions/SimpleQuickAction.tsx @@ -0,0 +1,37 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { useDispatch } from 'react-redux'; + +import { UILink } from '@sap-ux/ui-components'; + +import type { SimpleQuickAction } from '@sap-ux-private/control-property-editor-common'; +import { executeQuickAction } from '@sap-ux-private/control-property-editor-common'; + +export interface SimpleQuickActionListItemProps { + action: Readonly; +} + +/** + * Component for rendering Simple Quick Action. + * + * @param props Component props. + * @param props.action Simple Quick Action to render. + * @returns ReactElement + */ +export function SimpleQuickActionListItem({ action }: Readonly): ReactElement { + const dispatch = useDispatch(); + + return ( +
+ { + dispatch(executeQuickAction({ kind: action.kind, id: action.id })); + }}> + {action.title} + +
+ ); +} diff --git a/packages/control-property-editor/src/panels/quick-actions/index.ts b/packages/control-property-editor/src/panels/quick-actions/index.ts new file mode 100644 index 0000000000..614eb594ea --- /dev/null +++ b/packages/control-property-editor/src/panels/quick-actions/index.ts @@ -0,0 +1 @@ +export { QuickActionList } from './QuickActionList'; diff --git a/packages/control-property-editor/src/slice.ts b/packages/control-property-editor/src/slice.ts index a5c59ade8a..31fff7c41c 100644 --- a/packages/control-property-editor/src/slice.ts +++ b/packages/control-property-editor/src/slice.ts @@ -7,6 +7,7 @@ import type { OutlineNode, PendingPropertyChange, PropertyChange, + QuickActionGroup, SavedPropertyChange, Scenario, ShowMessage @@ -25,7 +26,9 @@ import { setAppMode, setUndoRedoEnablement, setSaveEnablement, - appLoaded + appLoaded, + updateQuickAction, + quickActionListChanged } from '@sap-ux-private/control-property-editor-common'; import { DeviceType } from './devices'; @@ -53,6 +56,7 @@ interface SliceState { }; canSave: boolean; isAppLoading: boolean; + quickActions: QuickActionGroup[]; } export interface ChangesSlice { @@ -138,8 +142,10 @@ export const initialState: SliceState = { canRedo: false }, canSave: false, - isAppLoading: true + isAppLoading: true, + quickActions: [] }; + const slice = createSlice, string>({ name: 'app', initialState, @@ -306,6 +312,26 @@ const slice = createSlice, string>({ .addMatcher(appLoaded.match, (state): void => { state.isAppLoading = false; }) + .addMatcher( + quickActionListChanged.match, + (state: SliceState, action: ReturnType): void => { + state.quickActions = action.payload; + } + ) + .addMatcher( + updateQuickAction.match, + (state: SliceState, action: ReturnType): void => { + for (const group of state.quickActions) { + for (let index = 0; index < group.actions.length; index++) { + const quickAction = group.actions[index]; + if (quickAction.id === action.payload.id) { + group.actions[index] = action.payload; + return; + } + } + } + } + ) }); export const { setProjectScenario } = slice.actions; diff --git a/packages/control-property-editor/test/unit/App.test.tsx b/packages/control-property-editor/test/unit/App.test.tsx index a989b1f74c..fd3b5306b7 100644 --- a/packages/control-property-editor/test/unit/App.test.tsx +++ b/packages/control-property-editor/test/unit/App.test.tsx @@ -212,6 +212,7 @@ test('renders warning message for "ADAPTATION_PROJECT" scenario', async () => { canUndo: true, canRedo: true }, + quickActions: [], canSave: true }; render(, { initialState }); diff --git a/packages/control-property-editor/test/unit/panels/quick-actions/QuickActionList.test.tsx b/packages/control-property-editor/test/unit/panels/quick-actions/QuickActionList.test.tsx new file mode 100644 index 0000000000..8589c26917 --- /dev/null +++ b/packages/control-property-editor/test/unit/panels/quick-actions/QuickActionList.test.tsx @@ -0,0 +1,153 @@ +import React from 'react'; +import { screen, fireEvent } from '@testing-library/react'; +import { initIcons } from '@sap-ux/ui-components'; + +import { render } from '../../utils'; +import { FilterName } from '../../../../src/slice'; +import type { FilterOptions, ChangesSlice, default as reducer } from '../../../../src/slice'; +import { DeviceType } from '../../../../src/devices'; +import { registerAppIcons } from '../../../../src/icons'; +import { initI18n } from '../../../../src/i18n'; +import { executeQuickAction } from '@sap-ux-private/control-property-editor-common'; +import { QuickActionList } from '../../../../src/panels/quick-actions'; + +export type State = ReturnType; + +const getEmptyModel = (): ChangesSlice => { + const model: ChangesSlice = { + controls: {} as any, + pending: [], + saved: [], + pendingChangeIds: [] + }; + return model; +}; + +describe('QuickActionList', () => { + beforeAll(() => { + initI18n(); + initIcons(); + registerAppIcons(); + }); + test('ChangePanel - check if quick action list rendered', () => { + const model = getEmptyModel(); + const children = [ + { + label: 'submenu1', + children: [] + }, + { + label: 'submenu2', + children: [ + { + label: 'submenu2-submenu1', + children: [] + }, + { + label: 'submenu2-submenu2', + children: [] + } + ] + } + ]; + const initialState: State = { + deviceType: DeviceType.Desktop, + scale: 1, + outline: {} as any, + filterQuery: [], + selectedControl: undefined, + changes: model, + icons: [], + dialogMessage: undefined, + isAdpProject: false, + quickActions: [ + { + title: 'List Report', + actions: [ + { + id: 'quick-action-1', + enabled: true, + kind: 'simple', + title: 'Quick Action 1' + }, + { + id: 'quick-action-2', + enabled: true, + kind: 'nested', + title: 'Quick Action 2', + children: children + }, + { + id: 'quick-action-3', + enabled: true, + kind: 'nested', + title: 'Quick Action 3', + children: [children[0]] + } + ] + } + ] + }; + const { dispatch } = render(, { initialState }); + + // check elements in the document + const pageTitle = screen.getByText(/list report quick actions/i); + expect(pageTitle).toBeInTheDocument(); + + // simple quick action + const quickAction1 = screen.getByRole('button', { name: /quick action 1/i }); + expect(quickAction1).toBeInTheDocument(); + + // nested quick action - multiple children + const quickAction2 = screen.getByRole('button', { name: /quick action 2/i }); + expect(quickAction2).toBeInTheDocument(); + + // nested quick action - single child + const quickAction3 = screen.getByRole('button', { name: /quick action 3 - submenu1/i }); + expect(quickAction3).toBeInTheDocument(); + + // simple quick action + fireEvent.click(quickAction1); + expect(dispatch).toBeCalledWith( + executeQuickAction({ + kind: 'simple', + id: 'quick-action-1' + }) + ); + + // nested quick actions + fireEvent.click(quickAction2); + + const menuItems = screen.getAllByRole('menuitem'); + expect(menuItems.length).toBe(2); + + menuItems.forEach((item, i) => { + expect(item.getElementsByTagName('span')[0].innerHTML).toBe(children[i].label); + if (i === 1) { + item.click(); + const submenuItems = screen.getAllByRole('menuitem'); + [submenuItems[2], submenuItems[3]].forEach((subMenu, i) => { + expect(subMenu.getElementsByTagName('span')[0].innerHTML).toBe(children[1].children[i].label); + }); + fireEvent.click(submenuItems[3]); + expect(dispatch).toBeCalledWith( + executeQuickAction({ + kind: 'nested', + path: '1/1', + id: 'quick-action-2' + }) + ); + } + }); + + // nested quick action with single child + fireEvent.click(quickAction3); + expect(dispatch).toBeCalledWith( + executeQuickAction({ + kind: 'nested', + id: 'quick-action-3', + path: '0' + }) + ); + }); +}); diff --git a/packages/control-property-editor/test/unit/slice.test.ts b/packages/control-property-editor/test/unit/slice.test.ts index fb76e942e5..cd67ade171 100644 --- a/packages/control-property-editor/test/unit/slice.test.ts +++ b/packages/control-property-editor/test/unit/slice.test.ts @@ -3,10 +3,12 @@ import { iconsLoaded, propertyChanged, propertyChangeFailed, + quickActionListChanged, reloadApplication, SCENARIO, showMessage, - storageFileChanged + storageFileChanged, + updateQuickAction } from '@sap-ux-private/control-property-editor-common'; import reducer, { @@ -406,5 +408,181 @@ describe('main redux slice', () => { isAppLoading: true }); }); + + test('quickActionListChanged', () => { + expect( + reducer( + { quickActions: [] } as any, + quickActionListChanged([ + { + actions: [ + { + id: 'test id 1', + enabled: true, + kind: 'simple', + title: 'test title' + } + ], + title: 'test title 1' + }, + { + actions: [ + { + id: 'test id 2', + enabled: true, + kind: 'nested', + children: [ + { + label: 'test label', + children: [ + { + label: 'test label 2', + children: [] + }, + { + label: 'test label 3', + children: [] + } + ] + } + ], + title: 'test title' + } + ], + title: 'test title 1' + } + ]) + ) + ).toStrictEqual({ + quickActions: [ + { + actions: [ + { + id: 'test id 1', + enabled: true, + kind: 'simple', + title: 'test title' + } + ], + title: 'test title 1' + }, + { + actions: [ + { + id: 'test id 2', + enabled: true, + kind: 'nested', + children: [ + { + label: 'test label', + children: [ + { + label: 'test label 2', + children: [] + }, + { + label: 'test label 3', + children: [] + } + ] + } + ], + title: 'test title' + } + ], + title: 'test title 1' + } + ] + }); + }); + + test('updateQuickAction', () => { + expect( + reducer( + { + quickActions: [ + { + actions: [ + { + id: 'test id 1', + enabled: true, + kind: 'simple', + title: 'test title' + } + ], + title: 'test title 1' + }, + { + actions: [ + { + id: 'test id 2', + enabled: true, + kind: 'nested', + children: [ + { + label: 'test label', + children: [ + { + label: 'test label 2', + children: [] + }, + { + label: 'test label 3', + children: [] + } + ] + } + ], + title: 'test title 22' + } + ], + title: 'test title 2' + } + ] + } as any, + updateQuickAction({ id: 'test id 1', enabled: false, kind: 'simple', title: 'new test' }) + ) + ).toStrictEqual({ + quickActions: [ + { + actions: [ + { + id: 'test id 1', + enabled: false, + kind: 'simple', + title: 'new test' + } + ], + title: 'test title 1' + }, + { + actions: [ + { + id: 'test id 2', + enabled: true, + kind: 'nested', + children: [ + { + label: 'test label', + children: [ + { + label: 'test label 2', + children: [] + }, + { + label: 'test label 3', + children: [] + } + ] + } + ], + title: 'test title 22' + } + ], + title: 'test title 2' + } + ] + }); + }); }); }); diff --git a/packages/create/CHANGELOG.md b/packages/create/CHANGELOG.md index 3529a56a07..47676b1e7b 100644 --- a/packages/create/CHANGELOG.md +++ b/packages/create/CHANGELOG.md @@ -1,5 +1,94 @@ # @sap-ux/create +## 0.8.6 + +### Patch Changes + +- Updated dependencies [0b7af6a] + - @sap-ux/preview-middleware@0.16.60 + +## 0.8.5 + +### Patch Changes + +- Updated dependencies [b1628da] + - @sap-ux/preview-middleware@0.16.59 + +## 0.8.4 + +### Patch Changes + +- Updated dependencies [40ce4ca] + - @sap-ux/abap-deploy-config-inquirer@0.0.12 + +## 0.8.3 + +### Patch Changes + +- Updated dependencies [d962ce1] + - @sap-ux/project-access@1.27.1 + - @sap-ux/abap-deploy-config-writer@0.0.40 + - @sap-ux/adp-tooling@0.12.45 + - @sap-ux/app-config-writer@0.4.31 + - @sap-ux/cap-config-writer@0.7.36 + - @sap-ux/cards-editor-config-writer@0.4.4 + - @sap-ux/mockserver-config-writer@0.6.4 + - @sap-ux/preview-middleware@0.16.58 + - @sap-ux/system-access@0.5.10 + - @sap-ux/abap-deploy-config-inquirer@0.0.11 + +## 0.8.2 + +### Patch Changes + +- Updated dependencies [7926d8c] + - @sap-ux/abap-deploy-config-inquirer@0.0.10 + +## 0.8.1 + +### Patch Changes + +- @sap-ux/cap-config-writer@0.7.35 + +## 0.8.0 + +### Minor Changes + +- 99d7394: adds create command add deploy-config + +### Patch Changes + +- Updated dependencies [99d7394] + - @sap-ux/abap-deploy-config-inquirer@0.0.9 + +## 0.7.67 + +### Patch Changes + +- @sap-ux/cap-config-writer@0.7.34 + +## 0.7.66 + +### Patch Changes + +- Updated dependencies [1294b1c] + - @sap-ux/adp-tooling@0.12.44 + - @sap-ux/preview-middleware@0.16.57 + +## 0.7.65 + +### Patch Changes + +- Updated dependencies [df29368] + - @sap-ux/project-access@1.27.0 + - @sap-ux/adp-tooling@0.12.43 + - @sap-ux/app-config-writer@0.4.30 + - @sap-ux/cap-config-writer@0.7.33 + - @sap-ux/cards-editor-config-writer@0.4.4 + - @sap-ux/mockserver-config-writer@0.6.4 + - @sap-ux/preview-middleware@0.16.56 + - @sap-ux/system-access@0.5.10 + ## 0.7.64 ### Patch Changes diff --git a/packages/create/README.md b/packages/create/README.md index 8437883b19..9b5f352627 100644 --- a/packages/create/README.md +++ b/packages/create/README.md @@ -31,6 +31,15 @@ Calling `sap-ux add annotations` allows adding an annotation to the OData Source ```sh sap-ux add annotations /path/to/adaptation-project ``` +### deploy-config +Calling `sap-ux add deploy-config` will prompt for ABAP deployment configuration details and add/update the project files accordingly +```sh +sap-ux add deploy-config /path/to/project +``` +#### deploy-config options: +`--target` abap | cf (cf deploy config inquirer not yet implemented)\ +`--base-file` e.g ui5.yaml\ +`--deploy-file` e.g. ui5-deploy.yaml ### model Calling `sap-ux add model` allows to add new OData Service and SAPUI5 Model to an existing adaptation project. diff --git a/packages/create/package.json b/packages/create/package.json index a357a70c93..82c3311a18 100644 --- a/packages/create/package.json +++ b/packages/create/package.json @@ -1,7 +1,7 @@ { "name": "@sap-ux/create", "description": "SAP Fiori tools module to add or remove features", - "version": "0.7.64", + "version": "0.8.6", "repository": { "type": "git", "url": "https://github.com/SAP/open-ux-tools.git", @@ -33,6 +33,8 @@ "!dist/**/*.map" ], "dependencies": { + "@sap-ux/abap-deploy-config-inquirer": "workspace:*", + "@sap-ux/abap-deploy-config-writer": "workspace:*", "@sap-ux/adp-tooling": "workspace:*", "@sap-ux/app-config-writer": "workspace:*", "@sap-ux/cap-config-writer": "workspace:*", diff --git a/packages/create/src/cli/add/deploy-config.ts b/packages/create/src/cli/add/deploy-config.ts new file mode 100644 index 0000000000..f70fb5b5b1 --- /dev/null +++ b/packages/create/src/cli/add/deploy-config.ts @@ -0,0 +1,139 @@ +import { FileName } from '@sap-ux/project-access'; +import { generate as generateDeployConfig } from '@sap-ux/abap-deploy-config-writer'; +import { getLogger, traceChanges, setLogLevelVerbose } from '../../tracing'; +import { validateBasePath } from '../../validation'; +import { + type AbapDeployConfigAnswers, + getPrompts as getAbapDeployConfigPrompts, + reconcileAnswers +} from '@sap-ux/abap-deploy-config-inquirer'; +import { prompt, type PromptObject } from 'prompts'; +import type { AbapDeployConfig } from '@sap-ux/ui5-config'; +import type { Command } from 'commander'; +import { promptYUIQuestions } from '../../common'; + +/** + * Add the "add deploy config" command to a passed command. + * + * @param cmd - commander command for adding deploy config command + */ +export function addDeployConfigCommand(cmd: Command): void { + cmd.command('deploy-config [path]') + .option('-t, --target ', 'target for deployment; ABAP or Cloud Foundry (not yet implemented)') + .option('-s, --simulate', 'simulate only do not write; sets also --verbose') + .option('-v, --verbose', 'show verbose information') + .option('-b, --base-file ', 'the base file config file of the project; default : ui5.yaml') + .option( + '-d, --deploy-file ', + 'the name of the deploy config file to be written; default : ui5-deploy.yaml' + ) + .action(async (path, options) => { + if (options.verbose === true || options.simulate) { + setLogLevelVerbose(); + } + await addDeployConfig( + path || process.cwd(), + options.target, + options.simulate, + options.baseFile ?? FileName.Ui5Yaml, + options.deployFile ?? FileName.UI5DeployYaml + ); + }); +} + +/** + * Prompts the user to select the target for deployment. + * + * @param target - target for deployment + * @returns target + */ +async function getTarget(target?: string): Promise<'abap' | 'cf'> { + if (!target || (target !== 'abap' && target !== 'cf')) { + const question: PromptObject = { + name: 'target', + type: 'select', + message: 'Select the target for deployment', + choices: [ + { title: 'ABAP', value: 'abap' } + // { title: 'Cloud Foundry', value: 'cf' } + ] + }; + return (await prompt(question)).target; + } else { + return target; + } +} + +/** + * Adds a deploy config to an app or project. + * + * @param basePath - path to application root + * @param target - target for deployment (ABAP or Cloud Foundry) + * @param simulate - simulate only do not write + * @param baseFile - base file name + * @param deployFile - deploy file name + */ +async function addDeployConfig( + basePath: string, + target?: 'abap' | 'cf', + simulate = false, + baseFile?: string, + deployFile?: string +): Promise { + const logger = getLogger(); + try { + target = await getTarget(target); + + if (target === 'cf') { + logger.info('Cloud Foundry deployment is not yet implemented.'); + return; + } else if (target === 'abap') { + logger.debug(`Called add deploy-config for path '${basePath}', simulate is '${simulate}'`); + + await validateBasePath(basePath); + + const { prompts: abapPrompts, answers: abapAnswers } = await getAbapDeployConfigPrompts( + { useAutocomplete: true }, + logger, + false + ); + + const answers = reconcileAnswers( + await promptYUIQuestions(abapPrompts, false), + abapAnswers + ); + + const config = { + target: { + url: answers.url, + client: answers.client, + scp: answers.scp, + destination: answers.destination + }, + app: { + name: answers.ui5AbapRepo, + package: answers.package, + description: answers.description, + transport: answers.transport + }, + index: answers.index + } satisfies AbapDeployConfig; + + logger.debug(`Adding deployment configuration : ${JSON.stringify(config, null, 2)}`); + const fs = await generateDeployConfig(basePath, config, { + baseFile, + deployFile + }); + await traceChanges(fs); + + if (!simulate) { + fs.commit(() => { + logger.info(`Changes written.`); + }); + } + } + } catch (error) { + logger.error(`Error while executing add deploy-config '${(error as Error).message}'`); + logger.debug(error as Error); + } +} diff --git a/packages/create/src/cli/add/index.ts b/packages/create/src/cli/add/index.ts index e5cebef9a4..94ae1c5ee0 100644 --- a/packages/create/src/cli/add/index.ts +++ b/packages/create/src/cli/add/index.ts @@ -8,6 +8,7 @@ import { addNewModelCommand } from './new-model'; import { addAnnotationsToOdataCommand } from './annotations-to-odata'; import { addAddHtmlFilesCmd } from './html'; import { addComponentUsagesCommand } from './component-usages'; +import { addDeployConfigCommand } from './deploy-config'; /** * Return 'create-fiori add *' commands. Commands include also the handler action. @@ -34,5 +35,7 @@ export function getAddCommands(): Command { addAddHtmlFilesCmd(addCommands); // create-fiori add component-usages addComponentUsagesCommand(addCommands); + // create-fiori add deploy-config + addDeployConfigCommand(addCommands); return addCommands; } diff --git a/packages/create/src/common/prompts.ts b/packages/create/src/common/prompts.ts index 4ab0139521..885f5e09f1 100644 --- a/packages/create/src/common/prompts.ts +++ b/packages/create/src/common/prompts.ts @@ -1,6 +1,6 @@ import prompts from 'prompts'; import type { PromptType, PromptObject, InitialReturnValue } from 'prompts'; -import type { ListQuestion, YUIQuestion } from '@sap-ux/inquirer-common'; +import type { YUIQuestion } from '@sap-ux/inquirer-common'; import type { Answers } from 'inquirer'; import { getLogger } from '../tracing'; @@ -21,31 +21,56 @@ const QUESTION_TYPE_MAP: Record = { checkbox: 'multiselect' }; +type AutoCompleteCallbackFn = (answers: Answers, input: string) => Promise>; + +/** + * Map choices from inquirer format to prompts format. + * + * @param choices choices to be mapped + * @returns mapped choices + */ +function mapChoices( + choices: Array<{ name: string; value: unknown } | string | number> +): Array<{ title: string; value: unknown }> { + return choices.map((choice) => ({ + title: typeof choice === 'object' ? choice.name : `${choice}`, + value: typeof choice === 'object' ? choice.value : choice + })); +} + /** * Enhances the new prompt with the choices from the original list question. * - * @param listQuestion original list question + * @param origChoices choices of the original list/autocomplete question * @param prompt converted prompt * @param answers previously given answers + * @param autoCompleteCb callback for autocomplete */ async function enhanceListQuestion( - listQuestion: ListQuestion, + origChoices: unknown, prompt: PromptObject, - answers: { [key: string]: unknown } + answers: { [key: string]: unknown }, + autoCompleteCb?: AutoCompleteCallbackFn ): Promise { - const choices: Array<{ name: string; value: unknown } | string | number> = ( - isFunction(listQuestion.choices) ? await listQuestion.choices(answers) : listQuestion.choices - ) as Array<{ name: string; value: unknown } | string | number>; - const mapppedChoices = choices.map((choice) => ({ - title: typeof choice === 'object' ? choice.name : `${choice}`, - value: typeof choice === 'object' ? choice.value : choice - })); + const choices = (isFunction(origChoices) ? await origChoices(answers) : origChoices) as Array< + { name: string; value: unknown } | string | number + >; + const mapppedChoices = mapChoices(choices); const initialValue = (prompt.initial as Function)(); prompt.choices = mapppedChoices; prompt.initial = (): InitialReturnValue => mapppedChoices[initialValue] ? initialValue : mapppedChoices.findIndex((choice) => choice.value === initialValue); + if (autoCompleteCb) { + prompt.suggest = async (input, choices): Promise => { + if (input) { + const newChoices = await autoCompleteCb(answers, input); + return mapChoices(newChoices); + } + return choices; + }; + } } /** @@ -72,7 +97,7 @@ async function extractMessage(question: YUIQuestion, answe * @returns question converted to prompts question */ export async function convertQuestion( - question: YUIQuestion & { choices?: unknown }, + question: YUIQuestion & { choices?: unknown; source?: AutoCompleteCallbackFn }, answers: T ): Promise { const prompt: PromptObject = { @@ -83,8 +108,8 @@ export async function convertQuestion( isFunction(question.validate) ? await question.validate(value, answers) : question.validate ?? true, initial: () => (isFunction(question.default) ? question.default(answers) : question.default) }; - if (question.choices) { - await enhanceListQuestion(question as ListQuestion, prompt, answers); + if (question.choices || question.source) { + await enhanceListQuestion(question.choices ?? question.source, prompt, answers, question.source); } return prompt; } @@ -93,15 +118,17 @@ export async function convertQuestion( * * @param questions list of questions * @param useDefaults - if true, the default values are used for all prompts + * @param answers - previously given answers * @returns the answers to the questions */ export async function promptYUIQuestions( questions: YUIQuestion[], - useDefaults: boolean + useDefaults: boolean, + answers?: T ): Promise { - const answers = {} as T; + answers ??= {} as T; for (const question of questions) { - if (isFunction(question.when) ? question.when(answers) : question.when !== false) { + if (isFunction(question.when) ? await question.when(answers) : question.when !== false) { if (useDefaults) { answers[question.name] = isFunction(question.default) ? question.default(answers) : question.default; } else { diff --git a/packages/create/test/unit/cli/add/deploy-config.test.ts b/packages/create/test/unit/cli/add/deploy-config.test.ts new file mode 100644 index 0000000000..00900fcee7 --- /dev/null +++ b/packages/create/test/unit/cli/add/deploy-config.test.ts @@ -0,0 +1,192 @@ +import type { Editor } from 'mem-fs-editor'; +import { addDeployConfigCommand } from '../../../../src/cli/add/deploy-config'; +import { Command } from 'commander'; +import type { ToolsLogger } from '@sap-ux/logger'; +import { join } from 'path'; +import * as logger from '../../../../src/tracing/logger'; +import * as deployConfigInquirer from '@sap-ux/abap-deploy-config-inquirer'; +import * as deployConfigWriter from '@sap-ux/abap-deploy-config-writer'; +import { prompt } from 'prompts'; + +jest.mock('prompts', () => ({ + ...jest.requireActual('prompts'), + prompt: jest.fn() +})); + +const mockPrompt = prompt as jest.Mock; + +describe('add/deploy-config', () => { + const appRoot = join(__dirname, '../../../fixtures/bare-minimum'); + + let loggerMock: ToolsLogger; + let fsMock: Editor; + let logLevelSpy: jest.SpyInstance; + + const getArgv = (arg: string[]) => ['', '', ...arg]; + + beforeEach(() => { + jest.clearAllMocks(); + + // Mock setup + loggerMock = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn() + } as Partial as ToolsLogger; + jest.spyOn(logger, 'getLogger').mockImplementation(() => loggerMock); + logLevelSpy = jest.spyOn(logger, 'setLogLevelVerbose').mockImplementation(() => undefined); + fsMock = { + dump: jest.fn(), + commit: jest.fn().mockImplementation((callback) => callback()) + } as Partial as Editor; + }); + + test('should prompt for target when not provided', async () => { + const getPromptsSpy = jest.spyOn(deployConfigInquirer, 'getPrompts'); + getPromptsSpy.mockResolvedValueOnce({ prompts: [], answers: {} }); + const deployConfigWriterSpy = jest.spyOn(deployConfigWriter, 'generate'); + deployConfigWriterSpy.mockResolvedValueOnce(fsMock); + mockPrompt.mockResolvedValueOnce({ target: 'abap' }); + + // Test execution + const command = new Command('add'); + addDeployConfigCommand(command); + await command.parseAsync(getArgv(['deploy-config', appRoot])); + + // Result check + expect(mockPrompt).toBeCalledTimes(1); + expect(getPromptsSpy).toBeCalledTimes(1); + expect(deployConfigWriterSpy).toBeCalledTimes(1); + }); + + test('should log when cf deploy config is requested', async () => { + // Test execution + const command = new Command('add'); + addDeployConfigCommand(command); + await command.parseAsync(getArgv(['deploy-config', appRoot, '--target', 'cf'])); + + // Result check + expect(loggerMock.info).toBeCalledWith('Cloud Foundry deployment is not yet implemented.'); + }); + + test('should add deploy config', async () => { + jest.spyOn(deployConfigInquirer, 'getPrompts').mockResolvedValueOnce({ prompts: [], answers: {} }); + jest.spyOn(deployConfigWriter, 'generate').mockResolvedValueOnce(fsMock); + + // Test execution + const command = new Command('add'); + addDeployConfigCommand(command); + await command.parseAsync(getArgv(['deploy-config', appRoot, '--target', 'abap'])); + + // Result check + expect(logLevelSpy).not.toBeCalled(); + expect(loggerMock.debug).toBeCalled(); + expect(loggerMock.info).toBeCalled(); + expect(loggerMock.warn).not.toBeCalled(); + expect(loggerMock.error).not.toBeCalled(); + expect(fsMock.commit).toBeCalled(); + }); + + test('should add deploy config --simulate', async () => { + jest.spyOn(deployConfigInquirer, 'getPrompts').mockResolvedValueOnce({ prompts: [], answers: {} }); + jest.spyOn(deployConfigWriter, 'generate').mockResolvedValueOnce(fsMock); + + // Test execution + const command = new Command('add'); + addDeployConfigCommand(command); + await command.parseAsync(getArgv(['deploy-config', appRoot, '--target', 'abap', '--simulate'])); + + // Result check + expect(logLevelSpy).toBeCalled(); + expect(loggerMock.debug).toBeCalled(); + expect(loggerMock.info).not.toBeCalled(); + expect(loggerMock.warn).not.toBeCalled(); + expect(loggerMock.error).not.toBeCalled(); + expect(fsMock.commit).not.toBeCalled(); + }); + + test('should add deploy config --verbose', async () => { + jest.spyOn(deployConfigInquirer, 'getPrompts').mockResolvedValueOnce({ prompts: [], answers: {} }); + jest.spyOn(deployConfigWriter, 'generate').mockResolvedValueOnce(fsMock); + + // Test execution + const command = new Command('add'); + addDeployConfigCommand(command); + await command.parseAsync(getArgv(['deploy-config', appRoot, '--target', 'abap', '--verbose'])); + + // Result check + expect(logLevelSpy).toBeCalled(); + expect(loggerMock.debug).toBeCalled(); + expect(loggerMock.info).toBeCalled(); + expect(loggerMock.warn).not.toBeCalled(); + expect(loggerMock.error).not.toBeCalled(); + expect(fsMock.commit).toBeCalled(); + }); + + test('should report error', async () => { + const errorObj = new Error('Error generating deployment configuration'); + + jest.spyOn(deployConfigInquirer, 'getPrompts').mockResolvedValueOnce({ prompts: [], answers: {} }); + jest.spyOn(deployConfigWriter, 'generate').mockImplementationOnce(() => { + throw errorObj; + }); + + // Test execution + const command = new Command('add'); + addDeployConfigCommand(command); + await command.parseAsync(getArgv(['deploy-config', appRoot, '--target', 'abap', '--verbose'])); + + // Result check + expect(logLevelSpy).toBeCalled(); + expect(loggerMock.debug).toBeCalled(); + expect(loggerMock.info).not.toBeCalled(); + expect(loggerMock.warn).not.toBeCalled(); + expect(loggerMock.error).toBeCalledWith(`Error while executing add deploy-config '${errorObj.message}'`); + expect(fsMock.commit).not.toBeCalled(); + }); + + test('should add deployment config when answers are returned by prompting', async () => { + const promptAnswers = { + url: 'http://example.com', + client: '100', + scp: false, + ui5AbapRepo: 'ZUI5_REPO', + package: 'ZPACKAGE', + description: 'UI5 App', + transport: 'TRDUMMY' + }; + + jest.spyOn(deployConfigInquirer, 'getPrompts').mockResolvedValueOnce({ prompts: [], answers: {} }); + jest.spyOn(deployConfigWriter, 'generate').mockResolvedValueOnce(fsMock); + mockPrompt.mockResolvedValueOnce({ target: 'abap' }); + jest.spyOn(deployConfigInquirer, 'reconcileAnswers').mockReturnValueOnce(promptAnswers); + + // Test execution + const command = new Command('add'); + addDeployConfigCommand(command); + await command.parseAsync(getArgv(['deploy-config', appRoot, '--verbose'])); + + // Result check + expect(loggerMock.debug).toBeCalledWith( + `Adding deployment configuration : ${JSON.stringify( + { + target: { + url: promptAnswers.url, + client: promptAnswers.client, + scp: promptAnswers.scp + }, + app: { + name: promptAnswers.ui5AbapRepo, + package: promptAnswers.package, + description: promptAnswers.description, + transport: promptAnswers.transport + } + }, + null, + 2 + )}` + ); + expect(fsMock.commit).toBeCalled(); + }); +}); diff --git a/packages/create/tsconfig.json b/packages/create/tsconfig.json index 0a11176872..7b2979a8fd 100644 --- a/packages/create/tsconfig.json +++ b/packages/create/tsconfig.json @@ -9,6 +9,12 @@ "outDir": "dist" }, "references": [ + { + "path": "../abap-deploy-config-inquirer" + }, + { + "path": "../abap-deploy-config-writer" + }, { "path": "../adp-tooling" }, diff --git a/packages/environment-check/CHANGELOG.md b/packages/environment-check/CHANGELOG.md index aeb632a8e4..8b463e70e9 100644 --- a/packages/environment-check/CHANGELOG.md +++ b/packages/environment-check/CHANGELOG.md @@ -1,5 +1,21 @@ # @sap-ux/environment-check +## 0.17.34 + +### Patch Changes + +- Updated dependencies [d962ce1] + - @sap-ux/project-access@1.27.1 + - @sap-ux/axios-extension@1.16.5 + +## 0.17.33 + +### Patch Changes + +- Updated dependencies [df29368] + - @sap-ux/project-access@1.27.0 + - @sap-ux/axios-extension@1.16.5 + ## 0.17.32 ### Patch Changes diff --git a/packages/environment-check/package.json b/packages/environment-check/package.json index 66d7fe30cd..7be7e8f6ea 100644 --- a/packages/environment-check/package.json +++ b/packages/environment-check/package.json @@ -1,6 +1,6 @@ { "name": "@sap-ux/environment-check", - "version": "0.17.32", + "version": "0.17.34", "description": "SAP Fiori environment check", "license": "Apache-2.0", "bin": { diff --git a/packages/fe-fpm-writer/CHANGELOG.md b/packages/fe-fpm-writer/CHANGELOG.md index 2c021816f5..caddd5d60c 100644 --- a/packages/fe-fpm-writer/CHANGELOG.md +++ b/packages/fe-fpm-writer/CHANGELOG.md @@ -1,5 +1,33 @@ # @sap-ux/fe-fpm-writer +## 0.29.0 + +### Minor Changes + +- 177cdc8: Apply the "contextPath" attribute in building blocks when the application has a minimum UI5 version equal to or below 1.96. + +## 0.28.3 + +### Patch Changes + +- Updated dependencies [d962ce1] + - @sap-ux/project-access@1.27.1 + - @sap-ux/fiori-annotation-api@0.1.40 + +## 0.28.2 + +### Patch Changes + +- Updated dependencies [df29368] + - @sap-ux/project-access@1.27.0 + - @sap-ux/fiori-annotation-api@0.1.39 + +## 0.28.1 + +### Patch Changes + +- b10e3fd: fix: ensure that the controller template include an example call to super.onInit() + ## 0.28.0 ### Minor Changes diff --git a/packages/fe-fpm-writer/package.json b/packages/fe-fpm-writer/package.json index 39aed22b79..d41d09c8c2 100644 --- a/packages/fe-fpm-writer/package.json +++ b/packages/fe-fpm-writer/package.json @@ -1,7 +1,7 @@ { "name": "@sap-ux/fe-fpm-writer", "description": "SAP Fiori elements flexible programming model writer", - "version": "0.28.0", + "version": "0.29.0", "repository": { "type": "git", "url": "https://github.com/SAP/open-ux-tools.git", diff --git a/packages/fe-fpm-writer/src/building-block/index.ts b/packages/fe-fpm-writer/src/building-block/index.ts index d7c21f35a3..372f31fcbb 100644 --- a/packages/fe-fpm-writer/src/building-block/index.ts +++ b/packages/fe-fpm-writer/src/building-block/index.ts @@ -10,6 +10,9 @@ import format from 'xml-formatter'; import { getErrorMessage, validateBasePath } from '../common/validate'; import { getTemplatePath } from '../templates'; import { CodeSnippetLanguage, type FilePathProps, type CodeSnippet } from '../prompts/types'; +import { coerce, lt } from 'semver'; +import type { Manifest } from '../common/types'; +import { getMinimumUI5Version } from '@sap-ux/project-access'; const PLACEHOLDERS = { 'id': 'REPLACE_WITH_BUILDING_BLOCK_ID', @@ -22,6 +25,18 @@ interface MetadataPath { metaPath: string; } +/** + * Gets manifest content. + * + * @param {string} basePath the base path + * @param {Editor} fs the memfs editor instance + * @returns {Manifest | undefined} the manifest content + */ +function getManifest(basePath: string, fs: Editor): Manifest | undefined { + const manifestPath = join(basePath, 'webapp/manifest.json'); + return fs.readJSON(manifestPath) as Manifest; +} + /** * Generates a building block into the provided xml view file. * @@ -46,7 +61,8 @@ export function generateBuildingBlock( // Read the view xml and template files and update contents of the view xml file const xmlDocument = getUI5XmlDocument(basePath, config.viewOrFragmentPath, fs); - const templateDocument = getTemplateDocument(config.buildingBlockData, xmlDocument, fs); + const manifest = getManifest(basePath, fs); + const templateDocument = getTemplateDocument(config.buildingBlockData, xmlDocument, fs, manifest); fs = updateViewFile(basePath, config.viewOrFragmentPath, config.aggregationPath, xmlDocument, templateDocument, fs); return fs; @@ -101,12 +117,12 @@ function getOrAddMacrosNamespace(ui5XmlDocument: Document): string { /** * Method returns default values for metadata path. * - * @param {BuildingBlockType} type - building vlock type. + * @param {boolean} applyContextPath - whether to apply contextPath. * @param {boolean} usePlaceholders - apply placeholder values if value for attribute/property is not provided * @returns {MetadataPath} Default values for metadata path. */ -function getDefaultMetaPath(type: BuildingBlockType, usePlaceholders?: boolean): MetadataPath { - if (type === BuildingBlockType.Chart) { +function getDefaultMetaPath(applyContextPath: boolean, usePlaceholders?: boolean): MetadataPath { + if (applyContextPath) { return { metaPath: usePlaceholders ? `/${PLACEHOLDERS.qualifier}` : '', contextPath: usePlaceholders ? PLACEHOLDERS.entitySet : '' @@ -120,25 +136,24 @@ function getDefaultMetaPath(type: BuildingBlockType, usePlaceholders?: boolean): /** * Method converts object based metaPath to metadata path. * - * @param {BuildingBlockType} type - building vlock type. + * @param {boolean} applyContextPath - whether to apply contextPath. * @param {BuildingBlockMetaPath} metaPath - object based metaPath. * @param {boolean} usePlaceholders - apply placeholder values if value for attribute/property is not provided * @returns {MetadataPath} Resolved metadata path information. */ function getMetaPath( - type: BuildingBlockType, + applyContextPath: boolean, metaPath?: BuildingBlockMetaPath, usePlaceholders?: boolean ): MetadataPath { if (!metaPath) { - return getDefaultMetaPath(type, usePlaceholders); + return getDefaultMetaPath(applyContextPath, usePlaceholders); } const { bindingContextType = 'absolute' } = metaPath; let { entitySet, qualifier } = metaPath; entitySet = entitySet || (usePlaceholders ? PLACEHOLDERS.entitySet : ''); const qualifierOrPlaceholder = qualifier || (usePlaceholders ? PLACEHOLDERS.qualifier : ''); - if (type === BuildingBlockType.Chart) { - // Special handling for chart - while runtime does not support approach without contextPath + if (applyContextPath) { const qualifierParts: string[] = qualifierOrPlaceholder.split('/'); qualifier = qualifierParts.pop() as string; return { @@ -156,6 +171,7 @@ function getMetaPath( * * @param {BuildingBlock} buildingBlockData - the building block data * @param {Document} viewDocument - the view xml file document + * @param {Manifest} manifest - the manifest content * @param {Editor} fs - the memfs editor instance * @param {boolean} usePlaceholders - apply placeholder values if value for attribute/property is not provided * @returns {string} the template xml file content @@ -163,18 +179,21 @@ function getMetaPath( function getTemplateContent( buildingBlockData: T, viewDocument: Document | undefined, + manifest: Manifest | undefined, fs: Editor, usePlaceholders?: boolean ): string { const templateFolderName = buildingBlockData.buildingBlockType; const templateFilePath = getTemplatePath(`/building-block/${templateFolderName}/View.xml`); if (typeof buildingBlockData.metaPath === 'object' || buildingBlockData.metaPath === undefined) { + // Special handling for chart - while runtime does not support approach without contextPath + // or for equal or below UI5 v1.96.0 contextPath is applied + const minUI5Version = manifest ? coerce(getMinimumUI5Version(manifest)) : undefined; + const applyContextPath = + buildingBlockData.buildingBlockType === BuildingBlockType.Chart || + !!(minUI5Version && lt(minUI5Version, '1.97.0')); // Convert object based metapath to string - const metadataPath = getMetaPath( - buildingBlockData.buildingBlockType, - buildingBlockData.metaPath, - usePlaceholders - ); + const metadataPath = getMetaPath(applyContextPath, buildingBlockData.metaPath, usePlaceholders); buildingBlockData = { ...buildingBlockData, metaPath: metadataPath.metaPath }; if (!buildingBlockData.contextPath && metadataPath.contextPath) { buildingBlockData.contextPath = metadataPath.contextPath; @@ -200,14 +219,16 @@ function getTemplateContent( * @param {BuildingBlock} buildingBlockData - the building block data * @param {Document} viewDocument - the view xml file document * @param {Editor} fs - the memfs editor instance + * @param {Manifest} manifest - the manifest content * @returns {Document} the template xml file document */ function getTemplateDocument( buildingBlockData: T, viewDocument: Document | undefined, - fs: Editor + fs: Editor, + manifest: Manifest | undefined ): Document { - const templateContent = getTemplateContent(buildingBlockData, viewDocument, fs); + const templateContent = getTemplateContent(buildingBlockData, viewDocument, manifest, fs); const errorHandler = (level: string, message: string) => { throw new Error(`Unable to parse template file with building block data. Details: [${level}] - ${message}`); }; @@ -302,7 +323,8 @@ export function getSerializedFileContent( const xmlDocument = config.viewOrFragmentPath ? getUI5XmlDocument(basePath, config.viewOrFragmentPath, fs) : undefined; - const content = getTemplateContent(config.buildingBlockData, xmlDocument, fs, true); + const manifest = getManifest(basePath, fs); + const content = getTemplateContent(config.buildingBlockData, xmlDocument, manifest, fs, true); const filePathProps = getFilePathProps(basePath, config.viewOrFragmentPath); return { viewOrFragmentPath: { diff --git a/packages/fe-fpm-writer/templates/page/custom/1.84/ext/Controller.js b/packages/fe-fpm-writer/templates/page/custom/1.84/ext/Controller.js index 1e3c55dcb9..e654bf0bbc 100644 --- a/packages/fe-fpm-writer/templates/page/custom/1.84/ext/Controller.js +++ b/packages/fe-fpm-writer/templates/page/custom/1.84/ext/Controller.js @@ -12,7 +12,7 @@ sap.ui.define( * @memberOf <%- ns %>.<%- name %> */ // onInit: function () { - // + // PageController.prototype.onInit.apply(this, arguments); // needs to be called to properly initialize the page controller // }, /** diff --git a/packages/fe-fpm-writer/templates/page/custom/1.84/ext/Controller.ts b/packages/fe-fpm-writer/templates/page/custom/1.84/ext/Controller.ts index 5a6f37587d..08bead1e96 100644 --- a/packages/fe-fpm-writer/templates/page/custom/1.84/ext/Controller.ts +++ b/packages/fe-fpm-writer/templates/page/custom/1.84/ext/Controller.ts @@ -11,7 +11,7 @@ export default class <%- name %> extends Controller { * @memberOf <%- ns %>.<%- name %> */ // public onInit(): void { - // + // super.onInit(); // needs to be called to properly initialize the page controller //} /** diff --git a/packages/fe-fpm-writer/templates/page/custom/1.94/ext/Controller.js b/packages/fe-fpm-writer/templates/page/custom/1.94/ext/Controller.js index a157a799ed..6f58c3649a 100644 --- a/packages/fe-fpm-writer/templates/page/custom/1.94/ext/Controller.js +++ b/packages/fe-fpm-writer/templates/page/custom/1.94/ext/Controller.js @@ -12,7 +12,7 @@ sap.ui.define( * @memberOf <%- ns %>.<%- name %> */ // onInit: function () { - // + // PageController.prototype.onInit.apply(this, arguments); // needs to be called to properly initialize the page controller // }, /** diff --git a/packages/fe-fpm-writer/templates/page/custom/1.94/ext/Controller.ts b/packages/fe-fpm-writer/templates/page/custom/1.94/ext/Controller.ts index 668199e86f..cbebfabcbf 100644 --- a/packages/fe-fpm-writer/templates/page/custom/1.94/ext/Controller.ts +++ b/packages/fe-fpm-writer/templates/page/custom/1.94/ext/Controller.ts @@ -11,7 +11,7 @@ export default class <%- name %> extends Controller { * @memberOf <%- ns %>.<%- name %> */ // public onInit(): void { - // + // super.onInit(); // needs to be called to properly initialize the page controller //} /** diff --git a/packages/fe-fpm-writer/test/unit/__snapshots__/building-block.test.ts.snap b/packages/fe-fpm-writer/test/unit/__snapshots__/building-block.test.ts.snap index 8ef0f9cf71..9efc518350 100644 --- a/packages/fe-fpm-writer/test/unit/__snapshots__/building-block.test.ts.snap +++ b/packages/fe-fpm-writer/test/unit/__snapshots__/building-block.test.ts.snap @@ -13,7 +13,7 @@ Object { "state": "modified", }, "generate-chart-with-id-no-macros-ns/webapp/manifest.json": Object { - "contents": "{\\"sap.app\\":{\\"id\\":\\"my.test.App\\"},\\"sap.ui5\\":{\\"dependencies\\":{\\"libs\\":{\\"sap.fe.core\\":{}}}},\\"default\\":{\\"sap.app\\":{\\"id\\":\\"my.test.App\\"},\\"sap.ui5\\":{\\"dependencies\\":{\\"libs\\":{\\"sap.fe.core\\":{}}}}}}", + "contents": "{\\"sap.app\\":{\\"id\\":\\"my.test.App\\"},\\"sap.ui5\\":{\\"dependencies\\":{\\"minUI5Version\\":\\"1.127.0\\",\\"libs\\":{\\"sap.fe.core\\":{}}}},\\"default\\":{\\"sap.app\\":{\\"id\\":\\"my.test.App\\"},\\"sap.ui5\\":{\\"dependencies\\":{\\"minUI5Version\\":\\"1.127.0\\",\\"libs\\":{\\"sap.fe.core\\":{}}}}}}", "state": "modified", }, } @@ -32,7 +32,7 @@ Object { "state": "modified", }, "generate-field-with-id-no-macros-ns/webapp/manifest.json": Object { - "contents": "{\\"sap.app\\":{\\"id\\":\\"my.test.App\\"},\\"sap.ui5\\":{\\"dependencies\\":{\\"libs\\":{\\"sap.fe.core\\":{}}}},\\"default\\":{\\"sap.app\\":{\\"id\\":\\"my.test.App\\"},\\"sap.ui5\\":{\\"dependencies\\":{\\"libs\\":{\\"sap.fe.core\\":{}}}}}}", + "contents": "{\\"sap.app\\":{\\"id\\":\\"my.test.App\\"},\\"sap.ui5\\":{\\"dependencies\\":{\\"minUI5Version\\":\\"1.127.0\\",\\"libs\\":{\\"sap.fe.core\\":{}}}},\\"default\\":{\\"sap.app\\":{\\"id\\":\\"my.test.App\\"},\\"sap.ui5\\":{\\"dependencies\\":{\\"minUI5Version\\":\\"1.127.0\\",\\"libs\\":{\\"sap.fe.core\\":{}}}}}}", "state": "modified", }, } @@ -51,7 +51,7 @@ Object { "state": "modified", }, "generate-filter-bar-with-id-no-macros-ns/webapp/manifest.json": Object { - "contents": "{\\"sap.app\\":{\\"id\\":\\"my.test.App\\"},\\"sap.ui5\\":{\\"dependencies\\":{\\"libs\\":{\\"sap.fe.core\\":{}}}},\\"default\\":{\\"sap.app\\":{\\"id\\":\\"my.test.App\\"},\\"sap.ui5\\":{\\"dependencies\\":{\\"libs\\":{\\"sap.fe.core\\":{}}}}}}", + "contents": "{\\"sap.app\\":{\\"id\\":\\"my.test.App\\"},\\"sap.ui5\\":{\\"dependencies\\":{\\"minUI5Version\\":\\"1.127.0\\",\\"libs\\":{\\"sap.fe.core\\":{}}}},\\"default\\":{\\"sap.app\\":{\\"id\\":\\"my.test.App\\"},\\"sap.ui5\\":{\\"dependencies\\":{\\"minUI5Version\\":\\"1.127.0\\",\\"libs\\":{\\"sap.fe.core\\":{}}}}}}", "state": "modified", }, } @@ -70,7 +70,7 @@ Object { "state": "modified", }, "generate-table-with-id-no-macros-ns/webapp/manifest.json": Object { - "contents": "{\\"sap.app\\":{\\"id\\":\\"my.test.App\\"},\\"sap.ui5\\":{\\"dependencies\\":{\\"libs\\":{\\"sap.fe.core\\":{}}}},\\"default\\":{\\"sap.app\\":{\\"id\\":\\"my.test.App\\"},\\"sap.ui5\\":{\\"dependencies\\":{\\"libs\\":{\\"sap.fe.core\\":{}}}}}}", + "contents": "{\\"sap.app\\":{\\"id\\":\\"my.test.App\\"},\\"sap.ui5\\":{\\"dependencies\\":{\\"minUI5Version\\":\\"1.127.0\\",\\"libs\\":{\\"sap.fe.core\\":{}}}},\\"default\\":{\\"sap.app\\":{\\"id\\":\\"my.test.App\\"},\\"sap.ui5\\":{\\"dependencies\\":{\\"minUI5Version\\":\\"1.127.0\\",\\"libs\\":{\\"sap.fe.core\\":{}}}}}}", "state": "modified", }, } @@ -89,7 +89,7 @@ Object { "state": "modified", }, "generate-chart-with-id/webapp/manifest.json": Object { - "contents": "{\\"sap.app\\":{\\"id\\":\\"my.test.App\\"},\\"sap.ui5\\":{\\"dependencies\\":{\\"libs\\":{\\"sap.fe.core\\":{}}}},\\"default\\":{\\"sap.app\\":{\\"id\\":\\"my.test.App\\"},\\"sap.ui5\\":{\\"dependencies\\":{\\"libs\\":{\\"sap.fe.core\\":{}}}}}}", + "contents": "{\\"sap.app\\":{\\"id\\":\\"my.test.App\\"},\\"sap.ui5\\":{\\"dependencies\\":{\\"minUI5Version\\":\\"1.127.0\\",\\"libs\\":{\\"sap.fe.core\\":{}}}},\\"default\\":{\\"sap.app\\":{\\"id\\":\\"my.test.App\\"},\\"sap.ui5\\":{\\"dependencies\\":{\\"minUI5Version\\":\\"1.127.0\\",\\"libs\\":{\\"sap.fe.core\\":{}}}}}}", "state": "modified", }, } @@ -108,7 +108,7 @@ Object { "state": "modified", }, "generate-field-with-id/webapp/manifest.json": Object { - "contents": "{\\"sap.app\\":{\\"id\\":\\"my.test.App\\"},\\"sap.ui5\\":{\\"dependencies\\":{\\"libs\\":{\\"sap.fe.core\\":{}}}},\\"default\\":{\\"sap.app\\":{\\"id\\":\\"my.test.App\\"},\\"sap.ui5\\":{\\"dependencies\\":{\\"libs\\":{\\"sap.fe.core\\":{}}}}}}", + "contents": "{\\"sap.app\\":{\\"id\\":\\"my.test.App\\"},\\"sap.ui5\\":{\\"dependencies\\":{\\"minUI5Version\\":\\"1.127.0\\",\\"libs\\":{\\"sap.fe.core\\":{}}}},\\"default\\":{\\"sap.app\\":{\\"id\\":\\"my.test.App\\"},\\"sap.ui5\\":{\\"dependencies\\":{\\"minUI5Version\\":\\"1.127.0\\",\\"libs\\":{\\"sap.fe.core\\":{}}}}}}", "state": "modified", }, } @@ -127,7 +127,7 @@ Object { "state": "modified", }, "generate-filter-bar-with-id/webapp/manifest.json": Object { - "contents": "{\\"sap.app\\":{\\"id\\":\\"my.test.App\\"},\\"sap.ui5\\":{\\"dependencies\\":{\\"libs\\":{\\"sap.fe.core\\":{}}}},\\"default\\":{\\"sap.app\\":{\\"id\\":\\"my.test.App\\"},\\"sap.ui5\\":{\\"dependencies\\":{\\"libs\\":{\\"sap.fe.core\\":{}}}}}}", + "contents": "{\\"sap.app\\":{\\"id\\":\\"my.test.App\\"},\\"sap.ui5\\":{\\"dependencies\\":{\\"minUI5Version\\":\\"1.127.0\\",\\"libs\\":{\\"sap.fe.core\\":{}}}},\\"default\\":{\\"sap.app\\":{\\"id\\":\\"my.test.App\\"},\\"sap.ui5\\":{\\"dependencies\\":{\\"minUI5Version\\":\\"1.127.0\\",\\"libs\\":{\\"sap.fe.core\\":{}}}}}}", "state": "modified", }, } @@ -146,7 +146,7 @@ Object { "state": "modified", }, "generate-table-with-id/webapp/manifest.json": Object { - "contents": "{\\"sap.app\\":{\\"id\\":\\"my.test.App\\"},\\"sap.ui5\\":{\\"dependencies\\":{\\"libs\\":{\\"sap.fe.core\\":{}}}},\\"default\\":{\\"sap.app\\":{\\"id\\":\\"my.test.App\\"},\\"sap.ui5\\":{\\"dependencies\\":{\\"libs\\":{\\"sap.fe.core\\":{}}}}}}", + "contents": "{\\"sap.app\\":{\\"id\\":\\"my.test.App\\"},\\"sap.ui5\\":{\\"dependencies\\":{\\"minUI5Version\\":\\"1.127.0\\",\\"libs\\":{\\"sap.fe.core\\":{}}}},\\"default\\":{\\"sap.app\\":{\\"id\\":\\"my.test.App\\"},\\"sap.ui5\\":{\\"dependencies\\":{\\"minUI5Version\\":\\"1.127.0\\",\\"libs\\":{\\"sap.fe.core\\":{}}}}}}", "state": "modified", }, } @@ -229,7 +229,7 @@ Object { "state": "modified", }, "generate-chart-with-optional-params/webapp/manifest.json": Object { - "contents": "{\\"sap.app\\":{\\"id\\":\\"my.test.App\\"},\\"sap.ui5\\":{\\"dependencies\\":{\\"libs\\":{\\"sap.fe.core\\":{}}}},\\"default\\":{\\"sap.app\\":{\\"id\\":\\"my.test.App\\"},\\"sap.ui5\\":{\\"dependencies\\":{\\"libs\\":{\\"sap.fe.core\\":{}}}}}}", + "contents": "{\\"sap.app\\":{\\"id\\":\\"my.test.App\\"},\\"sap.ui5\\":{\\"dependencies\\":{\\"minUI5Version\\":\\"1.127.0\\",\\"libs\\":{\\"sap.fe.core\\":{}}}},\\"default\\":{\\"sap.app\\":{\\"id\\":\\"my.test.App\\"},\\"sap.ui5\\":{\\"dependencies\\":{\\"minUI5Version\\":\\"1.127.0\\",\\"libs\\":{\\"sap.fe.core\\":{}}}}}}", "state": "modified", }, } @@ -248,7 +248,7 @@ Object { "state": "modified", }, "generate-chart-with-optional-params/webapp/manifest.json": Object { - "contents": "{\\"sap.app\\":{\\"id\\":\\"my.test.App\\"},\\"sap.ui5\\":{\\"dependencies\\":{\\"libs\\":{\\"sap.fe.core\\":{}}}},\\"default\\":{\\"sap.app\\":{\\"id\\":\\"my.test.App\\"},\\"sap.ui5\\":{\\"dependencies\\":{\\"libs\\":{\\"sap.fe.core\\":{}}}}}}", + "contents": "{\\"sap.app\\":{\\"id\\":\\"my.test.App\\"},\\"sap.ui5\\":{\\"dependencies\\":{\\"minUI5Version\\":\\"1.127.0\\",\\"libs\\":{\\"sap.fe.core\\":{}}}},\\"default\\":{\\"sap.app\\":{\\"id\\":\\"my.test.App\\"},\\"sap.ui5\\":{\\"dependencies\\":{\\"minUI5Version\\":\\"1.127.0\\",\\"libs\\":{\\"sap.fe.core\\":{}}}}}}", "state": "modified", }, } @@ -267,7 +267,7 @@ Object { "state": "modified", }, "generate-field-with-optional-params/webapp/manifest.json": Object { - "contents": "{\\"sap.app\\":{\\"id\\":\\"my.test.App\\"},\\"sap.ui5\\":{\\"dependencies\\":{\\"libs\\":{\\"sap.fe.core\\":{}}}},\\"default\\":{\\"sap.app\\":{\\"id\\":\\"my.test.App\\"},\\"sap.ui5\\":{\\"dependencies\\":{\\"libs\\":{\\"sap.fe.core\\":{}}}}}}", + "contents": "{\\"sap.app\\":{\\"id\\":\\"my.test.App\\"},\\"sap.ui5\\":{\\"dependencies\\":{\\"minUI5Version\\":\\"1.127.0\\",\\"libs\\":{\\"sap.fe.core\\":{}}}},\\"default\\":{\\"sap.app\\":{\\"id\\":\\"my.test.App\\"},\\"sap.ui5\\":{\\"dependencies\\":{\\"minUI5Version\\":\\"1.127.0\\",\\"libs\\":{\\"sap.fe.core\\":{}}}}}}", "state": "modified", }, } @@ -286,7 +286,7 @@ Object { "state": "modified", }, "generate-field-with-optional-params/webapp/manifest.json": Object { - "contents": "{\\"sap.app\\":{\\"id\\":\\"my.test.App\\"},\\"sap.ui5\\":{\\"dependencies\\":{\\"libs\\":{\\"sap.fe.core\\":{}}}},\\"default\\":{\\"sap.app\\":{\\"id\\":\\"my.test.App\\"},\\"sap.ui5\\":{\\"dependencies\\":{\\"libs\\":{\\"sap.fe.core\\":{}}}}}}", + "contents": "{\\"sap.app\\":{\\"id\\":\\"my.test.App\\"},\\"sap.ui5\\":{\\"dependencies\\":{\\"minUI5Version\\":\\"1.127.0\\",\\"libs\\":{\\"sap.fe.core\\":{}}}},\\"default\\":{\\"sap.app\\":{\\"id\\":\\"my.test.App\\"},\\"sap.ui5\\":{\\"dependencies\\":{\\"minUI5Version\\":\\"1.127.0\\",\\"libs\\":{\\"sap.fe.core\\":{}}}}}}", "state": "modified", }, } @@ -305,7 +305,7 @@ Object { "state": "modified", }, "generate-filter-bar-with-optional-params/webapp/manifest.json": Object { - "contents": "{\\"sap.app\\":{\\"id\\":\\"my.test.App\\"},\\"sap.ui5\\":{\\"dependencies\\":{\\"libs\\":{\\"sap.fe.core\\":{}}}},\\"default\\":{\\"sap.app\\":{\\"id\\":\\"my.test.App\\"},\\"sap.ui5\\":{\\"dependencies\\":{\\"libs\\":{\\"sap.fe.core\\":{}}}}}}", + "contents": "{\\"sap.app\\":{\\"id\\":\\"my.test.App\\"},\\"sap.ui5\\":{\\"dependencies\\":{\\"minUI5Version\\":\\"1.127.0\\",\\"libs\\":{\\"sap.fe.core\\":{}}}},\\"default\\":{\\"sap.app\\":{\\"id\\":\\"my.test.App\\"},\\"sap.ui5\\":{\\"dependencies\\":{\\"minUI5Version\\":\\"1.127.0\\",\\"libs\\":{\\"sap.fe.core\\":{}}}}}}", "state": "modified", }, } @@ -324,7 +324,7 @@ Object { "state": "modified", }, "generate-filter-bar-with-optional-params/webapp/manifest.json": Object { - "contents": "{\\"sap.app\\":{\\"id\\":\\"my.test.App\\"},\\"sap.ui5\\":{\\"dependencies\\":{\\"libs\\":{\\"sap.fe.core\\":{}}}},\\"default\\":{\\"sap.app\\":{\\"id\\":\\"my.test.App\\"},\\"sap.ui5\\":{\\"dependencies\\":{\\"libs\\":{\\"sap.fe.core\\":{}}}}}}", + "contents": "{\\"sap.app\\":{\\"id\\":\\"my.test.App\\"},\\"sap.ui5\\":{\\"dependencies\\":{\\"minUI5Version\\":\\"1.127.0\\",\\"libs\\":{\\"sap.fe.core\\":{}}}},\\"default\\":{\\"sap.app\\":{\\"id\\":\\"my.test.App\\"},\\"sap.ui5\\":{\\"dependencies\\":{\\"minUI5Version\\":\\"1.127.0\\",\\"libs\\":{\\"sap.fe.core\\":{}}}}}}", "state": "modified", }, } @@ -343,7 +343,7 @@ Object { "state": "modified", }, "generate-table-with-optional-params/webapp/manifest.json": Object { - "contents": "{\\"sap.app\\":{\\"id\\":\\"my.test.App\\"},\\"sap.ui5\\":{\\"dependencies\\":{\\"libs\\":{\\"sap.fe.core\\":{}}}},\\"default\\":{\\"sap.app\\":{\\"id\\":\\"my.test.App\\"},\\"sap.ui5\\":{\\"dependencies\\":{\\"libs\\":{\\"sap.fe.core\\":{}}}}}}", + "contents": "{\\"sap.app\\":{\\"id\\":\\"my.test.App\\"},\\"sap.ui5\\":{\\"dependencies\\":{\\"minUI5Version\\":\\"1.127.0\\",\\"libs\\":{\\"sap.fe.core\\":{}}}},\\"default\\":{\\"sap.app\\":{\\"id\\":\\"my.test.App\\"},\\"sap.ui5\\":{\\"dependencies\\":{\\"minUI5Version\\":\\"1.127.0\\",\\"libs\\":{\\"sap.fe.core\\":{}}}}}}", "state": "modified", }, } @@ -362,7 +362,7 @@ Object { "state": "modified", }, "generate-table-with-optional-params/webapp/manifest.json": Object { - "contents": "{\\"sap.app\\":{\\"id\\":\\"my.test.App\\"},\\"sap.ui5\\":{\\"dependencies\\":{\\"libs\\":{\\"sap.fe.core\\":{}}}},\\"default\\":{\\"sap.app\\":{\\"id\\":\\"my.test.App\\"},\\"sap.ui5\\":{\\"dependencies\\":{\\"libs\\":{\\"sap.fe.core\\":{}}}}}}", + "contents": "{\\"sap.app\\":{\\"id\\":\\"my.test.App\\"},\\"sap.ui5\\":{\\"dependencies\\":{\\"minUI5Version\\":\\"1.127.0\\",\\"libs\\":{\\"sap.fe.core\\":{}}}},\\"default\\":{\\"sap.app\\":{\\"id\\":\\"my.test.App\\"},\\"sap.ui5\\":{\\"dependencies\\":{\\"minUI5Version\\":\\"1.127.0\\",\\"libs\\":{\\"sap.fe.core\\":{}}}}}}", "state": "modified", }, } @@ -464,7 +464,7 @@ Object { "state": "modified", }, "../../../../unit/sample/building-block/webapp-prompts/webapp/manifest.json": Object { - "contents": "{\\"sap.app\\":{\\"id\\":\\"my.test.App\\"},\\"sap.ui5\\":{\\"dependencies\\":{\\"libs\\":{\\"sap.fe.templates\\":{}}}},\\"default\\":{\\"sap.app\\":{\\"id\\":\\"my.test.App\\"},\\"sap.ui5\\":{\\"dependencies\\":{\\"libs\\":{\\"sap.fe.core\\":{}}}}}}", + "contents": "{\\"sap.app\\":{\\"id\\":\\"my.test.App\\"},\\"sap.ui5\\":{\\"dependencies\\":{\\"libs\\":{\\"sap.fe.templates\\":{}}}},\\"default\\":{\\"sap.app\\":{\\"id\\":\\"my.test.App\\"},\\"sap.ui5\\":{\\"dependencies\\":{\\"minUI5Version\\":\\"1.127.0\\",\\"libs\\":{\\"sap.fe.core\\":{}}}}}}", "state": "modified", }, } diff --git a/packages/fe-fpm-writer/test/unit/building-block.test.ts b/packages/fe-fpm-writer/test/unit/building-block.test.ts index 85168ef96a..b644940b1d 100644 --- a/packages/fe-fpm-writer/test/unit/building-block.test.ts +++ b/packages/fe-fpm-writer/test/unit/building-block.test.ts @@ -508,7 +508,7 @@ describe('Building Blocks', () => { ); const aggregationPath = `/mvc:View/*[local-name()='Page']/*[local-name()='content']`; fs.write(join(basePath, xmlViewFilePath), testXmlViewContent); - + fs.write(join(basePath, manifestFilePath), JSON.stringify(testManifestContent)); const codeSnippet = getSerializedFileContent( basePath, { diff --git a/packages/fe-fpm-writer/test/unit/page/__snapshots__/custom.test.ts.snap b/packages/fe-fpm-writer/test/unit/page/__snapshots__/custom.test.ts.snap index c2c94c807a..ce5c2382b3 100644 --- a/packages/fe-fpm-writer/test/unit/page/__snapshots__/custom.test.ts.snap +++ b/packages/fe-fpm-writer/test/unit/page/__snapshots__/custom.test.ts.snap @@ -38,7 +38,7 @@ export default class CustomPage extends Controller { * @memberOf my.test.App.ext.customPage.CustomPage */ // public onInit(): void { - // + // super.onInit(); // needs to be called to properly initialize the page controller //} /** @@ -83,7 +83,7 @@ export default class CustomPage extends Controller { * @memberOf my.test.App.ext.customPage.CustomPage */ // public onInit(): void { - // + // super.onInit(); // needs to be called to properly initialize the page controller //} /** @@ -377,7 +377,7 @@ exports[`CustomPage generateCustomPage: different versions or target folder late * @memberOf my.test.App.ext.customPage.CustomPage */ // onInit: function () { - // + // PageController.prototype.onInit.apply(this, arguments); // needs to be called to properly initialize the page controller // }, /** @@ -478,7 +478,7 @@ exports[`CustomPage generateCustomPage: different versions or target folder late * @memberOf my.test.App.ext.customPage.CustomPage */ // onInit: function () { - // + // PageController.prototype.onInit.apply(this, arguments); // needs to be called to properly initialize the page controller // }, /** @@ -580,7 +580,7 @@ exports[`CustomPage generateCustomPage: different versions or target folder late * @memberOf my.test.App.ext.customPage.CustomPage */ // onInit: function () { - // + // PageController.prototype.onInit.apply(this, arguments); // needs to be called to properly initialize the page controller // }, /** @@ -682,7 +682,7 @@ exports[`CustomPage generateCustomPage: different versions or target folder late * @memberOf my.test.App.ext.different.CustomPage */ // onInit: function () { - // + // PageController.prototype.onInit.apply(this, arguments); // needs to be called to properly initialize the page controller // }, /** @@ -870,7 +870,7 @@ exports[`CustomPage generateCustomPage: different versions or target folder with * @memberOf my.test.App.ext.customPage.CustomPage */ // onInit: function () { - // + // PageController.prototype.onInit.apply(this, arguments); // needs to be called to properly initialize the page controller // }, /** diff --git a/packages/fe-fpm-writer/test/unit/prompts/__snapshots__/prompts.test.ts.snap b/packages/fe-fpm-writer/test/unit/prompts/__snapshots__/prompts.test.ts.snap index 1ad40dca85..220e2f59cc 100644 --- a/packages/fe-fpm-writer/test/unit/prompts/__snapshots__/prompts.test.ts.snap +++ b/packages/fe-fpm-writer/test/unit/prompts/__snapshots__/prompts.test.ts.snap @@ -1157,6 +1157,50 @@ exports[`Prompts getCodeSnippet Type "chart", get code snippet 1`] = ` />" `; +exports[`Prompts getCodeSnippet Type "chart", get code snippet, min ui5Version = 1.96.25 1`] = ` +"" +`; + +exports[`Prompts getCodeSnippet Type "chart", get code snippet, min ui5Version = 1.97.0 1`] = ` +"" +`; + +exports[`Prompts getCodeSnippet Type "chart", get code snippet, min ui5Version = 1.97.35 1`] = ` +"" +`; + +exports[`Prompts getCodeSnippet Type "chart", get code snippet, min ui5Version is undefined 1`] = ` +"" +`; + exports[`Prompts getCodeSnippet Type "filter-bar", get code snippet 1`] = ` "" `; +exports[`Prompts getCodeSnippet Type "filter-bar", get code snippet, min ui5Version = 1.96.25 1`] = ` +"" +`; + +exports[`Prompts getCodeSnippet Type "filter-bar", get code snippet, min ui5Version = 1.97.0 1`] = ` +"" +`; + +exports[`Prompts getCodeSnippet Type "filter-bar", get code snippet, min ui5Version = 1.97.35 1`] = ` +"" +`; + +exports[`Prompts getCodeSnippet Type "filter-bar", get code snippet, min ui5Version is undefined 1`] = ` +"" +`; + exports[`Prompts getCodeSnippet Type "table", get code snippet 1`] = ` "" `; +exports[`Prompts getCodeSnippet Type "table", get code snippet, min ui5Version = 1.96.25 1`] = ` +"" +`; + +exports[`Prompts getCodeSnippet Type "table", get code snippet, min ui5Version = 1.97.0 1`] = ` +"" +`; + +exports[`Prompts getCodeSnippet Type "table", get code snippet, min ui5Version = 1.97.35 1`] = ` +"" +`; + +exports[`Prompts getCodeSnippet Type "table", get code snippet, min ui5Version is undefined 1`] = ` +"" +`; + exports[`Prompts getCodeSnippet get code snippet with placeholders 1`] = ` "" `; +exports[`Prompts submitAnswers Type "chart", min ui5Version = 1.96.0 1`] = ` +" + + + + + +" +`; + exports[`Prompts submitAnswers Type "filter-bar" 1`] = ` " @@ -1690,6 +1826,16 @@ exports[`Prompts submitAnswers Type "filter-bar" 1`] = ` " `; +exports[`Prompts submitAnswers Type "filter-bar", min ui5Version = 1.96.0 1`] = ` +" + + + + + +" +`; + exports[`Prompts submitAnswers Type "table" 1`] = ` " @@ -1700,6 +1846,16 @@ exports[`Prompts submitAnswers Type "table" 1`] = ` " `; +exports[`Prompts submitAnswers Type "table", min ui5Version = 1.96.0 1`] = ` +" + + + + + +" +`; + exports[`Prompts submitAnswers Type generation prompts type without generator 1`] = ` " { let fs: Editor; @@ -246,6 +247,33 @@ describe('Prompts', () => { expect(result.viewOrFragmentPath.filePathProps?.fileName).toBe('Main.view.xml'); }); + test.each(types)('Type "%s", get code snippet, min ui5Version = 1.96.25', async (type: PromptsType) => { + jest.spyOn(projectAccess, 'getMinimumUI5Version').mockReturnValueOnce('1.96.25'); + const result = promptsAPI.getCodeSnippets(type, answers[type] as SupportedGeneratorAnswers); + expect(result.viewOrFragmentPath.content).toMatchSnapshot(); + expect(result.viewOrFragmentPath.filePathProps?.fileName).toBe('Main.view.xml'); + }); + + test.each(types)('Type "%s", get code snippet, min ui5Version = 1.97.0', async (type: PromptsType) => { + jest.spyOn(projectAccess, 'getMinimumUI5Version').mockReturnValueOnce('1.97.0'); + const result = promptsAPI.getCodeSnippets(type, answers[type] as SupportedGeneratorAnswers); + expect(result.viewOrFragmentPath.content).toMatchSnapshot(); + expect(result.viewOrFragmentPath.filePathProps?.fileName).toBe('Main.view.xml'); + }); + test.each(types)('Type "%s", get code snippet, min ui5Version = 1.97.35', async (type: PromptsType) => { + jest.spyOn(projectAccess, 'getMinimumUI5Version').mockReturnValueOnce('1.97.35'); + const result = promptsAPI.getCodeSnippets(type, answers[type] as SupportedGeneratorAnswers); + expect(result.viewOrFragmentPath.content).toMatchSnapshot(); + expect(result.viewOrFragmentPath.filePathProps?.fileName).toBe('Main.view.xml'); + }); + + test.each(types)('Type "%s", get code snippet, min ui5Version is undefined', async (type: PromptsType) => { + jest.spyOn(projectAccess, 'getMinimumUI5Version').mockReturnValueOnce(undefined); + const result = promptsAPI.getCodeSnippets(type, answers[type] as SupportedGeneratorAnswers); + expect(result.viewOrFragmentPath.content).toMatchSnapshot(); + expect(result.viewOrFragmentPath.filePathProps?.fileName).toBe('Main.view.xml'); + }); + test('get code snippet with placeholders', async () => { const result = promptsAPI.getCodeSnippets(PromptsType.Table, { buildingBlockData: { @@ -272,6 +300,28 @@ describe('Prompts', () => { expect(result.read(join(projectPath, baseAnswers.viewOrFragmentPath))).toMatchSnapshot(); }); + test.each(types)('Type "%s", min ui5Version = 1.96.0', async (type: PromptsType) => { + const filePath = join(projectPath, 'webapp/manifest.json'); + const json = `{ + "sap.app": { + "id": "my.test.App" + }, + "sap.ui5": { + "dependencies": { + "minUI5Version": "1.96.0", + "libs": { + "sap.fe.core": {}, + "sap.fe.templates": {} + } + } + } +} +`; + fs.write(filePath, json); + const result = promptsAPI.submitAnswers(type, answers[type] as SupportedGeneratorAnswers); + expect(result.read(join(projectPath, baseAnswers.viewOrFragmentPath))).toMatchSnapshot(); + }); + test('Type generation prompts type without generator', async () => { const result = promptsAPI.submitAnswers( PromptsType.BuildingBlocks, diff --git a/packages/fe-fpm-writer/test/unit/sample/building-block/webapp-prompts/webapp/manifest.json b/packages/fe-fpm-writer/test/unit/sample/building-block/webapp-prompts/webapp/manifest.json index 8475f9118f..0b1b5d379a 100644 --- a/packages/fe-fpm-writer/test/unit/sample/building-block/webapp-prompts/webapp/manifest.json +++ b/packages/fe-fpm-writer/test/unit/sample/building-block/webapp-prompts/webapp/manifest.json @@ -35,6 +35,7 @@ }, "sap.ui5": { "dependencies": { + "minUI5Version": "1.127.0", "libs": { "sap.fe.core": {} } diff --git a/packages/fe-fpm-writer/test/unit/sample/building-block/webapp/manifest.json b/packages/fe-fpm-writer/test/unit/sample/building-block/webapp/manifest.json index a89242995a..f77dd83d60 100644 --- a/packages/fe-fpm-writer/test/unit/sample/building-block/webapp/manifest.json +++ b/packages/fe-fpm-writer/test/unit/sample/building-block/webapp/manifest.json @@ -4,6 +4,7 @@ }, "sap.ui5": { "dependencies": { + "minUI5Version": "1.127.0", "libs": { "sap.fe.core": {} } diff --git a/packages/fiori-annotation-api/CHANGELOG.md b/packages/fiori-annotation-api/CHANGELOG.md index 6253c65228..f11d2b94b4 100644 --- a/packages/fiori-annotation-api/CHANGELOG.md +++ b/packages/fiori-annotation-api/CHANGELOG.md @@ -1,5 +1,21 @@ # @sap-ux/fiori-annotation-api +## 0.1.40 + +### Patch Changes + +- Updated dependencies [d962ce1] + - @sap-ux/project-access@1.27.1 + - @sap-ux/cds-odata-annotation-converter@0.3.5 + +## 0.1.39 + +### Patch Changes + +- Updated dependencies [df29368] + - @sap-ux/project-access@1.27.0 + - @sap-ux/cds-odata-annotation-converter@0.3.5 + ## 0.1.38 ### Patch Changes diff --git a/packages/fiori-annotation-api/package.json b/packages/fiori-annotation-api/package.json index ec7106ed75..eb5c3fd496 100644 --- a/packages/fiori-annotation-api/package.json +++ b/packages/fiori-annotation-api/package.json @@ -1,6 +1,6 @@ { "name": "@sap-ux/fiori-annotation-api", - "version": "0.1.38", + "version": "0.1.40", "description": "Library that provides API for reading and writing annotations in SAP Fiori elements projects.", "publisher": "SAPSE", "author": "SAP SE", diff --git a/packages/fiori-elements-writer/CHANGELOG.md b/packages/fiori-elements-writer/CHANGELOG.md index 28921f3e3e..e94ce64014 100644 --- a/packages/fiori-elements-writer/CHANGELOG.md +++ b/packages/fiori-elements-writer/CHANGELOG.md @@ -1,5 +1,48 @@ # @sap-ux/fiori-elements-writer +## 1.1.14 + +### Patch Changes + +- Updated dependencies [177cdc8] + - @sap-ux/fe-fpm-writer@0.29.0 + +## 1.1.13 + +### Patch Changes + +- Updated dependencies [d962ce1] + - @sap-ux/odata-service-writer@0.22.5 + - @sap-ux/fe-fpm-writer@0.28.3 + - @sap-ux/fiori-generator-shared@0.3.19 + - @sap-ux/ui5-application-writer@1.1.6 + - @sap-ux/ui5-test-writer@0.4.1 + +## 1.1.12 + +### Patch Changes + +- @sap-ux/fe-fpm-writer@0.28.2 +- @sap-ux/fiori-generator-shared@0.3.18 +- @sap-ux/odata-service-writer@0.22.4 +- @sap-ux/ui5-application-writer@1.1.6 +- @sap-ux/ui5-test-writer@0.4.1 + +## 1.1.11 + +### Patch Changes + +- 8cfd71a: ui5-application-writer - fix backward support for older ui5 versions in locate-reuse-libs.js +- Updated dependencies [8cfd71a] + - @sap-ux/ui5-application-writer@1.1.6 + +## 1.1.10 + +### Patch Changes + +- Updated dependencies [b10e3fd] + - @sap-ux/fe-fpm-writer@0.28.1 + ## 1.1.9 ### Patch Changes diff --git a/packages/fiori-elements-writer/package.json b/packages/fiori-elements-writer/package.json index e61fa3e051..cb2d1d395e 100644 --- a/packages/fiori-elements-writer/package.json +++ b/packages/fiori-elements-writer/package.json @@ -1,7 +1,7 @@ { "name": "@sap-ux/fiori-elements-writer", "description": "SAP Fiori elements application writer", - "version": "1.1.9", + "version": "1.1.14", "repository": { "type": "git", "url": "https://github.com/SAP/open-ux-tools.git", diff --git a/packages/fiori-elements-writer/test/__snapshots__/alp.test.ts.snap b/packages/fiori-elements-writer/test/__snapshots__/alp.test.ts.snap index 47f87a0b10..b8bee8994c 100644 --- a/packages/fiori-elements-writer/test/__snapshots__/alp.test.ts.snap +++ b/packages/fiori-elements-writer/test/__snapshots__/alp.test.ts.snap @@ -5948,10 +5948,15 @@ sap.registerComponentDependencyPaths(manifestUri) } else { sap.ui.getCore().attachInit(function () { registerSAPFonts(); - // initialize the ushell sandbox component - sap.ushell.Container.createRenderer(true).then(function (component) { - component.placeAt(\\"content\\"); - }); + try { + // initialize the ushell sandbox component in ui5 v2 + sap.ushell.Container.createRenderer(true).then(function (component) { + component.placeAt(\\"content\\"); + }); + } catch { + // support older versions of ui5 + sap.ushell.Container.createRenderer().placeAt(\\"content\\"); + } }); } });", @@ -11932,10 +11937,15 @@ sap.registerComponentDependencyPaths(manifestUri) } else { sap.ui.getCore().attachInit(function () { registerSAPFonts(); - // initialize the ushell sandbox component - sap.ushell.Container.createRenderer(true).then(function (component) { - component.placeAt(\\"content\\"); - }); + try { + // initialize the ushell sandbox component in ui5 v2 + sap.ushell.Container.createRenderer(true).then(function (component) { + component.placeAt(\\"content\\"); + }); + } catch { + // support older versions of ui5 + sap.ushell.Container.createRenderer().placeAt(\\"content\\"); + } }); } });", diff --git a/packages/fiori-elements-writer/test/__snapshots__/feop.test.ts.snap b/packages/fiori-elements-writer/test/__snapshots__/feop.test.ts.snap index 8770758320..d0b93b3ec7 100644 --- a/packages/fiori-elements-writer/test/__snapshots__/feop.test.ts.snap +++ b/packages/fiori-elements-writer/test/__snapshots__/feop.test.ts.snap @@ -3637,10 +3637,15 @@ sap.registerComponentDependencyPaths(manifestUri) } else { sap.ui.getCore().attachInit(function () { registerSAPFonts(); - // initialize the ushell sandbox component - sap.ushell.Container.createRenderer(true).then(function (component) { - component.placeAt(\\"content\\"); - }); + try { + // initialize the ushell sandbox component in ui5 v2 + sap.ushell.Container.createRenderer(true).then(function (component) { + component.placeAt(\\"content\\"); + }); + } catch { + // support older versions of ui5 + sap.ushell.Container.createRenderer().placeAt(\\"content\\"); + } }); } });", diff --git a/packages/fiori-elements-writer/test/__snapshots__/fpm.test.ts.snap b/packages/fiori-elements-writer/test/__snapshots__/fpm.test.ts.snap index a25cd60641..bd85370b0d 100644 --- a/packages/fiori-elements-writer/test/__snapshots__/fpm.test.ts.snap +++ b/packages/fiori-elements-writer/test/__snapshots__/fpm.test.ts.snap @@ -215,7 +215,7 @@ server: * @memberOf fefpmjs.ext.main.Main */ // onInit: function () { - // + // PageController.prototype.onInit.apply(this, arguments); // needs to be called to properly initialize the page controller // }, /** @@ -3655,10 +3655,15 @@ sap.registerComponentDependencyPaths(manifestUri) } else { sap.ui.getCore().attachInit(function () { registerSAPFonts(); - // initialize the ushell sandbox component - sap.ushell.Container.createRenderer(true).then(function (component) { - component.placeAt(\\"content\\"); - }); + try { + // initialize the ushell sandbox component in ui5 v2 + sap.ushell.Container.createRenderer(true).then(function (component) { + component.placeAt(\\"content\\"); + }); + } catch { + // support older versions of ui5 + sap.ushell.Container.createRenderer().placeAt(\\"content\\"); + } }); } });", @@ -3984,7 +3989,7 @@ export default class Main extends Controller { * @memberOf fefpmts.ext.main.Main */ // public onInit(): void { - // + // super.onInit(); // needs to be called to properly initialize the page controller //} /** diff --git a/packages/fiori-elements-writer/test/__snapshots__/lrop.test.ts.snap b/packages/fiori-elements-writer/test/__snapshots__/lrop.test.ts.snap index 21915dd248..82e6347bb4 100644 --- a/packages/fiori-elements-writer/test/__snapshots__/lrop.test.ts.snap +++ b/packages/fiori-elements-writer/test/__snapshots__/lrop.test.ts.snap @@ -3591,10 +3591,15 @@ sap.registerComponentDependencyPaths(manifestUri) } else { sap.ui.getCore().attachInit(function () { registerSAPFonts(); - // initialize the ushell sandbox component - sap.ushell.Container.createRenderer(true).then(function (component) { - component.placeAt(\\"content\\"); - }); + try { + // initialize the ushell sandbox component in ui5 v2 + sap.ushell.Container.createRenderer(true).then(function (component) { + component.placeAt(\\"content\\"); + }); + } catch { + // support older versions of ui5 + sap.ushell.Container.createRenderer().placeAt(\\"content\\"); + } }); } });", @@ -7194,10 +7199,15 @@ sap.registerComponentDependencyPaths(manifestUri) } else { sap.ui.getCore().attachInit(function () { registerSAPFonts(); - // initialize the ushell sandbox component - sap.ushell.Container.createRenderer(true).then(function (component) { - component.placeAt(\\"content\\"); - }); + try { + // initialize the ushell sandbox component in ui5 v2 + sap.ushell.Container.createRenderer(true).then(function (component) { + component.placeAt(\\"content\\"); + }); + } catch { + // support older versions of ui5 + sap.ushell.Container.createRenderer().placeAt(\\"content\\"); + } }); } });", @@ -14342,10 +14352,15 @@ sap.registerComponentDependencyPaths(manifestUri) } else { sap.ui.getCore().attachInit(function () { registerSAPFonts(); - // initialize the ushell sandbox component - sap.ushell.Container.createRenderer(true).then(function (component) { - component.placeAt(\\"content\\"); - }); + try { + // initialize the ushell sandbox component in ui5 v2 + sap.ushell.Container.createRenderer(true).then(function (component) { + component.placeAt(\\"content\\"); + }); + } catch { + // support older versions of ui5 + sap.ushell.Container.createRenderer().placeAt(\\"content\\"); + } }); } });", @@ -18004,10 +18019,15 @@ sap.registerComponentDependencyPaths(manifestUri) } else { sap.ui.getCore().attachInit(function () { registerSAPFonts(); - // initialize the ushell sandbox component - sap.ushell.Container.createRenderer(true).then(function (component) { - component.placeAt(\\"content\\"); - }); + try { + // initialize the ushell sandbox component in ui5 v2 + sap.ushell.Container.createRenderer(true).then(function (component) { + component.placeAt(\\"content\\"); + }); + } catch { + // support older versions of ui5 + sap.ushell.Container.createRenderer().placeAt(\\"content\\"); + } }); } });", @@ -21832,10 +21852,15 @@ sap.registerComponentDependencyPaths(manifestUri) } else { sap.ui.getCore().attachInit(function () { registerSAPFonts(); - // initialize the ushell sandbox component - sap.ushell.Container.createRenderer(true).then(function (component) { - component.placeAt(\\"content\\"); - }); + try { + // initialize the ushell sandbox component in ui5 v2 + sap.ushell.Container.createRenderer(true).then(function (component) { + component.placeAt(\\"content\\"); + }); + } catch { + // support older versions of ui5 + sap.ushell.Container.createRenderer().placeAt(\\"content\\"); + } }); } });", @@ -22644,10 +22669,15 @@ sap.registerComponentDependencyPaths(manifestUri) } else { sap.ui.getCore().attachInit(function () { registerSAPFonts(); - // initialize the ushell sandbox component - sap.ushell.Container.createRenderer(true).then(function (component) { - component.placeAt(\\"content\\"); - }); + try { + // initialize the ushell sandbox component in ui5 v2 + sap.ushell.Container.createRenderer(true).then(function (component) { + component.placeAt(\\"content\\"); + }); + } catch { + // support older versions of ui5 + sap.ushell.Container.createRenderer().placeAt(\\"content\\"); + } }); } });", @@ -23523,10 +23553,15 @@ sap.registerComponentDependencyPaths(manifestUri) } else { sap.ui.getCore().attachInit(function () { registerSAPFonts(); - // initialize the ushell sandbox component - sap.ushell.Container.createRenderer(true).then(function (component) { - component.placeAt(\\"content\\"); - }); + try { + // initialize the ushell sandbox component in ui5 v2 + sap.ushell.Container.createRenderer(true).then(function (component) { + component.placeAt(\\"content\\"); + }); + } catch { + // support older versions of ui5 + sap.ushell.Container.createRenderer().placeAt(\\"content\\"); + } }); } });", @@ -24849,10 +24884,15 @@ sap.registerComponentDependencyPaths(manifestUri) } else { sap.ui.getCore().attachInit(function () { registerSAPFonts(); - // initialize the ushell sandbox component - sap.ushell.Container.createRenderer(true).then(function (component) { - component.placeAt(\\"content\\"); - }); + try { + // initialize the ushell sandbox component in ui5 v2 + sap.ushell.Container.createRenderer(true).then(function (component) { + component.placeAt(\\"content\\"); + }); + } catch { + // support older versions of ui5 + sap.ushell.Container.createRenderer().placeAt(\\"content\\"); + } }); } });", @@ -28537,10 +28577,15 @@ sap.registerComponentDependencyPaths(manifestUri) } else { sap.ui.getCore().attachInit(function () { registerSAPFonts(); - // initialize the ushell sandbox component - sap.ushell.Container.createRenderer(true).then(function (component) { - component.placeAt(\\"content\\"); - }); + try { + // initialize the ushell sandbox component in ui5 v2 + sap.ushell.Container.createRenderer(true).then(function (component) { + component.placeAt(\\"content\\"); + }); + } catch { + // support older versions of ui5 + sap.ushell.Container.createRenderer().placeAt(\\"content\\"); + } }); } });", @@ -32200,10 +32245,15 @@ sap.registerComponentDependencyPaths(manifestUri) } else { sap.ui.getCore().attachInit(function () { registerSAPFonts(); - // initialize the ushell sandbox component - sap.ushell.Container.createRenderer(true).then(function (component) { - component.placeAt(\\"content\\"); - }); + try { + // initialize the ushell sandbox component in ui5 v2 + sap.ushell.Container.createRenderer(true).then(function (component) { + component.placeAt(\\"content\\"); + }); + } catch { + // support older versions of ui5 + sap.ushell.Container.createRenderer().placeAt(\\"content\\"); + } }); } });", @@ -35805,10 +35855,15 @@ sap.registerComponentDependencyPaths(manifestUri) } else { sap.ui.getCore().attachInit(function () { registerSAPFonts(); - // initialize the ushell sandbox component - sap.ushell.Container.createRenderer(true).then(function (component) { - component.placeAt(\\"content\\"); - }); + try { + // initialize the ushell sandbox component in ui5 v2 + sap.ushell.Container.createRenderer(true).then(function (component) { + component.placeAt(\\"content\\"); + }); + } catch { + // support older versions of ui5 + sap.ushell.Container.createRenderer().placeAt(\\"content\\"); + } }); } });", @@ -42778,10 +42833,15 @@ sap.registerComponentDependencyPaths(manifestUri) } else { sap.ui.getCore().attachInit(function () { registerSAPFonts(); - // initialize the ushell sandbox component - sap.ushell.Container.createRenderer(true).then(function (component) { - component.placeAt(\\"content\\"); - }); + try { + // initialize the ushell sandbox component in ui5 v2 + sap.ushell.Container.createRenderer(true).then(function (component) { + component.placeAt(\\"content\\"); + }); + } catch { + // support older versions of ui5 + sap.ushell.Container.createRenderer().placeAt(\\"content\\"); + } }); } });", @@ -46382,10 +46442,15 @@ sap.registerComponentDependencyPaths(manifestUri) } else { sap.ui.getCore().attachInit(function () { registerSAPFonts(); - // initialize the ushell sandbox component - sap.ushell.Container.createRenderer(true).then(function (component) { - component.placeAt(\\"content\\"); - }); + try { + // initialize the ushell sandbox component in ui5 v2 + sap.ushell.Container.createRenderer(true).then(function (component) { + component.placeAt(\\"content\\"); + }); + } catch { + // support older versions of ui5 + sap.ushell.Container.createRenderer().placeAt(\\"content\\"); + } }); } });", diff --git a/packages/fiori-elements-writer/test/__snapshots__/ovp.test.ts.snap b/packages/fiori-elements-writer/test/__snapshots__/ovp.test.ts.snap index 9feaf207eb..bf146b9a06 100644 --- a/packages/fiori-elements-writer/test/__snapshots__/ovp.test.ts.snap +++ b/packages/fiori-elements-writer/test/__snapshots__/ovp.test.ts.snap @@ -1661,10 +1661,15 @@ sap.registerComponentDependencyPaths(manifestUri) } else { sap.ui.getCore().attachInit(function () { registerSAPFonts(); - // initialize the ushell sandbox component - sap.ushell.Container.createRenderer(true).then(function (component) { - component.placeAt(\\"content\\"); - }); + try { + // initialize the ushell sandbox component in ui5 v2 + sap.ushell.Container.createRenderer(true).then(function (component) { + component.placeAt(\\"content\\"); + }); + } catch { + // support older versions of ui5 + sap.ushell.Container.createRenderer().placeAt(\\"content\\"); + } }); } });", @@ -9125,10 +9130,15 @@ sap.registerComponentDependencyPaths(manifestUri) } else { sap.ui.getCore().attachInit(function () { registerSAPFonts(); - // initialize the ushell sandbox component - sap.ushell.Container.createRenderer(true).then(function (component) { - component.placeAt(\\"content\\"); - }); + try { + // initialize the ushell sandbox component in ui5 v2 + sap.ushell.Container.createRenderer(true).then(function (component) { + component.placeAt(\\"content\\"); + }); + } catch { + // support older versions of ui5 + sap.ushell.Container.createRenderer().placeAt(\\"content\\"); + } }); } });", diff --git a/packages/fiori-elements-writer/test/__snapshots__/worklist.test.ts.snap b/packages/fiori-elements-writer/test/__snapshots__/worklist.test.ts.snap index 25f4571d0f..ae3cb61ce5 100644 --- a/packages/fiori-elements-writer/test/__snapshots__/worklist.test.ts.snap +++ b/packages/fiori-elements-writer/test/__snapshots__/worklist.test.ts.snap @@ -3599,10 +3599,15 @@ sap.registerComponentDependencyPaths(manifestUri) } else { sap.ui.getCore().attachInit(function () { registerSAPFonts(); - // initialize the ushell sandbox component - sap.ushell.Container.createRenderer(true).then(function (component) { - component.placeAt(\\"content\\"); - }); + try { + // initialize the ushell sandbox component in ui5 v2 + sap.ushell.Container.createRenderer(true).then(function (component) { + component.placeAt(\\"content\\"); + }); + } catch { + // support older versions of ui5 + sap.ushell.Container.createRenderer().placeAt(\\"content\\"); + } }); } });", @@ -7210,10 +7215,15 @@ sap.registerComponentDependencyPaths(manifestUri) } else { sap.ui.getCore().attachInit(function () { registerSAPFonts(); - // initialize the ushell sandbox component - sap.ushell.Container.createRenderer(true).then(function (component) { - component.placeAt(\\"content\\"); - }); + try { + // initialize the ushell sandbox component in ui5 v2 + sap.ushell.Container.createRenderer(true).then(function (component) { + component.placeAt(\\"content\\"); + }); + } catch { + // support older versions of ui5 + sap.ushell.Container.createRenderer().placeAt(\\"content\\"); + } }); } });", @@ -10873,10 +10883,15 @@ sap.registerComponentDependencyPaths(manifestUri) } else { sap.ui.getCore().attachInit(function () { registerSAPFonts(); - // initialize the ushell sandbox component - sap.ushell.Container.createRenderer(true).then(function (component) { - component.placeAt(\\"content\\"); - }); + try { + // initialize the ushell sandbox component in ui5 v2 + sap.ushell.Container.createRenderer(true).then(function (component) { + component.placeAt(\\"content\\"); + }); + } catch { + // support older versions of ui5 + sap.ushell.Container.createRenderer().placeAt(\\"content\\"); + } }); } });", @@ -14700,10 +14715,15 @@ sap.registerComponentDependencyPaths(manifestUri) } else { sap.ui.getCore().attachInit(function () { registerSAPFonts(); - // initialize the ushell sandbox component - sap.ushell.Container.createRenderer(true).then(function (component) { - component.placeAt(\\"content\\"); - }); + try { + // initialize the ushell sandbox component in ui5 v2 + sap.ushell.Container.createRenderer(true).then(function (component) { + component.placeAt(\\"content\\"); + }); + } catch { + // support older versions of ui5 + sap.ushell.Container.createRenderer().placeAt(\\"content\\"); + } }); } });", diff --git a/packages/fiori-freestyle-writer/CHANGELOG.md b/packages/fiori-freestyle-writer/CHANGELOG.md index ed384d28cd..fc5f785aa2 100644 --- a/packages/fiori-freestyle-writer/CHANGELOG.md +++ b/packages/fiori-freestyle-writer/CHANGELOG.md @@ -1,5 +1,30 @@ # @sap-ux/fiori-freestyle-writer +## 1.0.22 + +### Patch Changes + +- Updated dependencies [d962ce1] + - @sap-ux/odata-service-writer@0.22.5 + - @sap-ux/fiori-generator-shared@0.3.19 + - @sap-ux/ui5-application-writer@1.1.6 + +## 1.0.21 + +### Patch Changes + +- @sap-ux/fiori-generator-shared@0.3.18 +- @sap-ux/odata-service-writer@0.22.4 +- @sap-ux/ui5-application-writer@1.1.6 + +## 1.0.20 + +### Patch Changes + +- 8cfd71a: ui5-application-writer - fix backward support for older ui5 versions in locate-reuse-libs.js +- Updated dependencies [8cfd71a] + - @sap-ux/ui5-application-writer@1.1.6 + ## 1.0.19 ### Patch Changes diff --git a/packages/fiori-freestyle-writer/package.json b/packages/fiori-freestyle-writer/package.json index b6249f7fc3..af3424572e 100644 --- a/packages/fiori-freestyle-writer/package.json +++ b/packages/fiori-freestyle-writer/package.json @@ -1,7 +1,7 @@ { "name": "@sap-ux/fiori-freestyle-writer", "description": "SAP Fiori freestyle application writer", - "version": "1.0.19", + "version": "1.0.22", "repository": { "type": "git", "url": "https://github.com/SAP/open-ux-tools.git", diff --git a/packages/fiori-freestyle-writer/test/__snapshots__/basic.test.ts.snap b/packages/fiori-freestyle-writer/test/__snapshots__/basic.test.ts.snap index 6b7cf55e1a..a6b40470ea 100644 --- a/packages/fiori-freestyle-writer/test/__snapshots__/basic.test.ts.snap +++ b/packages/fiori-freestyle-writer/test/__snapshots__/basic.test.ts.snap @@ -639,10 +639,15 @@ sap.registerComponentDependencyPaths(manifestUri) } else { sap.ui.getCore().attachInit(function () { registerSAPFonts(); - // initialize the ushell sandbox component - sap.ushell.Container.createRenderer(true).then(function (component) { - component.placeAt(\\"content\\"); - }); + try { + // initialize the ushell sandbox component in ui5 v2 + sap.ushell.Container.createRenderer(true).then(function (component) { + component.placeAt(\\"content\\"); + }); + } catch { + // support older versions of ui5 + sap.ushell.Container.createRenderer().placeAt(\\"content\\"); + } }); } });", @@ -1350,10 +1355,15 @@ sap.registerComponentDependencyPaths(manifestUri) } else { sap.ui.getCore().attachInit(function () { registerSAPFonts(); - // initialize the ushell sandbox component - sap.ushell.Container.createRenderer(true).then(function (component) { - component.placeAt(\\"content\\"); - }); + try { + // initialize the ushell sandbox component in ui5 v2 + sap.ushell.Container.createRenderer(true).then(function (component) { + component.placeAt(\\"content\\"); + }); + } catch { + // support older versions of ui5 + sap.ushell.Container.createRenderer().placeAt(\\"content\\"); + } }); } });", @@ -2061,10 +2071,15 @@ sap.registerComponentDependencyPaths(manifestUri) } else { sap.ui.getCore().attachInit(function () { registerSAPFonts(); - // initialize the ushell sandbox component - sap.ushell.Container.createRenderer(true).then(function (component) { - component.placeAt(\\"content\\"); - }); + try { + // initialize the ushell sandbox component in ui5 v2 + sap.ushell.Container.createRenderer(true).then(function (component) { + component.placeAt(\\"content\\"); + }); + } catch { + // support older versions of ui5 + sap.ushell.Container.createRenderer().placeAt(\\"content\\"); + } }); } });", @@ -4709,10 +4724,15 @@ sap.registerComponentDependencyPaths(manifestUri) } else { sap.ui.getCore().attachInit(function () { registerSAPFonts(); - // initialize the ushell sandbox component - sap.ushell.Container.createRenderer(true).then(function (component) { - component.placeAt(\\"content\\"); - }); + try { + // initialize the ushell sandbox component in ui5 v2 + sap.ushell.Container.createRenderer(true).then(function (component) { + component.placeAt(\\"content\\"); + }); + } catch { + // support older versions of ui5 + sap.ushell.Container.createRenderer().placeAt(\\"content\\"); + } }); } });", @@ -5498,10 +5518,15 @@ sap.registerComponentDependencyPaths(manifestUri) } else { sap.ui.getCore().attachInit(function () { registerSAPFonts(); - // initialize the ushell sandbox component - sap.ushell.Container.createRenderer(true).then(function (component) { - component.placeAt(\\"content\\"); - }); + try { + // initialize the ushell sandbox component in ui5 v2 + sap.ushell.Container.createRenderer(true).then(function (component) { + component.placeAt(\\"content\\"); + }); + } catch { + // support older versions of ui5 + sap.ushell.Container.createRenderer().placeAt(\\"content\\"); + } }); } });", @@ -6288,10 +6313,15 @@ sap.registerComponentDependencyPaths(manifestUri) } else { sap.ui.getCore().attachInit(function () { registerSAPFonts(); - // initialize the ushell sandbox component - sap.ushell.Container.createRenderer(true).then(function (component) { - component.placeAt(\\"content\\"); - }); + try { + // initialize the ushell sandbox component in ui5 v2 + sap.ushell.Container.createRenderer(true).then(function (component) { + component.placeAt(\\"content\\"); + }); + } catch { + // support older versions of ui5 + sap.ushell.Container.createRenderer().placeAt(\\"content\\"); + } }); } });", @@ -7591,10 +7621,15 @@ sap.registerComponentDependencyPaths(manifestUri) } else { sap.ui.getCore().attachInit(function () { registerSAPFonts(); - // initialize the ushell sandbox component - sap.ushell.Container.createRenderer(true).then(function (component) { - component.placeAt(\\"content\\"); - }); + try { + // initialize the ushell sandbox component in ui5 v2 + sap.ushell.Container.createRenderer(true).then(function (component) { + component.placeAt(\\"content\\"); + }); + } catch { + // support older versions of ui5 + sap.ushell.Container.createRenderer().placeAt(\\"content\\"); + } }); } });", diff --git a/packages/fiori-freestyle-writer/test/__snapshots__/index.test.ts.snap b/packages/fiori-freestyle-writer/test/__snapshots__/index.test.ts.snap index c8e65dc0bc..68714b33d8 100644 --- a/packages/fiori-freestyle-writer/test/__snapshots__/index.test.ts.snap +++ b/packages/fiori-freestyle-writer/test/__snapshots__/index.test.ts.snap @@ -1301,10 +1301,15 @@ sap.registerComponentDependencyPaths(manifestUri) } else { sap.ui.getCore().attachInit(function () { registerSAPFonts(); - // initialize the ushell sandbox component - sap.ushell.Container.createRenderer(true).then(function (component) { - component.placeAt(\\"content\\"); - }); + try { + // initialize the ushell sandbox component in ui5 v2 + sap.ushell.Container.createRenderer(true).then(function (component) { + component.placeAt(\\"content\\"); + }); + } catch { + // support older versions of ui5 + sap.ushell.Container.createRenderer().placeAt(\\"content\\"); + } }); } });", @@ -3673,10 +3678,15 @@ sap.registerComponentDependencyPaths(manifestUri) } else { sap.ui.getCore().attachInit(function () { registerSAPFonts(); - // initialize the ushell sandbox component - sap.ushell.Container.createRenderer(true).then(function (component) { - component.placeAt(\\"content\\"); - }); + try { + // initialize the ushell sandbox component in ui5 v2 + sap.ushell.Container.createRenderer(true).then(function (component) { + component.placeAt(\\"content\\"); + }); + } catch { + // support older versions of ui5 + sap.ushell.Container.createRenderer().placeAt(\\"content\\"); + } }); } });", @@ -5866,10 +5876,15 @@ sap.registerComponentDependencyPaths(manifestUri) } else { sap.ui.getCore().attachInit(function () { registerSAPFonts(); - // initialize the ushell sandbox component - sap.ushell.Container.createRenderer(true).then(function (component) { - component.placeAt(\\"content\\"); - }); + try { + // initialize the ushell sandbox component in ui5 v2 + sap.ushell.Container.createRenderer(true).then(function (component) { + component.placeAt(\\"content\\"); + }); + } catch { + // support older versions of ui5 + sap.ushell.Container.createRenderer().placeAt(\\"content\\"); + } }); } });", diff --git a/packages/fiori-freestyle-writer/test/__snapshots__/listdetail.test.ts.snap b/packages/fiori-freestyle-writer/test/__snapshots__/listdetail.test.ts.snap index 166229bc4a..281c5ac3b6 100644 --- a/packages/fiori-freestyle-writer/test/__snapshots__/listdetail.test.ts.snap +++ b/packages/fiori-freestyle-writer/test/__snapshots__/listdetail.test.ts.snap @@ -2376,10 +2376,15 @@ sap.registerComponentDependencyPaths(manifestUri) } else { sap.ui.getCore().attachInit(function () { registerSAPFonts(); - // initialize the ushell sandbox component - sap.ushell.Container.createRenderer(true).then(function (component) { - component.placeAt(\\"content\\"); - }); + try { + // initialize the ushell sandbox component in ui5 v2 + sap.ushell.Container.createRenderer(true).then(function (component) { + component.placeAt(\\"content\\"); + }); + } catch { + // support older versions of ui5 + sap.ushell.Container.createRenderer().placeAt(\\"content\\"); + } }); } });", @@ -5070,10 +5075,15 @@ sap.registerComponentDependencyPaths(manifestUri) } else { sap.ui.getCore().attachInit(function () { registerSAPFonts(); - // initialize the ushell sandbox component - sap.ushell.Container.createRenderer(true).then(function (component) { - component.placeAt(\\"content\\"); - }); + try { + // initialize the ushell sandbox component in ui5 v2 + sap.ushell.Container.createRenderer(true).then(function (component) { + component.placeAt(\\"content\\"); + }); + } catch { + // support older versions of ui5 + sap.ushell.Container.createRenderer().placeAt(\\"content\\"); + } }); } });", @@ -7764,10 +7774,15 @@ sap.registerComponentDependencyPaths(manifestUri) } else { sap.ui.getCore().attachInit(function () { registerSAPFonts(); - // initialize the ushell sandbox component - sap.ushell.Container.createRenderer(true).then(function (component) { - component.placeAt(\\"content\\"); - }); + try { + // initialize the ushell sandbox component in ui5 v2 + sap.ushell.Container.createRenderer(true).then(function (component) { + component.placeAt(\\"content\\"); + }); + } catch { + // support older versions of ui5 + sap.ushell.Container.createRenderer().placeAt(\\"content\\"); + } }); } });", @@ -10373,10 +10388,15 @@ sap.registerComponentDependencyPaths(manifestUri) } else { sap.ui.getCore().attachInit(function () { registerSAPFonts(); - // initialize the ushell sandbox component - sap.ushell.Container.createRenderer(true).then(function (component) { - component.placeAt(\\"content\\"); - }); + try { + // initialize the ushell sandbox component in ui5 v2 + sap.ushell.Container.createRenderer(true).then(function (component) { + component.placeAt(\\"content\\"); + }); + } catch { + // support older versions of ui5 + sap.ushell.Container.createRenderer().placeAt(\\"content\\"); + } }); } });", @@ -12920,10 +12940,15 @@ sap.registerComponentDependencyPaths(manifestUri) } else { sap.ui.getCore().attachInit(function () { registerSAPFonts(); - // initialize the ushell sandbox component - sap.ushell.Container.createRenderer(true).then(function (component) { - component.placeAt(\\"content\\"); - }); + try { + // initialize the ushell sandbox component in ui5 v2 + sap.ushell.Container.createRenderer(true).then(function (component) { + component.placeAt(\\"content\\"); + }); + } catch { + // support older versions of ui5 + sap.ushell.Container.createRenderer().placeAt(\\"content\\"); + } }); } });", diff --git a/packages/fiori-freestyle-writer/test/__snapshots__/worklist.test.ts.snap b/packages/fiori-freestyle-writer/test/__snapshots__/worklist.test.ts.snap index d78ddec7fc..6df0a62114 100644 --- a/packages/fiori-freestyle-writer/test/__snapshots__/worklist.test.ts.snap +++ b/packages/fiori-freestyle-writer/test/__snapshots__/worklist.test.ts.snap @@ -6549,10 +6549,15 @@ sap.registerComponentDependencyPaths(manifestUri) } else { sap.ui.getCore().attachInit(function () { registerSAPFonts(); - // initialize the ushell sandbox component - sap.ushell.Container.createRenderer(true).then(function (component) { - component.placeAt(\\"content\\"); - }); + try { + // initialize the ushell sandbox component in ui5 v2 + sap.ushell.Container.createRenderer(true).then(function (component) { + component.placeAt(\\"content\\"); + }); + } catch { + // support older versions of ui5 + sap.ushell.Container.createRenderer().placeAt(\\"content\\"); + } }); } });", @@ -9887,10 +9892,15 @@ sap.registerComponentDependencyPaths(manifestUri) } else { sap.ui.getCore().attachInit(function () { registerSAPFonts(); - // initialize the ushell sandbox component - sap.ushell.Container.createRenderer(true).then(function (component) { - component.placeAt(\\"content\\"); - }); + try { + // initialize the ushell sandbox component in ui5 v2 + sap.ushell.Container.createRenderer(true).then(function (component) { + component.placeAt(\\"content\\"); + }); + } catch { + // support older versions of ui5 + sap.ushell.Container.createRenderer().placeAt(\\"content\\"); + } }); } });", @@ -12816,10 +12826,15 @@ sap.registerComponentDependencyPaths(manifestUri) } else { sap.ui.getCore().attachInit(function () { registerSAPFonts(); - // initialize the ushell sandbox component - sap.ushell.Container.createRenderer(true).then(function (component) { - component.placeAt(\\"content\\"); - }); + try { + // initialize the ushell sandbox component in ui5 v2 + sap.ushell.Container.createRenderer(true).then(function (component) { + component.placeAt(\\"content\\"); + }); + } catch { + // support older versions of ui5 + sap.ushell.Container.createRenderer().placeAt(\\"content\\"); + } }); } });", @@ -16180,10 +16195,15 @@ sap.registerComponentDependencyPaths(manifestUri) } else { sap.ui.getCore().attachInit(function () { registerSAPFonts(); - // initialize the ushell sandbox component - sap.ushell.Container.createRenderer(true).then(function (component) { - component.placeAt(\\"content\\"); - }); + try { + // initialize the ushell sandbox component in ui5 v2 + sap.ushell.Container.createRenderer(true).then(function (component) { + component.placeAt(\\"content\\"); + }); + } catch { + // support older versions of ui5 + sap.ushell.Container.createRenderer().placeAt(\\"content\\"); + } }); } });", @@ -19545,10 +19565,15 @@ sap.registerComponentDependencyPaths(manifestUri) } else { sap.ui.getCore().attachInit(function () { registerSAPFonts(); - // initialize the ushell sandbox component - sap.ushell.Container.createRenderer(true).then(function (component) { - component.placeAt(\\"content\\"); - }); + try { + // initialize the ushell sandbox component in ui5 v2 + sap.ushell.Container.createRenderer(true).then(function (component) { + component.placeAt(\\"content\\"); + }); + } catch { + // support older versions of ui5 + sap.ushell.Container.createRenderer().placeAt(\\"content\\"); + } }); } });", diff --git a/packages/fiori-generator-shared/CHANGELOG.md b/packages/fiori-generator-shared/CHANGELOG.md index 8213eae099..2301e86040 100644 --- a/packages/fiori-generator-shared/CHANGELOG.md +++ b/packages/fiori-generator-shared/CHANGELOG.md @@ -1,5 +1,19 @@ # @sap-ux/fiori-generator-shared +## 0.3.19 + +### Patch Changes + +- Updated dependencies [d962ce1] + - @sap-ux/project-access@1.27.1 + +## 0.3.18 + +### Patch Changes + +- Updated dependencies [df29368] + - @sap-ux/project-access@1.27.0 + ## 0.3.17 ### Patch Changes diff --git a/packages/fiori-generator-shared/package.json b/packages/fiori-generator-shared/package.json index ccdffb9fd5..4ac8e73bf7 100644 --- a/packages/fiori-generator-shared/package.json +++ b/packages/fiori-generator-shared/package.json @@ -1,7 +1,7 @@ { "name": "@sap-ux/fiori-generator-shared", "description": "Commonly used shared functionality and types to support the fiori generator.", - "version": "0.3.17", + "version": "0.3.19", "repository": { "type": "git", "url": "https://github.com/SAP/open-ux-tools.git", diff --git a/packages/i18n/CHANGELOG.md b/packages/i18n/CHANGELOG.md index 9b2cb8b40b..3dc7dd459f 100644 --- a/packages/i18n/CHANGELOG.md +++ b/packages/i18n/CHANGELOG.md @@ -1,5 +1,11 @@ # @sap-ux/i18n +## 0.2.0 + +### Minor Changes + +- df29368: Methods `createCapI18nEntries`, `getCapI18nFolder` - handle absolute path to cds file instead of relative path + ## 0.1.1 ### Patch Changes diff --git a/packages/i18n/package.json b/packages/i18n/package.json index 6c234310c2..e2aea553bd 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -1,6 +1,6 @@ { "name": "@sap-ux/i18n", - "version": "0.1.1", + "version": "0.2.0", "description": "Library for i18n", "repository": { "type": "git", diff --git a/packages/i18n/src/utils/resolve.ts b/packages/i18n/src/utils/resolve.ts index 184dcd3737..192bfa4b42 100644 --- a/packages/i18n/src/utils/resolve.ts +++ b/packages/i18n/src/utils/resolve.ts @@ -99,14 +99,14 @@ export function getCapI18nFiles(root: string, env: CdsEnvironment, filePaths: st * Get an i18n folder for an existing CDS file. A new folder is only created, if it does not exist and optional `mem-fs-editor` instance is not provided. * * @param root project root - * @param path path to cds file + * @param path absolute path to cds file * @param env CDS environment configuration, * @param fs optional `mem-fs-editor` instance. If provided, a new folder is not created, even if it does not exist * @returns i18n folder path */ export async function getCapI18nFolder(root: string, path: string, env: CdsEnvironment, fs?: Editor): Promise { const { folders } = getI18nConfiguration(env); - let i18nFolderPath = resolveCapI18nFolderForFile(root, env, join(root, path)); + let i18nFolderPath = resolveCapI18nFolderForFile(root, env, path); if (!i18nFolderPath) { const folder = folders[0]; i18nFolderPath = join(root, folder); diff --git a/packages/i18n/src/write/cap/create.ts b/packages/i18n/src/write/cap/create.ts index 7cabef76cc..777b2b850f 100644 --- a/packages/i18n/src/write/cap/create.ts +++ b/packages/i18n/src/write/cap/create.ts @@ -10,7 +10,7 @@ import type { Editor } from 'mem-fs-editor'; * Create new i18n entries to an existing file or in a new file if one does not exist. * * @param root project root, where i18n folder should reside if no i18n file exists - * @param path path to cds file for which translation should be maintained + * @param path absolute path to cds file for which translation should be maintained * @param newI18nEntries new i18n entries that will be maintained * @param env CDS environment configuration * @param fs optional `mem-fs-editor` instance. If provided, `mem-fs-editor` api is used instead of `fs` of node diff --git a/packages/i18n/test/unit/utils/resolve.test.ts b/packages/i18n/test/unit/utils/resolve.test.ts index d50d3e5997..8a9d56fc49 100644 --- a/packages/i18n/test/unit/utils/resolve.test.ts +++ b/packages/i18n/test/unit/utils/resolve.test.ts @@ -116,7 +116,7 @@ describe('resolve', () => { const DATA_ROOT = join(__dirname, '..', 'data'); const PROJECT_ROOT = join(DATA_ROOT, 'project'); - test('i18n folder exist', async () => { + test('i18n folder exists in passed subpath', async () => { const env: CdsEnvironment = { i18n: { folders: ['_i18n', 'i18n', 'assets/i18n'], @@ -128,6 +128,16 @@ describe('resolve', () => { join(PROJECT_ROOT, 'app', 'properties-csv', 'service.cds'), env ); + expect(result).toStrictEqual(join(PROJECT_ROOT, 'app', 'properties-csv', '_i18n')); + }); + test('i18n folder exists in root', async () => { + const env: CdsEnvironment = { + i18n: { + folders: ['_i18n', 'i18n', 'assets/i18n'], + default_language: 'en' + } + }; + const result = await getCapI18nFolder(PROJECT_ROOT, join(PROJECT_ROOT, 'app', 'dummy'), env); expect(result).toStrictEqual(join(PROJECT_ROOT, 'i18n')); }); test('i18n folder does not exist', async () => { diff --git a/packages/launch-config/CHANGELOG.md b/packages/launch-config/CHANGELOG.md index b36c776bc7..48caadcdca 100644 --- a/packages/launch-config/CHANGELOG.md +++ b/packages/launch-config/CHANGELOG.md @@ -1,5 +1,25 @@ # @sap-ux/launch-config +## 0.3.0 + +### Minor Changes + +- 73fcd05: Add Launch Configuration Logic for debugging generated apps + +## 0.2.20 + +### Patch Changes + +- Updated dependencies [d962ce1] + - @sap-ux/project-access@1.27.1 + +## 0.2.19 + +### Patch Changes + +- Updated dependencies [df29368] + - @sap-ux/project-access@1.27.0 + ## 0.2.18 ### Patch Changes diff --git a/packages/launch-config/package.json b/packages/launch-config/package.json index 0572ad53fd..fe9cc2f3b6 100644 --- a/packages/launch-config/package.json +++ b/packages/launch-config/package.json @@ -1,6 +1,6 @@ { "name": "@sap-ux/launch-config", - "version": "0.2.18", + "version": "0.3.0", "description": "SAP Fiori tools launch config administration", "repository": { "type": "git", @@ -34,6 +34,9 @@ "@sap-ux/project-access": "workspace:*", "@sap-ux/ui5-config": "workspace:*", "@sap-ux/ui5-info": "workspace:*", + "@sap-ux/odata-service-inquirer": "workspace:*", + "@sap-ux/store": "workspace:*", + "i18next": "23.5.1", "jsonc-parser": "3.2.0", "mem-fs": "2.1.0", "mem-fs-editor": "9.4.0", diff --git a/packages/launch-config/src/debug-config/config.ts b/packages/launch-config/src/debug-config/config.ts new file mode 100644 index 0000000000..f130269fb6 --- /dev/null +++ b/packages/launch-config/src/debug-config/config.ts @@ -0,0 +1,139 @@ +import { DatasourceType, OdataVersion } from '@sap-ux/odata-service-inquirer'; +import { basename } from 'path'; +import { getLaunchConfig } from '../launch-config-crud/utils'; +import type { LaunchConfig, LaunchJSON, DebugOptions, LaunchConfigEnv } from '../types'; +import { FIORI_TOOLS_LAUNCH_CONFIG_HANDLER_ID } from '../types'; + +// debug constants +const testFlpSandboxHtml = 'test/flpSandbox.html'; +const indexHtml = 'index.html'; +const testFlpSandboxMockServerHtml = 'test/flpSandboxMockServer.html'; + +/** + * Generates a URL query string with an optional SAP client parameter and a disable cache parameter. + * + * @param {string} sapClientParam - The SAP client parameter to be included in the URL query string. + * @returns {string} A formatted URL query string containing the SAP client parameter and disable cache parameter. + * @example + * const urlParam = getEnvUrlParams('testsapclinet'); + * // Returns 'testsapclinet&sap-ui-xx-viewCache=false' + * @example + * const urlParam = getEnvUrlParams(''); + * // Returns 'sap-ui-xx-viewCache=false' + */ +function getEnvUrlParams(sapClientParam: string): string { + const disableCacheParam = 'sap-ui-xx-viewCache=false'; + return sapClientParam ? `${sapClientParam}&${disableCacheParam}` : disableCacheParam; +} + +/** + * Creates a launch configuration. + * + * @param {string} name - The name of the configuration. + * @param {string} cwd - The current working directory. + * @param {string[]} runtimeArgs - The runtime arguments. + * @param {string[]} cmdArgs - The command arguments. + * @param {object} envVars - Environment variables for the configuration. + * @param {string} [runConfig] - The optional run configuration for AppStudio. + * @returns {LaunchConfig} The launch configuration object. + */ +function createLaunchConfig( + name: string, + cwd: string, + runtimeArgs: string[], + cmdArgs: string[], + envVars: LaunchConfigEnv, + runConfig?: string +): LaunchConfig { + const config = getLaunchConfig(name, cwd, runtimeArgs, cmdArgs, envVars); + if (runConfig) { + // runConfig is only used in BAS + config.env['run.config'] = runConfig; + } + return config; +} + +/** + * Configures the launch.json file based on provided options. + * + * @param {string} cwd - The current working directory. + * @param {DebugOptions} configOpts - Configuration options for the launch.json file. + * @returns {LaunchJSON} The configured launch.json object. + */ +export function configureLaunchJsonFile(cwd: string, configOpts: DebugOptions): LaunchJSON { + const { + projectPath, + isAppStudio, + datasourceType, + flpAppId, + flpSandboxAvailable, + sapClientParam, + odataVersion, + isMigrator, + isFioriElement, + migratorMockIntent + } = configOpts; + + const projectName = basename(projectPath); + const flpAppIdWithHash = flpAppId && !flpAppId.startsWith('#') ? `#${flpAppId}` : flpAppId; + const startHtmlFile = flpSandboxAvailable ? testFlpSandboxHtml : indexHtml; + const runConfig = isAppStudio + ? JSON.stringify({ + handlerId: FIORI_TOOLS_LAUNCH_CONFIG_HANDLER_ID, + runnableId: projectPath + }) + : undefined; + const envUrlParam = getEnvUrlParams(sapClientParam); + + const launchFile: LaunchJSON = { version: '0.2.0', configurations: [] }; + + // Add live configuration if the datasource is not from a metadata file + if (datasourceType !== DatasourceType.metadataFile) { + const startCommand = `${startHtmlFile}${flpAppIdWithHash}`; + const liveConfig = createLaunchConfig( + `Start ${projectName}`, + cwd, + ['fiori', 'run'], + ['--open', startCommand], + { DEBUG: '--inspect', FIORI_TOOLS_URL_PARAMS: envUrlParam }, + runConfig + ); + launchFile.configurations.push(liveConfig); + } + + // Add mock configuration for OData V2 or V4 + if (odataVersion && [OdataVersion.v2, OdataVersion.v4].includes(odataVersion)) { + const params = `${flpAppIdWithHash ?? ''}`; + const mockCmdArgs = + isMigrator && odataVersion === OdataVersion.v2 + ? ['--open', `${testFlpSandboxMockServerHtml}${params}`] + : ['--config', './ui5-mock.yaml', '--open', `${testFlpSandboxHtml}${params}`]; + const mockConfig = createLaunchConfig( + `Start ${projectName} Mock`, + cwd, + ['fiori', 'run'], + mockCmdArgs, + { FIORI_TOOLS_URL_PARAMS: envUrlParam }, + runConfig + ); + launchFile.configurations.push(mockConfig); + } + + // Add local configuration + const shouldUseMockServer = isFioriElement && odataVersion === OdataVersion.v2 && isMigrator; + const localHtmlFile = shouldUseMockServer ? testFlpSandboxMockServerHtml : startHtmlFile; + const startLocalCommand = `${localHtmlFile}${ + migratorMockIntent ? `#${migratorMockIntent.replace('#', '')}` : flpAppIdWithHash + }`; + const localConfig = createLaunchConfig( + `Start ${projectName} Local`, + cwd, + ['fiori', 'run'], + ['--config', './ui5-local.yaml', '--open', startLocalCommand], + { FIORI_TOOLS_URL_PARAMS: envUrlParam }, + runConfig + ); + launchFile.configurations.push(localConfig); + + return launchFile; +} diff --git a/packages/launch-config/src/debug-config/helpers.ts b/packages/launch-config/src/debug-config/helpers.ts new file mode 100644 index 0000000000..0b72de8501 --- /dev/null +++ b/packages/launch-config/src/debug-config/helpers.ts @@ -0,0 +1,79 @@ +import { posix, basename, dirname, join } from 'path'; +import type { WorkspaceHandlerInfo } from '../types'; + +/** + * Retrieves the file system path to the `launch.json` file within the first opened folder. + * + * @param {Array} workspaceFolders - An array of workspace folders, provided by VS Code's API. + * @param {object} workspaceFolders[].uri - The URI object representing the folder. + * @param {string} workspaceFolders[].uri.fsPath - The file system path of the folder. + * @returns {string | undefined} The file system path to the `launch.json` file in the first opened workspace folder, + * or `undefined` if no workspace are available. + */ +export function getLaunchJsonPath(workspaceFolders: any): string | undefined { + if (workspaceFolders && workspaceFolders.length > 0) { + return workspaceFolders[0].uri.fsPath; + } + return undefined; +} + +/** + * Formats cwd by appending the provided path to the workspace folder path. If no path is provided, it returns the workspace folder path. + * + * @param {string} [path] - An optional path (project name or nested path) to append to the workspace folder path. + * @returns {string} The formatted cwd string including the workspace folder and the provided path. + * @example + * // Returns "${workspaceFolder}/myProject" + * formatCwd('myProject'); + * @example + * // Returns "${workspaceFolder}/nested/path" + * formatCwd('nested/path'); + * @example + * // Returns "${workspaceFolder}" + * formatCwd(); + */ +export function formatCwd(path?: string): string { + const formattedPath = path ? posix.sep + path : ''; + return `\${workspaceFolder}${formattedPath}`; +} + +/** + * Checks whether a given folder is part of the current workspace in VS Code. + * + * @param {string} selectedFolder - The file system path of the folder to check. + * @param {any} workspace - The VS Code API workspace object, used to access workspace information. + * @returns {boolean} - Returns `true` if the folder is in the workspace, + * `false` if not, or `undefined` if no workspace is defined or accessible. + */ +export function isFolderInWorkspace(selectedFolder: string, workspace: any): boolean | undefined { + const { workspaceFile, workspaceFolders } = workspace; + if (!workspaceFile && !workspaceFolders) { + return undefined; + } + if (workspaceFolders) { + return workspaceFolders.some( + (folder: any) => folder.uri.fsPath && selectedFolder.toLowerCase().includes(folder.uri.fsPath.toLowerCase()) + ); + } + return false; +} + +/** + * Creates a launch configuration for applications not included in the current workspace. + * This function generates the cwd comman, the path to the launch.json file, + * and optionally provides a URI for updating workspace folders if the environment is not BAS. + * + * @param {string} projectPath - The full path of the project for which the launch configuration is being created. + * @param isAppStudio - A boolean indicating whether the current environment is BAS. + * @param {any} vscode - An instance of the VSCode API. + * @returns {WorkspaceHandlerInfo} - An object containing the cwd, launch.json path, and optionally, the URI for updating workspace folders. + */ +export function handleAppsNotInWorkspace(projectPath: string, isAppStudio: boolean, vscode: any): WorkspaceHandlerInfo { + const projectName = basename(projectPath); + const launchJsonPath = join(dirname(projectPath), projectName); + return { + cwd: formatCwd(), + launchJsonPath, + workspaceFolderUri: !isAppStudio ? vscode.Uri?.file(launchJsonPath) : undefined + }; +} diff --git a/packages/launch-config/src/debug-config/workspaceManager.ts b/packages/launch-config/src/debug-config/workspaceManager.ts new file mode 100644 index 0000000000..6dd8d0b17a --- /dev/null +++ b/packages/launch-config/src/debug-config/workspaceManager.ts @@ -0,0 +1,119 @@ +import { dirname, join, relative, basename } from 'path'; +import type { DebugOptions, WorkspaceHandlerInfo } from '../types'; +import { formatCwd, getLaunchJsonPath, isFolderInWorkspace, handleAppsNotInWorkspace } from './helpers'; + +/** + * Handles the case where an unsaved workspace is open and the user creates an app in a folder outside of the workspace. + * This function updates the paths to reflect where the (possibly nested) target folder is located inside the workspace. + * + * @param {string} projectPath - The project's folder path including project name. + * @param {any} vscode - The VS Code API object. + * @returns {WorkspaceHandlerInfo} An object containing the path to the `launch.json` configuration file and the cwd for the launch configuration. + */ +export function handleUnsavedWorkspace(projectPath: string, vscode: any): WorkspaceHandlerInfo { + const workspace = vscode.workspace; + const wsFolder = workspace.getWorkspaceFolder(vscode.Uri.file(projectPath))?.uri?.fsPath; + const nestedFolder = relative(wsFolder ?? projectPath, projectPath); + return { + launchJsonPath: join(wsFolder ?? projectPath), + cwd: formatCwd(nestedFolder) + }; +} + +/** + * Handles the case where a previously saved workspace is open, and the user creates an app within or outside the workspace. + * The function determines whether the project is inside the workspace and updates the launch configurations accordingly. + * + * @param {string} projectPath - The project's path including project name. + * @param {string} projectName - The name of the project. + * @param {string} targetFolder - The directory in which the project's files are located. + * @param isAppStudio - A boolean indicating whether the current environment is BAS. + * @param {any} vscode - The VS Code API object. + * @returns {WorkspaceHandlerInfo} An object containing the path to the `launch.json` configuration file and the cwd for the launch configuration. + */ +export function handleSavedWorkspace( + projectPath: string, + projectName: string, + targetFolder: string, + isAppStudio: boolean, + vscode: any +): WorkspaceHandlerInfo { + const workspace = vscode.workspace; + if (!isFolderInWorkspace(projectPath, workspace)) { + return handleAppsNotInWorkspace(projectPath, isAppStudio, vscode); + } + const launchJsonPath = getLaunchJsonPath(workspace.workspaceFolders) ?? targetFolder; + return { + launchJsonPath, + cwd: formatCwd(projectName) + }; +} + +/** + * Handles the case where a folder is open in VS Code, but no workspace file is associated with it. + * + * @param {string} projectPath - The project's path including project name. + * @param {string} targetFolder - The directory in which the project's files are located. + * @param isAppStudio - A boolean indicating whether the current environment is BAS. + * @param {any} vscode - The VS Code API object. + * @returns {WorkspaceHandlerInfo} An object containing the path to the `launch.json` configuration file and the cwd for the launch configuration. + */ +export function handleOpenFolderButNoWorkspaceFile( + projectPath: string, + targetFolder: string, + isAppStudio: boolean, + vscode: any +): WorkspaceHandlerInfo { + const workspace = vscode.workspace; + if (!isFolderInWorkspace(projectPath, workspace)) { + return handleAppsNotInWorkspace(projectPath, isAppStudio, vscode); + } + // The user has chosen to generate the app in a folder or a nested folder. + const wsFolder = workspace.getWorkspaceFolder(vscode.Uri.file(projectPath))?.uri?.fsPath; + const nestedFolder = relative(wsFolder ?? projectPath, projectPath); + const launchJsonPath = getLaunchJsonPath(workspace.workspaceFolders) ?? targetFolder; + return { + launchJsonPath, + cwd: formatCwd(nestedFolder) + }; +} + +/** + * Manages the configuration of the debug workspace based on the provided options. + * This function handles different scenarios depending on whether a workspace is open, + * whether the project is inside or outside of a workspace, and other factors. + * + * @param {DebugOptions} options - The options used to determine how to manage the workspace configuration. + * @param {string} options.projectPath -The project's path including project name. + * @param {boolean} [options.isAppStudio] - A boolean indicating whether the current environment is BAS. + * @param {boolean} [options.writeToAppOnly] - If true, write the launch configuration directly to the app folder, ignoring workspace settings. + * @param {any} options.vscode - The VS Code API object. + * @returns {WorkspaceHandlerInfo} An object containing the path to the `launch.json` configuration file, the cwd command, workspaceFolderUri if provided will enable reload. + */ +export function handleWorkspaceConfig(options: DebugOptions): WorkspaceHandlerInfo { + const { projectPath, isAppStudio = false, writeToAppOnly = false, vscode } = options; + + const projectName = basename(projectPath); + const targetFolder = dirname(projectPath); + + // Directly handle the case where we ignore workspace settings + if (writeToAppOnly) { + return handleAppsNotInWorkspace(projectPath, isAppStudio, vscode); + } + const workspace = vscode.workspace; + const workspaceFile = workspace?.workspaceFile; + // Handles the scenario where no workspace or folder is open in VS Code. + if (!workspace) { + return handleAppsNotInWorkspace(projectPath, isAppStudio, vscode); + } + // Handle case where a folder is open, but not a workspace file + if (!workspaceFile) { + return handleOpenFolderButNoWorkspaceFile(projectPath, targetFolder, isAppStudio, vscode); + } + // Handles the case where a previously saved workspace is open + if (workspaceFile.scheme === 'file') { + return handleSavedWorkspace(projectPath, projectName, targetFolder, isAppStudio, vscode); + } + // Handles the case where an unsaved workspace is open + return handleUnsavedWorkspace(projectPath, vscode); +} diff --git a/packages/launch-config/src/i18n.ts b/packages/launch-config/src/i18n.ts new file mode 100644 index 0000000000..796e62c64f --- /dev/null +++ b/packages/launch-config/src/i18n.ts @@ -0,0 +1,37 @@ +import type { TOptions } from 'i18next'; +import i18next from 'i18next'; +import translations from './translations/launch-config.i18n.json'; + +const NS = 'launch-config'; + +/** + * Initialize i18next with the translations for this module. + */ +export async function initI18n(): Promise { + await i18next.init({ + resources: { + en: { + [NS]: translations + } + }, + lng: 'en', + fallbackLng: 'en', + defaultNS: NS, + ns: [NS] + }); +} + +/** + * Helper function facading the call to i18next. + * + * @param key i18n key + * @param options additional options + * @returns {string} localized string stored for the given key + */ +export function t(key: string, options?: TOptions): string { + return i18next.t(key, options); +} + +initI18n().catch(() => { + // Ignore any errors since the write will still work +}); diff --git a/packages/launch-config/src/index.ts b/packages/launch-config/src/index.ts index fae4f11272..d38fecfed7 100644 --- a/packages/launch-config/src/index.ts +++ b/packages/launch-config/src/index.ts @@ -1,5 +1,5 @@ export * from './types'; -export { createLaunchConfig } from './launch-config-crud/create'; +export { createLaunchConfig, configureLaunchConfig } from './launch-config-crud/create'; export { deleteLaunchConfig } from './launch-config-crud/delete'; export { convertOldLaunchConfigToFioriRun } from './launch-config-crud/modify'; export { getLaunchConfigs, getLaunchConfigByName } from './launch-config-crud/read'; diff --git a/packages/launch-config/src/launch-config-crud/create.ts b/packages/launch-config/src/launch-config-crud/create.ts index 5501d58807..5d7b10ff41 100644 --- a/packages/launch-config/src/launch-config-crud/create.ts +++ b/packages/launch-config/src/launch-config-crud/create.ts @@ -1,13 +1,20 @@ import { create as createStorage } from 'mem-fs'; import { create } from 'mem-fs-editor'; -import { join } from 'path'; +import { join, basename } from 'path'; import { DirName } from '@sap-ux/project-access'; import { LAUNCH_JSON_FILE } from '../types'; -import type { FioriOptions, LaunchJSON } from '../types'; +import type { FioriOptions, LaunchJSON, UpdateWorkspaceFolderOptions, DebugOptions } from '../types'; import type { Editor } from 'mem-fs-editor'; import { generateNewFioriLaunchConfig } from './utils'; -import { parse } from 'jsonc-parser'; import { updateLaunchJSON } from './writer'; +import { parse } from 'jsonc-parser'; +import { handleWorkspaceConfig } from '../debug-config/workspaceManager'; +import { configureLaunchJsonFile } from '../debug-config/config'; +import { getFioriToolsDirectory } from '@sap-ux/store'; +import type { Logger } from '@sap-ux/logger'; +import { DatasourceType } from '@sap-ux/odata-service-inquirer'; +import { t } from '../i18n'; +import fs from 'fs'; /** * Enhance or create the launch.json file with new launch config. @@ -44,3 +51,125 @@ export async function createLaunchConfig(rootFolder: string, fioriOptions: Fiori } return fs; } + +/** + * Writes the application info settings to the appInfo.json file. + * Adds the specified path to the latestGeneratedFiles array. + * + * @param {string} path - The project file path to add. + * @param log - The logger instance. + */ +export function writeApplicationInfoSettings(path: string, log?: Logger): void { + const appInfoFilePath: string = getFioriToolsDirectory(); + const appInfoContents = fs.existsSync(appInfoFilePath) + ? JSON.parse(fs.readFileSync(appInfoFilePath, 'utf-8')) + : { latestGeneratedFiles: [] }; + appInfoContents.latestGeneratedFiles.push(path); + try { + fs.writeFileSync(appInfoFilePath, JSON.stringify(appInfoContents, null, 2)); + } catch (error) { + log?.error(t('errorAppInfoFile', { error: error })); + } +} + +/** + * Updates the workspace folders in VSCode if the update options are provided. + * + * @param {UpdateWorkspaceFolderOptions} updateWorkspaceFolders - The options for updating workspace folders. + * @param {string} rootFolderPath - The root folder path of the project. + * @param log - The logger instance. + */ +export function updateWorkspaceFoldersIfNeeded( + updateWorkspaceFolders: UpdateWorkspaceFolderOptions | undefined, + rootFolderPath: string, + log?: Logger +): void { + if (updateWorkspaceFolders) { + const { uri, vscode, projectName } = updateWorkspaceFolders; + writeApplicationInfoSettings(rootFolderPath, log); + + if (uri && vscode) { + const currentWorkspaceFolders = vscode.workspace.workspaceFolders || []; + vscode.workspace.updateWorkspaceFolders(currentWorkspaceFolders.length, undefined, { + name: projectName, + uri + }); + } + } +} + +/** + * Creates or updates the launch.json file with the provided configurations. + * + * @param {string} rootFolderPath - The root folder path of the project. + * @param {LaunchJSON} launchJsonFile - The launch.json configuration to write. + * @param {UpdateWorkspaceFolderOptions} [updateWorkspaceFolders] - Optional workspace folder update options. + * @param log - The logger instance. + */ +export function createOrUpdateLaunchConfigJSON( + rootFolderPath: string, + launchJsonFile?: LaunchJSON, + updateWorkspaceFolders?: UpdateWorkspaceFolderOptions, + log?: Logger +): void { + try { + const launchJSONPath = join(rootFolderPath, DirName.VSCode, LAUNCH_JSON_FILE); + if (fs.existsSync(launchJSONPath)) { + const existingLaunchConfig = parse(fs.readFileSync(launchJSONPath, 'utf-8')) as LaunchJSON; + const updatedConfigurations = existingLaunchConfig.configurations.concat( + launchJsonFile?.configurations ?? [] + ); + fs.writeFileSync( + launchJSONPath, + JSON.stringify({ ...existingLaunchConfig, configurations: updatedConfigurations }, null, 4) + ); + } else { + const dotVscodePath = join(rootFolderPath, DirName.VSCode); + fs.mkdirSync(dotVscodePath, { recursive: true }); + const path = join(dotVscodePath, 'launch.json'); + fs.writeFileSync(path, JSON.stringify(launchJsonFile ?? {}, null, 4), 'utf8'); + } + } catch (error) { + log?.error(t('errorLaunchFile', { error: error })); + } + updateWorkspaceFoldersIfNeeded(updateWorkspaceFolders, rootFolderPath, log); +} + +/** + * Generates and creates launch configuration for the project based on debug options. + * + * @param {DebugOptions} options - The options for configuring the debug setup. + * @param log - The logger instance. + */ +export function configureLaunchConfig(options: DebugOptions, log?: Logger): void { + const { datasourceType, projectPath, vscode } = options; + if (datasourceType === DatasourceType.capProject) { + log?.info(t('startApp', { npmStart: '`npm start`', cdsRun: '`cds run --in-memory`' })); + return; + } + if (!vscode) { + return; + } + const { launchJsonPath, workspaceFolderUri, cwd } = handleWorkspaceConfig(options); + // construct launch.json file + const launchJsonFile = configureLaunchJsonFile(cwd, options); + // update workspace folders if workspaceFolderUri is available + const updateWorkspaceFolders = workspaceFolderUri + ? { + uri: workspaceFolderUri, + projectName: basename(options.projectPath), + vscode + } + : undefined; + + createOrUpdateLaunchConfigJSON(launchJsonPath, launchJsonFile, updateWorkspaceFolders, log); + + const npmCommand = datasourceType === DatasourceType.metadataFile ? 'run start-mock' : 'start'; + const projectName = basename(projectPath); + log?.info( + t('startServerMessage', { + folder: projectName, + npmCommand + }) + ); +} diff --git a/packages/launch-config/src/launch-config-crud/utils.ts b/packages/launch-config/src/launch-config-crud/utils.ts index 8534779d18..06a61bebf3 100644 --- a/packages/launch-config/src/launch-config-crud/utils.ts +++ b/packages/launch-config/src/launch-config-crud/utils.ts @@ -79,7 +79,7 @@ export function mergeArgs(newArgs: string[] | undefined, oldArgs: string[] | und * @param env - environment variables for the application. * @returns launch config object. */ -function getLaunchConfig( +export function getLaunchConfig( name: string, cwd: string, runtimeArgs: string[], @@ -88,15 +88,15 @@ function getLaunchConfig( ): LaunchConfig { return { name, - cwd, - runtimeArgs, type: 'node', request: 'launch', + cwd, runtimeExecutable: 'npx', - args, // default arguments windows: { runtimeExecutable: `npx.cmd` }, + runtimeArgs, + args, // default arguments console: 'internalConsole', internalConsoleOptions: 'openOnSessionStart', outputCapture: 'std', diff --git a/packages/launch-config/src/translations/launch-config.i18n.json b/packages/launch-config/src/translations/launch-config.i18n.json new file mode 100644 index 0000000000..21f182dd92 --- /dev/null +++ b/packages/launch-config/src/translations/launch-config.i18n.json @@ -0,0 +1,10 @@ +{ + "info": { + "startApp": "To start the application, type {{npmStart}} or {{cdsRun}}", + "startServerMessage": "To start the server, launch a terminal and browse to the {{folder}} folder and type npm {{npmCommand}}" + }, + "error": { + "errorLaunchFile": "Error in generating debug launch.json: {{error}}", + "appInfoFilePath": "Error in generating appInfo.json: {{error}}" + } +} diff --git a/packages/launch-config/src/types/types.ts b/packages/launch-config/src/types/types.ts index 8766945d00..2498ecea15 100644 --- a/packages/launch-config/src/types/types.ts +++ b/packages/launch-config/src/types/types.ts @@ -1,5 +1,6 @@ import type { ODataVersion } from '@sap-ux/project-access'; import type { FioriToolsProxyConfigBackend } from '@sap-ux/ui5-config'; +import type { OdataVersion, DatasourceType } from '@sap-ux/odata-service-inquirer'; export enum Arguments { FrameworkVersion = '--framework-version', @@ -46,14 +47,69 @@ export interface LaunchConfig { } export interface LaunchConfigEnv { - 'run.config': string; FIORI_TOOLS_UI5_VERSION?: string; FIORI_TOOLS_UI5_URI?: string; FIORI_TOOLS_BACKEND_CONFIG?: string; FIORI_TOOLS_URL_PARAMS?: string; + 'run.config'?: string; + DEBUG?: string; } export interface LaunchConfigInfo { launchConfigs: LaunchConfig[]; filePath: string; } + +/** + * Configuration options for debugging launch configurations. + */ +export interface DebugOptions { + /** Path to the project directory. */ + projectPath: string; + /** Type of the data source used in the project. */ + datasourceType: DatasourceType; + /** SAP client parameter for the connection. */ + sapClientParam: string; + /** FLP application ID. */ + flpAppId: string; + /** Indicates if the FLP sandbox environment is available. */ + flpSandboxAvailable: boolean; + /** Version of the OData service. */ + odataVersion?: OdataVersion; + /** Indicates if the project is a Fiori Element. */ + isFioriElement?: boolean; + /** Intent parameter for the migrator mock. */ + migratorMockIntent?: string; + /** Indicates if the project is a migrator. */ + isMigrator?: boolean; + /** Indicates if the environment is SAP App Studio. */ + isAppStudio?: boolean; + /** If true, write to the app only. */ + writeToAppOnly?: boolean; + /** Reference to the VS Code instance. */ + vscode?: any; +} + +/** + * Options for updating the workspace folder. + */ +export interface UpdateWorkspaceFolderOptions { + /** Name of the project. */ + projectName: string; + /** Reference to the VS Code instance. */ + vscode: any; + /** URI of the workspace folder. */ + uri?: string; +} + +/** + * Information related to the workspace handler. + */ +export interface WorkspaceHandlerInfo { + /** Path to the launch.json file in the workspace. */ + launchJsonPath: string; + /** Current working directory of the workspace. */ + cwd: string; + /** URI of the workspace folder. */ + workspaceFolderUri?: string; +} diff --git a/packages/launch-config/test/debug-config/config.test.ts b/packages/launch-config/test/debug-config/config.test.ts new file mode 100644 index 0000000000..7a0b31841b --- /dev/null +++ b/packages/launch-config/test/debug-config/config.test.ts @@ -0,0 +1,166 @@ +import { configureLaunchJsonFile } from '../../src/debug-config/config'; +import type { DebugOptions, LaunchConfig, LaunchJSON } from '../../src/types'; +import path from 'path'; +import { DatasourceType, OdataVersion } from '@sap-ux/odata-service-inquirer'; +import { FIORI_TOOLS_LAUNCH_CONFIG_HANDLER_ID } from '../../src/types'; + +const projectName = 'project1'; +const cwd = `\${workspaceFolder}`; + +// Base configuration template +const baseConfigurationObj: Partial = { + type: 'node', + request: 'launch', + runtimeExecutable: 'npx', + cwd, + windows: { runtimeExecutable: 'npx.cmd' }, + runtimeArgs: ['fiori', 'run'], + console: 'internalConsole', + internalConsoleOptions: 'openOnSessionStart', + outputCapture: 'std', + env: { FIORI_TOOLS_URL_PARAMS: 'sap-ui-xx-viewCache=false' } +}; + +const liveConfigurationObj = { + ...baseConfigurationObj, + name: 'Start project1', + env: { ...baseConfigurationObj.env, DEBUG: '--inspect' }, + args: ['--open', 'test/flpSandbox.html#project1-tile'] +}; + +const mockConfigurationObj = { + ...baseConfigurationObj, + name: 'Start project1 Mock', + args: ['--config', './ui5-mock.yaml', '--open', 'test/flpSandbox.html#project1-tile'] +}; + +const localConfigurationObj = { + ...baseConfigurationObj, + name: 'Start project1 Local', + args: ['--config', './ui5-local.yaml', '--open', 'test/flpSandbox.html#project1-tile'] +}; + +// Utility function to find configuration by name +const findConfiguration = (launchFile: LaunchJSON, name: string) => + launchFile.configurations.find((item) => item.name === name); + +describe('debug config tests', () => { + let configOptions: DebugOptions; + const vscodeMock = { + workspace: { + workspaceFolders: [{ uri: { fsPath: '' } }], + workspaceFile: undefined, + getWorkspaceFolder: undefined + } + }; + beforeEach(() => { + configOptions = { + vscode: vscodeMock, + projectPath: path.join(__dirname, projectName), + odataVersion: OdataVersion.v2, + sapClientParam: '', + flpAppId: 'project1-tile', + isFioriElement: true, + flpSandboxAvailable: true, + datasourceType: DatasourceType.odataServiceUrl + }; + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.resetAllMocks(); + }); + + it('Should return the correct configuration for OData v2', () => { + const launchFile = configureLaunchJsonFile(cwd, configOptions); + expect(launchFile.configurations.length).toBe(3); + + expect(findConfiguration(launchFile, `Start ${projectName}`)).toEqual(liveConfigurationObj); + expect(findConfiguration(launchFile, `Start ${projectName} Mock`)).toEqual(mockConfigurationObj); + expect(findConfiguration(launchFile, `Start ${projectName} Local`)).toEqual(localConfigurationObj); + }); + + it('Should return the correct configuration for OData v4', () => { + configOptions.odataVersion = OdataVersion.v4; + const launchFile = configureLaunchJsonFile(cwd, configOptions); + expect(launchFile.configurations.length).toBe(3); + + expect(findConfiguration(launchFile, `Start ${projectName}`)).toEqual(liveConfigurationObj); + expect(findConfiguration(launchFile, `Start ${projectName} Mock`)).toEqual(mockConfigurationObj); + expect(findConfiguration(launchFile, `Start ${projectName} Local`)).toEqual(localConfigurationObj); + }); + + it('Should return correct configuration for local metadata', () => { + configOptions.datasourceType = DatasourceType.metadataFile; + const launchFile = configureLaunchJsonFile(cwd, configOptions); + expect(launchFile.configurations.length).toBe(2); + + expect(findConfiguration(launchFile, `Start ${projectName}`)).toBeUndefined(); + expect(findConfiguration(launchFile, `Start ${projectName} Mock`)).toEqual(mockConfigurationObj); + expect(findConfiguration(launchFile, `Start ${projectName} Local`)).toEqual(localConfigurationObj); + }); + + it('Should return correct configuration when project is being migrated', () => { + configOptions.isMigrator = true; + const launchFile = configureLaunchJsonFile(cwd, configOptions); + const mockConfigWithMigrator = { + ...mockConfigurationObj, + args: ['--open', 'test/flpSandboxMockServer.html#project1-tile'] + }; + expect(findConfiguration(launchFile, `Start ${projectName} Mock`)).toEqual(mockConfigWithMigrator); + }); + + it('Should return correct configuration when project is not a fiori element, no flp Sandbox Available & no flp app id', () => { + configOptions.isFioriElement = false; + configOptions.flpSandboxAvailable = false; + configOptions.flpAppId = ''; + const launchFile = configureLaunchJsonFile(cwd, configOptions); + const localConfig = { + ...localConfigurationObj, + args: ['--config', './ui5-local.yaml', '--open', 'index.html'] + }; + expect(findConfiguration(launchFile, `Start ${projectName} Local`)).toEqual(localConfig); + }); + + it('Should return correct configuration when migrator mock intent is provided', () => { + configOptions.migratorMockIntent = 'flpSandboxMockFlpIntent'; + const launchFile = configureLaunchJsonFile(cwd, configOptions); + const localConfig = { + ...localConfigurationObj, + args: ['--config', './ui5-local.yaml', '--open', 'test/flpSandbox.html#flpSandboxMockFlpIntent'] + }; + expect(findConfiguration(launchFile, `Start ${projectName} Local`)).toEqual(localConfig); + }); + + it('Should return correct configuration on BAS and sapClientParam is available', () => { + configOptions.odataVersion = OdataVersion.v2; + configOptions.datasourceType = DatasourceType.odataServiceUrl; + configOptions.sapClientParam = 'sapClientParam'; + configOptions.isAppStudio = true; + + const launchFile = configureLaunchJsonFile(cwd, configOptions); + expect(launchFile.configurations.length).toBe(3); + + const projectPath = path.join(__dirname, 'project1'); + const expectedRunConfig = JSON.stringify({ + handlerId: FIORI_TOOLS_LAUNCH_CONFIG_HANDLER_ID, + runnableId: projectPath + }); + const expectedEnv = { + FIORI_TOOLS_URL_PARAMS: 'sapClientParam&sap-ui-xx-viewCache=false', + 'run.config': expectedRunConfig + }; + + const liveConfigWithRunConfig = { + ...liveConfigurationObj, + env: { ...liveConfigurationObj.env, ...expectedEnv } + }; + expect(liveConfigWithRunConfig).toEqual(findConfiguration(launchFile, `Start ${projectName}`)); + + const mockConfigWithRunConfig = { ...mockConfigurationObj, env: expectedEnv }; + expect(mockConfigWithRunConfig).toEqual(findConfiguration(launchFile, `Start ${projectName} Mock`)); + + const localConfigWithRunConfig = { ...localConfigurationObj, env: expectedEnv }; + expect(localConfigWithRunConfig).toEqual(findConfiguration(launchFile, `Start ${projectName} Local`)); + }); +}); diff --git a/packages/launch-config/test/debug-config/configureLaunchConfig.test.ts b/packages/launch-config/test/debug-config/configureLaunchConfig.test.ts new file mode 100644 index 0000000000..e03290504b --- /dev/null +++ b/packages/launch-config/test/debug-config/configureLaunchConfig.test.ts @@ -0,0 +1,230 @@ +import { join } from 'path'; +import { handleWorkspaceConfig } from '../../src/debug-config/workspaceManager'; +import type { DebugOptions, UpdateWorkspaceFolderOptions, LaunchJSON } from '../../src/types'; +import { LAUNCH_JSON_FILE } from '../../src/types'; +import { + writeApplicationInfoSettings, + updateWorkspaceFoldersIfNeeded, + createOrUpdateLaunchConfigJSON, + configureLaunchConfig +} from '../../src/launch-config-crud/create'; +import { t } from '../../src/i18n'; +import { DatasourceType } from '@sap-ux/odata-service-inquirer'; +import type { Editor } from 'mem-fs-editor'; +import { DirName } from '@sap-ux/project-access'; +import { getFioriToolsDirectory } from '@sap-ux/store'; +import type { Logger } from '@sap-ux/logger'; +import { existsSync, mkdir } from 'fs'; +import fs from 'fs'; + +// Mock dependencies +jest.mock('mem-fs'); +jest.mock('mem-fs-editor'); +jest.mock('jsonc-parser', () => ({ + parse: jest.fn().mockReturnValue({ + configurations: [{ name: 'Existing Config', type: 'node' }] + }) +})); +jest.mock('../../src/debug-config/workspaceManager', () => ({ + handleWorkspaceConfig: jest.fn() +})); +jest.mock('../../src/debug-config/config', () => ({ + configureLaunchJsonFile: jest.fn(), + writeApplicationInfoSettings: jest.requireActual('../../src/debug-config/config').writeApplicationInfoSettings +})); +const mockLog = { + error: jest.fn(), + info: jest.fn() +} as unknown as Logger; + +const mockEditor = { + exists: jest.fn().mockReturnValue(false), + read: jest.fn(), + write: jest.fn() +} as unknown as Editor; +const mockPath = '/mock/project/path'; +// Define a variable to control the behavior of writeFileSync +let writeFileSyncMockBehavior: 'success' | 'error'; + +jest.mock('fs', () => ({ + ...jest.requireActual('fs'), + //mkdirSync: jest.fn(), + existsSync: jest.fn().mockReturnValue(true), + readFileSync: jest.fn((path: string, encoding: string) => { + // Mock different behaviors based on the path + if (path) { + return JSON.stringify({ latestGeneratedFiles: [] }); // Mock file content + } + throw new Error('Simulated read error'); + }), + writeFileSync: jest.fn().mockImplementation(() => { + if (writeFileSyncMockBehavior === 'error') { + throw new Error('Simulated write error'); // Throw an error for `writeFileSync` when behavior is 'error' + } + // Otherwise, assume it succeeds + }) +})); + +// Function to set the behavior for writeFileSync +const setWriteFileSyncBehavior = (behavior: 'success' | 'error') => { + writeFileSyncMockBehavior = behavior; + // Reinitialize the mock to apply the new behavior + fs.writeFileSync = jest.fn().mockImplementation(() => { + if (writeFileSyncMockBehavior === 'error') { + throw new Error(); + } + }); +}; + +describe('Config Functions', () => { + const launchJson = { + configurations: [{ name: 'New Config', type: 'node' }] + } as LaunchJSON; + + const existingLaunchJson = { + configurations: [{ name: 'Existing Config', type: 'node' }] + } as LaunchJSON; + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('writeApplicationInfoSettings', () => { + it('should write application info settings to appInfo.json', () => { + writeApplicationInfoSettings(mockPath, mockLog); + expect(fs.writeFileSync).toHaveBeenCalledWith( + getFioriToolsDirectory(), + JSON.stringify({ latestGeneratedFiles: [mockPath] }, null, 2) + ); + }); + + it('should handle error while writing to appInfo.json', () => { + setWriteFileSyncBehavior('error'); + writeApplicationInfoSettings(mockPath, mockLog); + expect(mockLog.error).toHaveBeenCalledWith(t('errorAppInfoFile')); + }); + }); + + describe('updateWorkspaceFoldersIfNeeded', () => { + it('should update workspace folders if options are provided', () => { + const updateOptions = { + uri: '/mock/uri', + vscode: { + workspace: { + workspaceFolders: [], + updateWorkspaceFolders: jest.fn() + } + }, + projectName: 'Test Project' + } as UpdateWorkspaceFolderOptions; + updateWorkspaceFoldersIfNeeded(updateOptions, '/root/folder/path', mockLog); + expect(updateOptions.vscode.workspace.updateWorkspaceFolders).toHaveBeenCalledWith(0, undefined, { + name: 'Test Project', + uri: '/mock/uri' + }); + }); + + it('should not update workspace folders if no options are provided', () => { + const updateOptions: UpdateWorkspaceFolderOptions | undefined = undefined; + updateWorkspaceFoldersIfNeeded(updateOptions, '/root/folder/path', mockLog); + // No updateWorkspaceFolders call expected hence no app info json written + expect(fs.writeFileSync).not.toHaveBeenCalled(); + }); + }); + + describe('createOrUpdateLaunchConfigJSON', () => { + it('should create a new launch.json file if it does not exist', () => { + const rootFolderPath = '/root/folder'; + fs.mkdirSync = jest.fn().mockReturnValue(rootFolderPath); + fs.existsSync = jest.fn().mockReturnValue(false); + createOrUpdateLaunchConfigJSON(rootFolderPath, launchJson, undefined, mockLog); + expect(fs.writeFileSync).toHaveBeenCalledWith( + join(rootFolderPath, DirName.VSCode, LAUNCH_JSON_FILE), + JSON.stringify(launchJson, null, 4), + 'utf8' + ); + }); + + it('should update an existing launch.json file', () => { + const rootFolderPath = '/root/folder'; + fs.existsSync = jest.fn().mockReturnValue(true); + createOrUpdateLaunchConfigJSON(rootFolderPath, launchJson, undefined, mockLog); + + expect(fs.writeFileSync).toHaveBeenCalledWith( + join(rootFolderPath, DirName.VSCode, LAUNCH_JSON_FILE), + JSON.stringify( + { + configurations: [...existingLaunchJson.configurations, ...launchJson.configurations] + }, + null, + 4 + ) + ); + }); + + it('should handle errors while writing launch.json file', () => { + const rootFolderPath = '/root/folder'; + + setWriteFileSyncBehavior('error'); + createOrUpdateLaunchConfigJSON(rootFolderPath, launchJson, undefined, mockLog); + expect(mockLog.error).toHaveBeenCalledWith(t('errorLaunchFile')); + }); + }); + + describe('configureLaunchConfig', () => { + it('should configure launch config and update workspace folders', () => { + const mockOptions = { + projectPath: '/mock/project/path', + writeToAppOnly: true, + vscode: { + workspace: { + workspaceFolders: [], + updateWorkspaceFolders: jest.fn() + } + } as any + } as DebugOptions; + + const mockLog = { + info: jest.fn(), + error: jest.fn() + } as unknown as Logger; + + // Mock handleWorkspaceConfig to return a specific launchJsonPath and cwd + (handleWorkspaceConfig as jest.Mock).mockReturnValue({ + launchJsonPath: '/mock/launch.json', + cwd: '${workspaceFolder}/path', + workspaceFolderUri: '/mock/launch.json' + }); + + // Call the function under test + configureLaunchConfig(mockOptions, mockLog); + + // Expectations to ensure that workspace folders are updated correctly + expect(mockOptions.vscode.workspace.updateWorkspaceFolders).toHaveBeenCalledWith(0, undefined, { + uri: '/mock/launch.json', + name: 'path' + }); + }); + + it('should log startApp message when datasourceType is capProject', () => { + const options = { + datasourceType: DatasourceType.capProject, + projectPath: 'some/path' + } as DebugOptions; + configureLaunchConfig(options, mockLog); + expect(mockLog.info).toHaveBeenCalledWith( + t('startApp', { npmStart: '`npm start`', cdsRun: '`cds run --in-memory`' }) + ); + }); + + it('Should not run in Yeoman CLI or if vscode not found', () => { + const options = { + datasourceType: DatasourceType.metadataFile, + projectPath: 'some/path', + vscode: false + } as DebugOptions; + configureLaunchConfig(options, mockLog); + expect(mockLog.info).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/launch-config/test/debug-config/helpers.test.ts b/packages/launch-config/test/debug-config/helpers.test.ts new file mode 100644 index 0000000000..2445185ace --- /dev/null +++ b/packages/launch-config/test/debug-config/helpers.test.ts @@ -0,0 +1,130 @@ +import { + getLaunchJsonPath, + formatCwd, + isFolderInWorkspace, + handleAppsNotInWorkspace +} from '../../src/debug-config/helpers'; +import path from 'path'; + +// Mock the vscode object +const mockVscode = { + Uri: { + file: jest.fn((f: string) => ({ path: f })) + } +}; + +describe('launchConfig Unit Tests', () => { + const isAppStudio = false; + + // Test for to get path where launch json is going to be written + describe('getLaunchJsonPath', () => { + it('should return the path to launch.json in the first opened folder', () => { + const mockWorkspaceFolders = [{ uri: { fsPath: '/mock/workspace/folder1' } }]; + const result = getLaunchJsonPath(mockWorkspaceFolders); + expect(result).toBe('/mock/workspace/folder1'); + }); + + it('should return undefined when there are no workspace folders', () => { + const result = getLaunchJsonPath([]); + expect(result).toBeUndefined(); + }); + + it('should return undefined when workspaceFolders is undefined', () => { + const result = getLaunchJsonPath(undefined); + expect(result).toBeUndefined(); + }); + }); + + // Test for cwd command + describe('formatCwd', () => { + it('should return "${workspaceFolder}" when no path is provided', () => { + const result = formatCwd(); + expect(result).toBe('${workspaceFolder}'); + }); + + it('should return "${workspaceFolder}/myProject" when "myProject" is provided', () => { + const result = formatCwd('myProject'); + expect(result).toBe('${workspaceFolder}/myProject'); + }); + + it('should return "${workspaceFolder}/nested/path" when "nested/path" is provided', () => { + const result = formatCwd('nested/path'); + expect(result).toBe('${workspaceFolder}/nested/path'); + }); + }); + + // Test for is folder in workspace + describe('isFolderInWorkspace', () => { + it('should return true if the selected folder is part of the workspace', () => { + const mockWorkspace = { + workspaceFolders: [ + { uri: { fsPath: '/mock/workspace/folder1' } }, + { uri: { fsPath: '/mock/workspace/folder2' } } + ] + }; + const result = isFolderInWorkspace('/mock/workspace/folder1/subfolder', mockWorkspace); + expect(result).toBe(true); + }); + + it('should return false if the selected folder is not part of the workspace', () => { + const mockWorkspace = { + workspaceFolders: [ + { uri: { fsPath: '/mock/workspace/folder1' } }, + { uri: { fsPath: '/mock/workspace/folder2' } } + ] + }; + const result = isFolderInWorkspace('/another/folder', mockWorkspace); + expect(result).toBe(false); + }); + + it('should return undefined if workspace is not defined or accessible', () => { + const result = isFolderInWorkspace('/mock/workspace/folder1', {}); + expect(result).toBeUndefined(); + }); + + it('should return false if workspaceFile is defined but no workspaceFolders', () => { + const mockWorkspace = { + workspaceFile: { scheme: 'file' }, + workspaceFolders: undefined + }; + const result = isFolderInWorkspace('/mock/workspace/folder1', mockWorkspace); + expect(result).toBe(false); + }); + }); + + // Test for create launch config outside workspace + describe('handleAppsNotInWorkspace', () => { + it('should create a launch config for non-workspace apps', () => { + const mockProjectPath = '/mock/project/path'; + const result = handleAppsNotInWorkspace(mockProjectPath, isAppStudio, mockVscode); + expect(result.cwd).toBe('${workspaceFolder}'); + expect(result.launchJsonPath).toBe( + path.join(path.dirname(mockProjectPath), path.basename(mockProjectPath)) + ); + expect(result.workspaceFolderUri).toEqual({ + path: path.join(path.dirname(mockProjectPath), path.basename(mockProjectPath)) + }); + }); + + it('should handle cases where vscode.Uri is not available', () => { + const mockProjectPath = '/mock/project/path'; + const result = handleAppsNotInWorkspace(mockProjectPath, isAppStudio, {}); + expect(result.cwd).toBe('${workspaceFolder}'); + expect(result.launchJsonPath).toBe( + path.join(path.dirname(mockProjectPath), path.basename(mockProjectPath)) + ); + expect(result.workspaceFolderUri).toBeUndefined(); + }); + + it('should handle cases where isAppStudio is true', () => { + const mockProjectPath = '/mock/project/path', + isAppStudio = true; + const result = handleAppsNotInWorkspace(mockProjectPath, isAppStudio, mockVscode); + expect(result.cwd).toBe('${workspaceFolder}'); + expect(result.launchJsonPath).toBe( + path.join(path.dirname(mockProjectPath), path.basename(mockProjectPath)) + ); + expect(result.workspaceFolderUri).toBeUndefined(); + }); + }); +}); diff --git a/packages/launch-config/test/debug-config/workspaceManager.test.ts b/packages/launch-config/test/debug-config/workspaceManager.test.ts new file mode 100644 index 0000000000..03888f0df0 --- /dev/null +++ b/packages/launch-config/test/debug-config/workspaceManager.test.ts @@ -0,0 +1,285 @@ +import { + handleWorkspaceConfig, + handleUnsavedWorkspace, + handleSavedWorkspace, + handleOpenFolderButNoWorkspaceFile +} from '../../src/debug-config/workspaceManager'; +import { + formatCwd, + getLaunchJsonPath, + isFolderInWorkspace, + handleAppsNotInWorkspace +} from '../../src/debug-config/helpers'; +import type { DebugOptions } from '../../src/types'; +import path from 'path'; + +// Mock the helpers +jest.mock('../../src/debug-config/helpers', () => ({ + formatCwd: jest.fn(), + handleAppsNotInWorkspace: jest.fn(), + getLaunchJsonPath: jest.fn(), + isFolderInWorkspace: jest.fn() +})); + +// Mock the path module +jest.mock('path', () => ({ + ...jest.requireActual('path'), + relative: jest.fn() +})); + +describe('launchConfig Unit Tests', () => { + const isAppStudio = false; + const mockVscode = { + workspace: { + getWorkspaceFolder: jest.fn(), + workspaceFolders: [], + workspaceFile: { scheme: 'file' } + }, + Uri: { + file: jest.fn((f: string) => ({ fsPath: f })) + } + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('handleUnsavedWorkspace', () => { + it('should update paths for nested folders inside a workspace', () => { + const mockProjectPath = '/mock/project/nestedFolder'; + const mockWsFolder = '/mock/workspace/folder'; + const mockNestedFolder = 'nestedFolder'; + mockVscode.workspace.getWorkspaceFolder.mockReturnValue({ uri: { fsPath: mockWsFolder } }); + (path.relative as jest.Mock).mockReturnValue(mockNestedFolder); + (formatCwd as jest.Mock).mockReturnValue('${workspaceFolder}/nestedFolder'); + + const result = handleUnsavedWorkspace(mockProjectPath, mockVscode); + expect(result).toEqual({ + launchJsonPath: mockWsFolder, + cwd: '${workspaceFolder}/nestedFolder' + }); + }); + }); + + describe('handleSavedWorkspace', () => { + it('should handle projects inside the workspace', () => { + const mockProjectPath = '/mock/project/path'; + const mockProjectName = 'project'; + const mockTargetFolder = '/target/folder'; + (isFolderInWorkspace as jest.Mock).mockReturnValue(true); + (formatCwd as jest.Mock).mockReturnValue('${workspaceFolder}/project'); + (getLaunchJsonPath as jest.Mock).mockReturnValue(mockTargetFolder); + + const result = handleSavedWorkspace( + mockProjectPath, + mockProjectName, + mockTargetFolder, + isAppStudio, + mockVscode + ); + expect(result).toEqual({ + launchJsonPath: mockTargetFolder, + cwd: '${workspaceFolder}/project' + }); + }); + + it('should create a launch config for non-workspace apps', () => { + const mockProjectPath = '/mock/project/path'; + const mockProjectName = 'project'; + const mockTargetFolder = '/target/folder'; + (isFolderInWorkspace as jest.Mock).mockReturnValue(false); + (handleAppsNotInWorkspace as jest.Mock).mockReturnValue({ + launchJsonPath: mockProjectPath, + cwd: '${workspaceFolder}' + }); + + const result = handleSavedWorkspace( + mockProjectPath, + mockProjectName, + mockTargetFolder, + isAppStudio, + mockVscode + ); + expect(result).toEqual({ + launchJsonPath: mockProjectPath, + cwd: '${workspaceFolder}' + }); + }); + }); + + describe('handleOpenFolderButNoWorkspaceFile', () => { + it('should create a launch config for non-workspace apps if folder is not in workspace', () => { + const mockProjectPath = '/mock/project/path'; + const mockTargetFolder = '/target/folder'; + (isFolderInWorkspace as jest.Mock).mockReturnValue(false); + (handleAppsNotInWorkspace as jest.Mock).mockReturnValue({ + launchJsonPath: mockProjectPath, + cwd: '${workspaceFolder}' + }); + + const result = handleOpenFolderButNoWorkspaceFile( + mockProjectPath, + mockTargetFolder, + isAppStudio, + mockVscode + ); + expect(result).toEqual({ + launchJsonPath: mockProjectPath, + cwd: '${workspaceFolder}' + }); + }); + + it('should update paths for nested folders inside an open folder', () => { + const mockProjectPath = '/mock/project/nestedFolder'; + const mockTargetFolder = '/target/folder'; + const mockWsFolder = '/mock/workspace/folder'; + const mockNestedFolder = 'nestedFolder'; + + (isFolderInWorkspace as jest.Mock).mockReturnValue(true); + mockVscode.workspace.getWorkspaceFolder.mockReturnValue({ uri: { fsPath: mockWsFolder } }); + (path.relative as jest.Mock).mockReturnValue(mockNestedFolder); + (formatCwd as jest.Mock).mockReturnValue('${workspaceFolder}/nestedFolder'); + (getLaunchJsonPath as jest.Mock).mockReturnValue(mockTargetFolder); + + const result = handleOpenFolderButNoWorkspaceFile( + mockProjectPath, + mockTargetFolder, + isAppStudio, + mockVscode + ); + expect(result).toEqual({ + launchJsonPath: mockTargetFolder, + cwd: '${workspaceFolder}/nestedFolder' + }); + }); + }); + + describe('handleWorkspaceConfig', () => { + it('should handle writeToAppOnly option', () => { + const mockProjectPath = '/mock/project/path'; + const options = { + projectPath: mockProjectPath, + writeToAppOnly: true, + vscode: mockVscode + } as DebugOptions; + + (handleAppsNotInWorkspace as jest.Mock).mockReturnValue({ + launchJsonPath: mockProjectPath, + cwd: '${workspaceFolder}' + }); + + const result = handleWorkspaceConfig(options); + expect(result).toEqual({ + launchJsonPath: mockProjectPath, + cwd: '${workspaceFolder}' + }); + expect(handleAppsNotInWorkspace).toHaveBeenCalledWith(mockProjectPath, isAppStudio, mockVscode); + }); + + it('should handle open folder but no workspace file case', () => { + const mockProjectPath = '/mock/project/path'; + const mockTargetFolder = '/target/folder'; + const options = { + projectPath: mockProjectPath, + vscode: { + ...mockVscode, + workspace: { ...mockVscode.workspace, workspaceFile: undefined } + } + } as DebugOptions; + + // Set up mocks for helpers + (isFolderInWorkspace as jest.Mock).mockReturnValue(true); + (formatCwd as jest.Mock).mockReturnValue('${workspaceFolder}/path'); + (getLaunchJsonPath as jest.Mock).mockReturnValue(mockTargetFolder); + + // Call the function under test + const result = handleWorkspaceConfig(options); + + // Assertions + expect(result).toEqual({ + launchJsonPath: mockTargetFolder, + cwd: '${workspaceFolder}/path' + }); + + // Verify if handleOpenFolderButNoWorkspaceFile was called correctly indirectly + const expectedLaunchJsonPath = getLaunchJsonPath(mockVscode.workspace.workspaceFolders) ?? mockTargetFolder; + const expectedCwd = formatCwd(path.relative(mockTargetFolder, mockProjectPath)); + + expect(result.launchJsonPath).toBe(expectedLaunchJsonPath); + expect(result.cwd).toBe(expectedCwd); + }); + + it('should handle no workspace case', () => { + const mockProjectPath = '/mock/project/path'; + const options = { + projectPath: mockProjectPath, + vscode: { ...mockVscode, workspace: undefined } + } as DebugOptions; + (handleAppsNotInWorkspace as jest.Mock).mockReturnValue({ + launchJsonPath: mockProjectPath, + cwd: '${workspaceFolder}' + }); + + const result = handleWorkspaceConfig(options); + expect(result).toEqual({ + launchJsonPath: mockProjectPath, + cwd: '${workspaceFolder}' + }); + expect(handleAppsNotInWorkspace).toHaveBeenCalledWith(mockProjectPath, isAppStudio, options.vscode); + }); + + it('should handle saved workspace case', () => { + const mockProjectPath = '/mock/project/path'; + const mockTargetFolder = '/target/folder'; + const mockVscode = { + workspace: { + getWorkspaceFolder: jest.fn().mockReturnValue({ uri: { fsPath: mockTargetFolder } }), + workspaceFile: { scheme: 'file' } + } + }; + // Prepare options for the test + const options = { + projectPath: mockProjectPath, + vscode: mockVscode + } as DebugOptions; + // Call the function under test + const result = handleWorkspaceConfig(options); + // Assertions + expect(result).toEqual({ + launchJsonPath: mockTargetFolder, + cwd: '${workspaceFolder}/path' + }); + }); + + it('should handle unsaved workspace case', () => { + const mockProjectPath = '/mock/project/path'; + const mockTargetFolder = '/target/folder'; + const mockVscode = { + workspace: { + getWorkspaceFolder: jest.fn().mockReturnValue(undefined), + workspaceFile: { scheme: 'unknown' } + }, + Uri: { + file: jest.fn().mockReturnValue({ + uri: { + fsPath: mockTargetFolder + } + }) + } + }; + + // Prepare options for the test + const options = { + projectPath: mockProjectPath, + vscode: mockVscode + } as DebugOptions; + // Call the function under test + const result = handleWorkspaceConfig(options); + // Assertions + expect(result).toEqual({ + launchJsonPath: mockProjectPath, + cwd: '${workspaceFolder}/path' + }); + }); + }); +}); diff --git a/packages/launch-config/tsconfig.json b/packages/launch-config/tsconfig.json index 4d0934a086..d03f1afd91 100644 --- a/packages/launch-config/tsconfig.json +++ b/packages/launch-config/tsconfig.json @@ -2,7 +2,8 @@ "extends": "../../tsconfig.json", "include": [ "../../types/mem-fs-editor.d.ts", - "src" + "src", + "src/**/*.json" ], "compilerOptions": { "rootDir": "src", @@ -12,9 +13,15 @@ { "path": "../logger" }, + { + "path": "../odata-service-inquirer" + }, { "path": "../project-access" }, + { + "path": "../store" + }, { "path": "../ui5-config" }, diff --git a/packages/odata-service-inquirer/CHANGELOG.md b/packages/odata-service-inquirer/CHANGELOG.md index c4d6cbf764..4f23d672fc 100644 --- a/packages/odata-service-inquirer/CHANGELOG.md +++ b/packages/odata-service-inquirer/CHANGELOG.md @@ -1,5 +1,35 @@ # @sap-ux/odata-service-inquirer +## 0.5.36 + +### Patch Changes + +- Updated dependencies [d962ce1] + - @sap-ux/project-access@1.27.1 + - @sap-ux/axios-extension@1.16.5 + - @sap-ux/telemetry@0.5.27 + +## 0.5.35 + +### Patch Changes + +- eb958a1: Fix for annotations not retrieved by service url prompt + +## 0.5.34 + +### Patch Changes + +- 8a84adf: Fix for no services + error GA link + +## 0.5.33 + +### Patch Changes + +- Updated dependencies [df29368] + - @sap-ux/project-access@1.27.0 + - @sap-ux/axios-extension@1.16.5 + - @sap-ux/telemetry@0.5.26 + ## 0.5.32 ### Patch Changes diff --git a/packages/odata-service-inquirer/package.json b/packages/odata-service-inquirer/package.json index 6d8421940c..5ce4bad8f4 100644 --- a/packages/odata-service-inquirer/package.json +++ b/packages/odata-service-inquirer/package.json @@ -1,7 +1,7 @@ { "name": "@sap-ux/odata-service-inquirer", "description": "Prompts module that can prompt users for inputs required for odata service writing", - "version": "0.5.32", + "version": "0.5.36", "repository": { "type": "git", "url": "https://github.com/SAP/open-ux-tools.git", diff --git a/packages/odata-service-inquirer/src/error-handler/error-handler.ts b/packages/odata-service-inquirer/src/error-handler/error-handler.ts index c2cfa8405f..2764121737 100644 --- a/packages/odata-service-inquirer/src/error-handler/error-handler.ts +++ b/packages/odata-service-inquirer/src/error-handler/error-handler.ts @@ -29,6 +29,7 @@ export enum ERROR_TYPE { CERT_SELF_SIGNED_CERT_IN_CHAIN = 'CERT_SELF_SIGNED_CERT_IN_CHAIN', UNKNOWN = 'UNKNOWN', INVALID_URL = 'INVALID_URL', + TIMEOUT = 'TIMEOUT', CONNECTION = 'CONNECTION', SERVICES_UNAVAILABLE = 'SERVICES_UNAVAILABLE', // All services SERVICE_UNAVAILABLE = 'SERVICE_UNAVAILABLE', // Specific service @@ -57,6 +58,7 @@ export const ERROR_MAP: Record = { /Unable to retrieve SAP Business Accelerator Hub key/ // API Hub error msg ], [ERROR_TYPE.AUTH_TIMEOUT]: [/UAATimeoutError/], + [ERROR_TYPE.TIMEOUT]: [/Timeout/], [ERROR_TYPE.CERT]: [], // General cert error, unspecified root cause [ERROR_TYPE.CERT_UKNOWN_OR_INVALID]: [ /UNABLE_TO_GET_ISSUER_CERT/, @@ -67,7 +69,7 @@ export const ERROR_MAP: Record = { [ERROR_TYPE.CERT_SELF_SIGNED]: [/DEPTH_ZERO_SELF_SIGNED_CERT/], [ERROR_TYPE.CERT_SELF_SIGNED_CERT_IN_CHAIN]: [/SELF_SIGNED_CERT_IN_CHAIN/], [ERROR_TYPE.UNKNOWN]: [], - [ERROR_TYPE.CONNECTION]: [/ENOTFOUND/, /ECONNRESET/, /ECONNREFUSED/], + [ERROR_TYPE.CONNECTION]: [/ENOTFOUND/, /ECONNRESET/, /ECONNREFUSED/, /ConnectionError/], [ERROR_TYPE.SERVICES_UNAVAILABLE]: [], [ERROR_TYPE.SERVICE_UNAVAILABLE]: [/503/], [ERROR_TYPE.INVALID_URL]: [/Invalid URL/, /ERR_INVALID_URL/], @@ -91,7 +93,7 @@ export const ERROR_MAP: Record = { [ERROR_TYPE.NO_V4_SERVICES]: [] }; -type ValidationLinkOrString = string | ValidationLink | undefined; +type ValidationLinkOrString = string | ValidationLink; /** * Maps errors to end-user messages using some basic root cause analysis based on regex matching. @@ -124,6 +126,7 @@ export class ErrorHandler { }), [ERROR_TYPE.AUTH]: t('errors.authenticationFailed', { error }), [ERROR_TYPE.AUTH_TIMEOUT]: t('errors.authenticationTimeout'), + [ERROR_TYPE.TIMEOUT]: t('errors.timeout, { error }'), [ERROR_TYPE.INVALID_URL]: t('errors.invalidUrl'), [ERROR_TYPE.CONNECTION]: t('errors.connectionError', { error: (error as Error)?.message || JSON.stringify(error) @@ -184,7 +187,8 @@ export class ErrorHandler { [ERROR_TYPE.NOT_FOUND]: undefined, [ERROR_TYPE.ODATA_URL_NOT_FOUND]: undefined, [ERROR_TYPE.INTERNAL_SERVER_ERROR]: undefined, - [ERROR_TYPE.NO_V2_SERVICES]: undefined + [ERROR_TYPE.NO_V2_SERVICES]: undefined, + [ERROR_TYPE.TIMEOUT]: undefined }; return errorToHelp[errorType]; }; @@ -371,8 +375,8 @@ export class ErrorHandler { * @param reset optional, resets the previous error state if true * @returns An instance of @see {ValidationLink} */ - public getValidationErrorHelp(error?: any, reset = false): ValidationLinkOrString { - let errorHelp: ValidationLinkOrString; + public getValidationErrorHelp(error?: any, reset = false): ValidationLinkOrString | undefined { + let errorHelp: ValidationLinkOrString | undefined; let errorMsg: string | undefined; if (error) { const resolvedError = ErrorHandler.mapErrorToMsg(error); @@ -457,7 +461,7 @@ export class ErrorHandler { * @param errorMsg - the message to appear with the help link * @returns A validation help link or help link message */ - public static getHelpForError(errorType: ERROR_TYPE, errorMsg?: string): ValidationLinkOrString { + public static getHelpForError(errorType: ERROR_TYPE, errorMsg?: string): ValidationLinkOrString | undefined { const helpNode = ErrorHandler.getHelpNode(errorType); const mappedErrorMsg = errorMsg ?? ErrorHandler.getErrorMsgFromType(errorType); diff --git a/packages/odata-service-inquirer/src/prompts/connectionValidator.ts b/packages/odata-service-inquirer/src/prompts/connectionValidator.ts index fb4492cbb8..ea9eb12fae 100644 --- a/packages/odata-service-inquirer/src/prompts/connectionValidator.ts +++ b/packages/odata-service-inquirer/src/prompts/connectionValidator.ts @@ -68,7 +68,7 @@ export class ConnectionValidator { private _odataService: ODataService | undefined; private _serviceProvider: ServiceProvider | undefined; - private _axiosConfig: (AxiosExtensionRequestConfig & ProviderConfiguration) | undefined; + private _axiosConfig: AxiosExtensionRequestConfig & ProviderConfiguration; private _catalogV2: CatalogService | undefined; private _catalogV4: CatalogService | undefined; private _systemAuthType: SystemAuthType | undefined; @@ -82,7 +82,7 @@ export class ConnectionValidator { * * @returns the axios configuration */ - public get axiosConfig(): AxiosRequestConfig | undefined { + public get axiosConfig(): AxiosRequestConfig { return this._axiosConfig; } diff --git a/packages/odata-service-inquirer/src/prompts/datasources/sap-system/new-system/questions.ts b/packages/odata-service-inquirer/src/prompts/datasources/sap-system/new-system/questions.ts index 983dbf4b21..e2b7ff35e5 100644 --- a/packages/odata-service-inquirer/src/prompts/datasources/sap-system/new-system/questions.ts +++ b/packages/odata-service-inquirer/src/prompts/datasources/sap-system/new-system/questions.ts @@ -21,6 +21,8 @@ import { type ServiceAnswer, newSystemPromptNames } from './types'; import { getAbapOnPremQuestions } from '../abap-on-prem/questions'; import { getAbapOnBTPSystemQuestions } from '../abap-on-btp/questions'; import LoggerHelper from '../../../logger-helper'; +import { errorHandler } from '../../../prompt-helpers'; +import { ERROR_TYPE, ErrorHandler } from '../../../../error-handler/error-handler'; // New system choice value is a hard to guess string to avoid conflicts with existing system names or user named systems // since it will be used as a new system value in the system selection prompt. @@ -285,6 +287,10 @@ export function getSystemServiceQuestion( if (!connectValidator.validatedUrl) { return false; } + // if no choices are available and an error is present, return the error message + if (serviceChoices.length === 0 && errorHandler.hasError()) { + return ErrorHandler.getHelpForError(ERROR_TYPE.SERVICES_UNAVAILABLE) ?? false; + } // Dont re-request the same service details if (service && previousService?.servicePath !== service.servicePath) { previousService = service; @@ -308,6 +314,10 @@ export function getSystemServiceQuestion( throw new Error(result); } } + if (serviceChoices.length === 0 && errorHandler.hasError()) { + const noServicesError = ErrorHandler.getHelpForError(ERROR_TYPE.SERVICES_UNAVAILABLE)!.toString(); + throw new Error(noServicesError); + } return false; }, name: `${promptNamespace}:${cliServicePromptName}` diff --git a/packages/odata-service-inquirer/src/prompts/datasources/sap-system/new-system/service-helper.ts b/packages/odata-service-inquirer/src/prompts/datasources/sap-system/new-system/service-helper.ts index dbe6703efd..9ef6031532 100644 --- a/packages/odata-service-inquirer/src/prompts/datasources/sap-system/new-system/service-helper.ts +++ b/packages/odata-service-inquirer/src/prompts/datasources/sap-system/new-system/service-helper.ts @@ -1,8 +1,8 @@ import { + type CatalogService, ServiceType, + V2CatalogService, type Annotations, - type CatalogService, - type V2CatalogService, type ODataServiceInfo, type ServiceProvider, ODataVersion @@ -14,6 +14,7 @@ import type { ServiceAnswer } from './types'; import type { ConnectionValidator } from '../../../connectionValidator'; import { PromptState } from '../../../../utils'; import { OdataVersion } from '@sap-ux/odata-service-writer'; +import { errorHandler } from '../../../prompt-helpers'; // Service ids continaining these paths should not be offered as UI compatible services const nonUIServicePaths = ['/IWBEP/COMMON/']; @@ -58,6 +59,19 @@ const createServiceChoices = (serviceInfos?: ODataServiceInfo[]): ListChoiceOpti return choices.sort((a, b) => (a.name ? a.name.localeCompare(b.name ?? '') : 0)); }; +/** + * Logs the catalog reuest errors. + * + * @param requestErrors catalog request errors + */ +function logErrorsForHelp(requestErrors: Record | {}): void { + // Log the first error only + const catalogErrors = Object.values(requestErrors); + if (catalogErrors.length > 0) { + catalogErrors.forEach((error) => errorHandler.logErrorMsgs(error)); + } +} + /** * Get the service choices from the specified catalogs. * @@ -65,19 +79,31 @@ const createServiceChoices = (serviceInfos?: ODataServiceInfo[]): ListChoiceOpti * @returns service choices based on the provided catalogs */ export async function getServiceChoices(catalogs: CatalogService[]): Promise[]> { + const requestErrors: Record | {} = {}; const listServicesRequests = catalogs.map(async (catalog) => { try { return await catalog.listServices(); } catch (error) { LoggerHelper.logger.error( - `An error occurred requesting services from: ${catalog.entitySet}. Some services may not be listed.` + t('errors.serviceCatalogRequest', { + catalogRequestUri: catalog.getUri(), + entitySet: catalog.entitySet, + error + }) ); + // Save any errors for processing later as we may show more useful message to the user + Object.assign(requestErrors, { + [catalog instanceof V2CatalogService ? ODataVersion.v2 : ODataVersion.v4]: error + }); return []; } }); const serviceInfos: ODataServiceInfo[][] = await Promise.all(listServicesRequests); const flatServices = serviceInfos?.flat() ?? []; LoggerHelper.logger.debug(`Number of services available: ${flatServices.length}`); + if (flatServices.length === 0) { + logErrorsForHelp(requestErrors); + } return createServiceChoices(flatServices); } diff --git a/packages/odata-service-inquirer/src/prompts/datasources/service-url/questions.ts b/packages/odata-service-inquirer/src/prompts/datasources/service-url/questions.ts index 3ee0023a7e..80f5bd663f 100644 --- a/packages/odata-service-inquirer/src/prompts/datasources/service-url/questions.ts +++ b/packages/odata-service-inquirer/src/prompts/datasources/service-url/questions.ts @@ -6,11 +6,10 @@ import { t } from '../../../i18n'; import type { OdataServiceAnswers, OdataServicePromptOptions } from '../../../types'; import { hostEnvironment, promptNames } from '../../../types'; import { PromptState, getHostEnvironment } from '../../../utils'; -import LoggerHelper from '../../logger-helper'; import { ConnectionValidator } from '../../connectionValidator'; +import LoggerHelper from '../../logger-helper'; import { serviceUrlInternalPromptNames } from './types'; import { validateService } from './validators'; -import type { AbapServiceProvider } from '@sap-ux/axios-extension'; /** * Internal only answers to service URL prompting not returned with OdataServiceAnswers. @@ -64,7 +63,7 @@ function getServiceUrlPrompt(connectValidator: ConnectionValidator, requiredVers url, { odataService: connectValidator.odataService, - abapServiceProvider: connectValidator.serviceProvider as AbapServiceProvider + axiosConfig: connectValidator.axiosConfig }, requiredVersion ); @@ -118,7 +117,7 @@ function getIgnoreCertErrorsPrompt( serviceUrl, { odataService: connectValidator.odataService, - abapServiceProvider: connectValidator.serviceProvider as AbapServiceProvider + axiosConfig: connectValidator.axiosConfig }, requiredVersion, ignoreCertError @@ -167,7 +166,7 @@ function getCliIgnoreCertValidatePrompt( serviceUrl, { odataService: connectValidator.odataService, - abapServiceProvider: connectValidator.serviceProvider as AbapServiceProvider + axiosConfig: connectValidator.axiosConfig }, requiredVersion, true @@ -237,7 +236,7 @@ function getPasswordPrompt( serviceUrl, { odataService: connectValidator.odataService, - abapServiceProvider: connectValidator.serviceProvider as AbapServiceProvider + axiosConfig: connectValidator.axiosConfig }, requiredVersion, ignoreCertError diff --git a/packages/odata-service-inquirer/src/prompts/datasources/service-url/validators.ts b/packages/odata-service-inquirer/src/prompts/datasources/service-url/validators.ts index e4cc865897..f6d8891b56 100644 --- a/packages/odata-service-inquirer/src/prompts/datasources/service-url/validators.ts +++ b/packages/odata-service-inquirer/src/prompts/datasources/service-url/validators.ts @@ -1,4 +1,4 @@ -import type { AbapServiceProvider, ODataService, ODataVersion } from '@sap-ux/axios-extension'; +import { createForAbap, type AxiosRequestConfig, type ODataService, type ODataVersion } from '@sap-ux/axios-extension'; import type { OdataVersion } from '@sap-ux/odata-service-writer'; import { ERROR_TYPE, ErrorHandler } from '../../../error-handler/error-handler'; import { t } from '../../../i18n'; @@ -15,14 +15,14 @@ import { errorHandler } from '../../prompt-helpers'; * @param url the full odata service url including query parameters * @param connectionConfig the connection configuration to use for the validation, a subset of the ConnectionValidator properties * @param connectionConfig.odataService the odata service instance used to retrieve the metadata (as used by ConnectionValidator) - * @param connectionConfig.abapServiceProvider the abap service provider instance used to retrieve annotations (as used by ConnectionValidator) + * @param connectionConfig.axiosConfig the axios config to use for the annotations request (as used by ConnectionValidator) * @param requiredVersion if specified and the service odata version does not match this version, an error is returned * @param ignoreCertError if true some certificate errors are ignored * @returns true if a valid odata service was returned, false or an error message string otherwise */ export async function validateService( url: string, - { odataService, abapServiceProvider }: { odataService: ODataService; abapServiceProvider: AbapServiceProvider }, + { odataService, axiosConfig }: { odataService: ODataService; axiosConfig: AxiosRequestConfig }, requiredVersion: OdataVersion | undefined = undefined, ignoreCertError = false ): Promise { @@ -56,7 +56,8 @@ export async function validateService( // Best effort attempt to get annotations but dont throw an error if it fails as this may not even be an Abap system try { // Create an abap provider instance to get the annotations using the same request config - const catalogService = abapServiceProvider.catalog(serviceOdataVersion as unknown as ODataVersion); + const abapProvider = createForAbap(axiosConfig); + const catalogService = abapProvider.catalog(serviceOdataVersion as unknown as ODataVersion); LoggerHelper.attachAxiosLogger(catalogService.interceptors); LoggerHelper.logger.debug('Getting annotations for service'); const annotations = await catalogService.getAnnotations({ path: fullUrl.pathname }); diff --git a/packages/odata-service-inquirer/src/translations/odata-service-inquirer.i18n.json b/packages/odata-service-inquirer/src/translations/odata-service-inquirer.i18n.json index 34ed431623..d2a7229680 100644 --- a/packages/odata-service-inquirer/src/translations/odata-service-inquirer.i18n.json +++ b/packages/odata-service-inquirer/src/translations/odata-service-inquirer.i18n.json @@ -130,6 +130,7 @@ "authenticationTimeout": "Authorization was not verified within the allowed time. Please ensure you have authenticated using the associated browser window.", "invalidUrl": "Not a valid URL", "connectionError": "A connection error occurred, please ensure the target host is available on the network: {{- error}}", + "timeout": "A connection timeout error occurred: {{- error}}", "serviceUnavailable": "Selected service is returning an error.", "catalogServiceNotActive": "Catalog service is not active", "internalServerError": "The URL you have provided cannot be accessed and is returning: '{{- error}}'. Please ensure that the URL is accessible externally.", @@ -155,7 +156,8 @@ "serviceTypeRequestError": "Error retrieving service type: {{- error}}", "noAbapEnvsInCFSpace": "No ABAP environments in CF space found.", "abapEnvsCFDiscoveryFailed": "Discovering ABAP Environments failed. Please ensure you are logged into Cloud Foundry (see https://docs.cloudfoundry.org/cf-cli/getting-started.html#login).", - "abapServiceAuthenticationFailed": "ABAP environment authentication using UAA failed." + "abapServiceAuthenticationFailed": "ABAP environment authentication using UAA failed.", + "serviceCatalogRequest": "An error occurred requesting services from: {{- catalogRequestUri }} and entity set: {{entitySet}}. {{error}}" }, "texts": { "anExpiredCert": "an expired", diff --git a/packages/odata-service-inquirer/test/unit/prompts/sap-system/abap-on-prem/questions.test.ts b/packages/odata-service-inquirer/test/unit/prompts/sap-system/abap-on-prem/questions.test.ts index 95be24d02b..d09c89b3ed 100644 --- a/packages/odata-service-inquirer/test/unit/prompts/sap-system/abap-on-prem/questions.test.ts +++ b/packages/odata-service-inquirer/test/unit/prompts/sap-system/abap-on-prem/questions.test.ts @@ -1,5 +1,12 @@ import { Severity } from '@sap-devx/yeoman-ui-types'; -import type { ODataService, ODataServiceInfo, ServiceProvider } from '@sap-ux/axios-extension'; +import type { + CatalogService, + ODataService, + ODataServiceInfo, + ServiceProvider, + V2CatalogService, + V4CatalogService +} from '@sap-ux/axios-extension'; import { ODataVersion, ServiceType } from '@sap-ux/axios-extension'; import type { ListQuestion } from '@sap-ux/inquirer-common'; import { OdataVersion } from '@sap-ux/odata-service-writer'; @@ -31,10 +38,10 @@ const serviceProviderMock = {} as Partial; const catalogs = { [ODataVersion.v2]: { listServices: jest.fn().mockResolvedValue([]) - } as { listServices: Function; getAnnotations?: Function; getServiceType?: Function }, + } as Partial, [ODataVersion.v4]: { listServices: jest.fn().mockResolvedValue([]) - } as { listServices: Function; getAnnotations?: Function } + } as Partial }; const connectionValidatorMock = { validity: {} as ConnectionValidator['validity'], @@ -712,4 +719,42 @@ describe('questions', () => { ); expect(validationResult).toBe(t('errors.serviceMetadataErrorUI', { servicePath: selectedService.servicePath })); }); + + test('should show a guided answer link when no services are returned and an error was logged', async () => { + const mockV2CatUri = 'http://some.abap.system:1234/sap/opu/odata/IWFND/CATALOGSERVICE;v=2/'; + const mockV4CatUri = + 'http://some.abap.system:1234/sap/opu/odata4/iwfnd/config/default/iwfnd/catalog/0002/ServiceGroups'; + const entitySet = 'RecommendedServiceCollection'; + const catRequestError = new Error('Failed to get services'); + connectionValidatorMock.catalogs = { + [ODataVersion.v2]: { + listServices: jest.fn().mockRejectedValue(catRequestError), + entitySet, + getUri: jest.fn().mockReturnValue(mockV2CatUri) + }, + [ODataVersion.v4]: { + listServices: jest.fn().mockResolvedValue([]), + entitySet, + getUri: jest.fn().mockReturnValue(mockV4CatUri) + } + }; + connectionValidatorMock.validatedUrl = 'http://some.abap.system:1234'; + const loggerSpy = jest.spyOn(LoggerHelper.logger, 'error'); + const newSystemQuestions = getAbapOnPremQuestions(); + const serviceSelectionPrompt = newSystemQuestions.find( + (question) => question.name === `abapOnPrem:${promptNames.serviceSelection}` + ); + // + const choices = await ((serviceSelectionPrompt as ListQuestion)?.choices as Function)(); + expect(choices).toEqual([]); + const valResult = await ((serviceSelectionPrompt as ListQuestion)?.validate as Function)(); + expect(valResult).toBe(t('errors.servicesUnavailable')); + expect(loggerSpy).toHaveBeenCalledWith( + t('errors.serviceCatalogRequest', { + catalogRequestUri: mockV2CatUri, + entitySet, + error: catRequestError + }) + ); + }); }); diff --git a/packages/odata-service-inquirer/test/unit/prompts/service-url/questions.test.ts b/packages/odata-service-inquirer/test/unit/prompts/service-url/questions.test.ts index bae78f0fdf..4a04a21238 100644 --- a/packages/odata-service-inquirer/test/unit/prompts/service-url/questions.test.ts +++ b/packages/odata-service-inquirer/test/unit/prompts/service-url/questions.test.ts @@ -18,7 +18,8 @@ const connectionValidatorMock = { validateUrl: validateUrlMockTrue, validateAuth: validateAuthTrue, odataService: odataServiceMock, - serviceProvider: serviceProviderMock + serviceProvider: serviceProviderMock, + axiosConfig: {} }; jest.mock('../../../../src/prompts/connectionValidator', () => { return { @@ -129,6 +130,7 @@ describe('Service URL prompts', () => { authRequired: false, authenticated: false }; + connectionValidatorMock.axiosConfig = {}; // Should validate service and return true if valid const serviceUrl = 'https://some.host:1234/service/path'; @@ -140,7 +142,7 @@ describe('Service URL prompts', () => { expect(serviceValidatorSpy).toHaveBeenCalledWith( serviceUrl, - expect.objectContaining({ 'abapServiceProvider': {}, 'odataService': {} }), + expect.objectContaining({ 'axiosConfig': {}, 'odataService': {} }), undefined ); expect(validateUrlMockTrue).toHaveBeenCalledWith(serviceUrl); @@ -155,7 +157,7 @@ describe('Service URL prompts', () => { expect(connectionValidatorMock.validateUrl).toHaveBeenCalledWith(serviceUrl); expect(serviceValidatorSpy).toHaveBeenCalledWith( serviceUrl, - expect.objectContaining({ 'abapServiceProvider': {}, 'odataService': {} }), + expect.objectContaining({ 'axiosConfig': {}, 'odataService': {} }), OdataVersion.v4 ); @@ -243,7 +245,7 @@ describe('Service URL prompts', () => { }); expect(serviceValidatorSpy).toHaveBeenCalledWith( serviceUrl, - expect.objectContaining({ 'abapServiceProvider': {}, 'odataService': {} }), + expect.objectContaining({ 'axiosConfig': {}, 'odataService': {} }), undefined, true ); @@ -321,7 +323,7 @@ describe('Service URL prompts', () => { }); expect(serviceValidatorSpy).toHaveBeenCalledWith( serviceUrl, - expect.objectContaining({ 'abapServiceProvider': {}, 'odataService': {} }), + expect.objectContaining({ 'axiosConfig': {}, 'odataService': {} }), undefined, true ); @@ -400,7 +402,7 @@ describe('Service URL prompts', () => { }); expect(serviceValidatorSpy).toHaveBeenCalledWith( serviceUrl, - expect.objectContaining({ 'abapServiceProvider': {}, 'odataService': {} }), + expect.objectContaining({ 'axiosConfig': {}, 'odataService': {} }), undefined, undefined ); diff --git a/packages/odata-service-inquirer/test/unit/prompts/service-url/validators.test.ts b/packages/odata-service-inquirer/test/unit/prompts/service-url/validators.test.ts index 0004626c6c..0fc6b25324 100644 --- a/packages/odata-service-inquirer/test/unit/prompts/service-url/validators.test.ts +++ b/packages/odata-service-inquirer/test/unit/prompts/service-url/validators.test.ts @@ -19,7 +19,8 @@ jest.mock('@sap-ux/axios-extension', () => ({ ...jest.requireActual('@sap-ux/axios-extension'), AbapServiceProvider: jest.fn().mockImplementation(() => ({ catalog: catalogServiceMock - })) + })), + createForAbap: jest.fn().mockImplementation(() => new AbapServiceProvider()) })); describe('Test service url validators', () => { @@ -59,7 +60,7 @@ describe('Test service url validators', () => { expect( await validateService(serviceUrl, { odataService, - abapServiceProvider: new AbapServiceProvider() + axiosConfig: {} }) ).toMatch(t('prompts.validationMessages.metadataInvalid')); @@ -68,31 +69,19 @@ describe('Test service url validators', () => { expect( await validateService(serviceUrl, { odataService, - abapServiceProvider: new AbapServiceProvider() + axiosConfig: {} }) ).toBe(true); expect(catalogServiceMock).toHaveBeenCalledWith(OdataVersion.v2); // Valid metadata with required version - expect( - await validateService( - serviceUrl, - { odataService, abapServiceProvider: new AbapServiceProvider() }, - OdataVersion.v4 - ) - ).toBe( + expect(await validateService(serviceUrl, { odataService, axiosConfig: {} }, OdataVersion.v4)).toBe( t('prompts.validationMessages.odataVersionMismatch', { requiredOdataVersion: OdataVersion.v4, providedOdataVersion: OdataVersion.v2 }) ); - expect( - await validateService( - serviceUrl, - { odataService, abapServiceProvider: new AbapServiceProvider() }, - OdataVersion.v2 - ) - ).toBe(true); + expect(await validateService(serviceUrl, { odataService, axiosConfig: {} }, OdataVersion.v2)).toBe(true); }); test('should set the prompt state', async () => { @@ -111,7 +100,7 @@ describe('Test service url validators', () => { expect( await validateService(serviceUrl, { odataService, - abapServiceProvider: new AbapServiceProvider() + 'axiosConfig': {} }) ).toBe(true); expect(PromptState.odataService).toEqual({ @@ -140,7 +129,7 @@ describe('Test service url validators', () => { expect( await validateService(serviceUrl, { odataService, - abapServiceProvider: new AbapServiceProvider() + 'axiosConfig': {} }) ).toBe(true); expect(loggerSpy).toHaveBeenCalledWith(t('prompts.validationMessages.annotationsNotFound')); @@ -151,7 +140,7 @@ describe('Test service url validators', () => { expect( await validateService(serviceUrl, { odataService, - abapServiceProvider: new AbapServiceProvider() + 'axiosConfig': {} }) ).toBe(true); expect(loggerSpy).toHaveBeenCalledWith(t('prompts.validationMessages.annotationsNotFound')); @@ -167,7 +156,7 @@ describe('Test service url validators', () => { expect( await validateService(serviceUrl, { odataService, - abapServiceProvider: new AbapServiceProvider() + 'axiosConfig': {} }) ).toBe(t('errors.unknownError', { error: metadataRequestError.message })); expect(loggerSpy).toHaveBeenCalled(); @@ -179,7 +168,7 @@ describe('Test service url validators', () => { expect( await validateService(serviceUrl, { odataService, - abapServiceProvider: new AbapServiceProvider() + 'axiosConfig': {} }) ).toBe(t('errors.odataServiceUrlNotFound')); }); diff --git a/packages/odata-service-writer/CHANGELOG.md b/packages/odata-service-writer/CHANGELOG.md index a152cea4f0..9385f14538 100644 --- a/packages/odata-service-writer/CHANGELOG.md +++ b/packages/odata-service-writer/CHANGELOG.md @@ -1,5 +1,12 @@ # @sap-ux/odata-service-writer +## 0.22.5 + +### Patch Changes + +- d962ce1: Move hasUI5CliV3 to project-access for common re-use + - @sap-ux/mockserver-config-writer@0.6.4 + ## 0.22.4 ### Patch Changes diff --git a/packages/odata-service-writer/package.json b/packages/odata-service-writer/package.json index 2572760f38..ee15766de6 100644 --- a/packages/odata-service-writer/package.json +++ b/packages/odata-service-writer/package.json @@ -9,7 +9,7 @@ "bugs": { "url": "https://github.com/SAP/open-ux-tools/issues?q=is%3Aopen+is%3Aissue+label%3Abug+label%3Aodata-service-writer" }, - "version": "0.22.4", + "version": "0.22.5", "license": "Apache-2.0", "main": "dist/index.js", "scripts": { diff --git a/packages/odata-service-writer/src/updates.ts b/packages/odata-service-writer/src/updates.ts index f1e0b660d5..9a5ceb6094 100644 --- a/packages/odata-service-writer/src/updates.ts +++ b/packages/odata-service-writer/src/updates.ts @@ -5,7 +5,7 @@ import { t } from './i18n'; import type { OdataService, CdsAnnotationsInfo, EdmxAnnotationsInfo } from './types'; import semVer from 'semver'; import prettifyXml from 'prettify-xml'; -import { getMinimumUI5Version, type Manifest } from '@sap-ux/project-access'; +import { getMinimumUI5Version, type Manifest, hasUI5CliV3 } from '@sap-ux/project-access'; /** * Internal function that updates the manifest.json based on the given service configuration. @@ -152,18 +152,3 @@ export function updatePackageJson(path: string, fs: Editor, addMockServer: boole } fs.writeJSON(path, packageJson); } - -/** - * Check if dev dependencies contains @ui5/cli version greater than 2. - * - * @param devDependencies dev dependencies from package.json - * @returns boolean - */ -export function hasUI5CliV3(devDependencies: any): boolean { - let isV3 = false; - const ui5CliSemver = semVer.coerce(devDependencies['@ui5/cli']); - if (ui5CliSemver && semVer.gte(ui5CliSemver, '3.0.0')) { - isV3 = true; - } - return isV3; -} diff --git a/packages/preview-middleware-client/CHANGELOG.md b/packages/preview-middleware-client/CHANGELOG.md index 11269e9380..e085549050 100644 --- a/packages/preview-middleware-client/CHANGELOG.md +++ b/packages/preview-middleware-client/CHANGELOG.md @@ -1,5 +1,11 @@ # @sap-ux-private/preview-middleware-client +## 0.11.0 + +### Minor Changes + +- b1628da: Add quick actions to adaptation editor + ## 0.10.9 ### Patch Changes diff --git a/packages/preview-middleware-client/jest.config.js b/packages/preview-middleware-client/jest.config.js index 4298919fc1..b0501dc208 100644 --- a/packages/preview-middleware-client/jest.config.js +++ b/packages/preview-middleware-client/jest.config.js @@ -2,6 +2,9 @@ const config = require('../../jest.base'); config.testEnvironment = 'jsdom'; config.moduleNameMapper = { '^sap/(.+)$': '/test/__mock__/sap/$1.ts', + // Jest will try to load browser version, because environment is set to jsdom, but that is not what we want + // https://jest-archive-august-2023.netlify.app/docs/28.x/upgrading-to-jest28#packagejson-exports + '^@sap-ux/i18n$': require.resolve('@sap-ux/i18n'), '^mock/(.+)$': '/test/__mock__/$1.ts', '^open/ux/preview/client/(.+)$': '/src/$1.ts' }; diff --git a/packages/preview-middleware-client/package.json b/packages/preview-middleware-client/package.json index 613124526c..6375e11d12 100644 --- a/packages/preview-middleware-client/package.json +++ b/packages/preview-middleware-client/package.json @@ -1,6 +1,6 @@ { "name": "@sap-ux-private/preview-middleware-client", - "version": "0.10.9", + "version": "0.11.0", "description": "Client-side coding hosted by the preview middleware", "repository": { "type": "git", @@ -11,7 +11,9 @@ "private": true, "main": "dist/index.js", "scripts": { - "build": "tsc --noEmit && ui5 build --clean-dest --exclude-task minify --exclude-task generateComponentPreload", + "build": "npm-run-all -l -p build:type-check build:component", + "build:type-check": "tsc --noEmit", + "build:component": "ui5 build --clean-dest --exclude-task minify --exclude-task generateComponentPreload", "clean": "rimraf --glob dist coverage *.tsbuildinfo", "format": "prettier --write '**/*.{js,json,ts,yaml,yml}' --ignore-path ../../.prettierignore", "lint": "eslint . --ext .ts", @@ -30,7 +32,9 @@ "ui5-tooling-modules": "3.0.5", "@sap-ux-private/control-property-editor-common": "workspace:*", "@sap-ux/eslint-plugin-fiori-tools": "workspace:*", + "@sap-ux/i18n": "workspace:*", "@ui5/cli": "3.8.0", + "npm-run-all2": "6.2.0", "ui5-tooling-transpile": "3.4.0" }, "browserslist": "defaults" diff --git a/packages/preview-middleware-client/src/adp/controllers/AddFragment.controller.ts b/packages/preview-middleware-client/src/adp/controllers/AddFragment.controller.ts index 4462f0d32f..13618c5283 100644 --- a/packages/preview-middleware-client/src/adp/controllers/AddFragment.controller.ts +++ b/packages/preview-middleware-client/src/adp/controllers/AddFragment.controller.ts @@ -44,7 +44,7 @@ type AddFragmentModel = JSONModel & { * @namespace open.ux.preview.client.adp.controllers */ export default class AddFragment extends BaseDialog { - constructor(name: string, overlays: UI5Element, rta: RuntimeAuthoring) { + constructor(name: string, overlays: UI5Element, rta: RuntimeAuthoring, private aggregation?: string) { super(name); this.rta = rta; this.overlays = overlays; @@ -181,7 +181,7 @@ export default class AddFragment extends BaseDialog { } return false; }); - const defaultAggregation = controlMetadata.getDefaultAggregationName(); + const defaultAggregation = this.aggregation ?? controlMetadata.getDefaultAggregationName(); const selectedControlName = controlMetadata.getName(); let selectedControlChildren: string[] | number[] = Object.keys( diff --git a/packages/preview-middleware-client/src/adp/controllers/ControllerExtension.controller.ts b/packages/preview-middleware-client/src/adp/controllers/ControllerExtension.controller.ts index dd1d5139fd..cc108aecce 100644 --- a/packages/preview-middleware-client/src/adp/controllers/ControllerExtension.controller.ts +++ b/packages/preview-middleware-client/src/adp/controllers/ControllerExtension.controller.ts @@ -17,9 +17,6 @@ import JSONModel from 'sap/ui/model/json/JSONModel'; /** sap.ui.rta */ import type RuntimeAuthoring from 'sap/ui/rta/RuntimeAuthoring'; -/** sap.ui.fl */ -import Utils from 'sap/ui/fl/Utils'; - /** sap.ui.layout */ import type SimpleForm from 'sap/ui/layout/form/SimpleForm'; @@ -35,16 +32,12 @@ import { writeController } from '../api-handler'; import BaseDialog from './BaseDialog.controller'; +import { getControllerInfo } from '../utils'; interface ControllerExtensionService { add: (codeRef: string, viewId: string) => Promise<{ creation: string }>; } -interface ControllerInfo { - controllerName: string; - viewId: string; -} - type ControllerModel = JSONModel & { getProperty(sPath: '/controllersList'): { controllerName: string }[]; getProperty(sPath: '/controllerExists'): boolean; @@ -164,7 +157,7 @@ export default class ControllerExtension extends BaseDialog { const selectorId = this.overlays.getId(); const overlayControl = sap.ui.getCore().byId(selectorId) as unknown as ElementOverlay; - const { controllerName, viewId } = this.getControllerInfo(overlayControl); + const { controllerName, viewId } = getControllerInfo(overlayControl); const existingController = await this.getExistingController(controllerName); if (existingController) { @@ -184,21 +177,6 @@ export default class ControllerExtension extends BaseDialog { } } } - - /** - * Gets controller name and view ID for the given overlay control. - * - * @param overlayControl The overlay control. - * @returns The controller name and view ID. - */ - private getControllerInfo(overlayControl: ElementOverlay): ControllerInfo { - const control = overlayControl.getElement(); - const view = Utils.getViewForControl(control); - const controllerName = view.getController().getMetadata().getName(); - const viewId = view.getId(); - return { controllerName, viewId }; - } - /** * Updates the model properties for an existing controller. * diff --git a/packages/preview-middleware-client/src/adp/init-dialogs.ts b/packages/preview-middleware-client/src/adp/init-dialogs.ts index a627d64536..a1ff345ed8 100644 --- a/packages/preview-middleware-client/src/adp/init-dialogs.ts +++ b/packages/preview-middleware-client/src/adp/init-dialogs.ts @@ -19,7 +19,7 @@ import ControllerExtension from './controllers/ControllerExtension.controller'; import { ExtensionPointData } from './extension-point'; import ExtensionPoint from './controllers/ExtensionPoint.controller'; import ManagedObject from 'sap/ui/base/ManagedObject'; -import { isReuseComponent } from '../cpe/outline/utils'; +import { isReuseComponent } from '../cpe/utils'; import { Ui5VersionInfo } from '../utils/version'; export const enum DialogNames { @@ -30,6 +30,26 @@ export const enum DialogNames { type Controller = AddFragment | ControllerExtension | ExtensionPoint; +/** + * Handler for enablement of Extend With Controller context menu entry + * + * @param control UI5 control. + * @param syncViewsIds Runtime Authoring + * @param ui5VersionInfo UI5 version information + * + * @returns boolean whether menu item is enabled or not + */ +export function isControllerExtensionEnabledForControl( + control: ManagedObject, + syncViewsIds: string[], + ui5VersionInfo: Ui5VersionInfo +): boolean { + const clickedControlId = FlUtils.getViewForControl(control).getId(); + const isClickedControlReuseComponent = isReuseComponent(clickedControlId, ui5VersionInfo); + + return !syncViewsIds.includes(clickedControlId) && !isClickedControlReuseComponent; +} + /** * Handler for enablement of Extend With Controller context menu entry * @@ -47,11 +67,7 @@ export const isControllerExtensionEnabled = ( if (overlays.length === 0 || overlays.length > 1) { return false; } - - const clickedControlId = FlUtils.getViewForControl(overlays[0].getElement()).getId(); - const isClickedControlReuseComponent = isReuseComponent(clickedControlId, ui5VersionInfo); - - return !syncViewsIds.includes(clickedControlId) && !isClickedControlReuseComponent; + return isControllerExtensionEnabledForControl(overlays[0].getElement(), syncViewsIds, ui5VersionInfo); }; /** @@ -102,18 +118,25 @@ export const getAddFragmentItemText = (overlay: ElementOverlay) => { * @param rta Runtime Authoring * @param dialogName Dialog name * @param extensionPointData Control ID + * @param aggregation Name of aggregation that should be selected when dialog is opened */ export async function handler( overlay: UI5Element, rta: RuntimeAuthoring, dialogName: DialogNames, - extensionPointData?: ExtensionPointData + extensionPointData?: ExtensionPointData, + aggregation?: string ): Promise { let controller: Controller; switch (dialogName) { case DialogNames.ADD_FRAGMENT: - controller = new AddFragment(`open.ux.preview.client.adp.controllers.${dialogName}`, overlay, rta); + controller = new AddFragment( + `open.ux.preview.client.adp.controllers.${dialogName}`, + overlay, + rta, + aggregation + ); break; case DialogNames.CONTROLLER_EXTENSION: controller = new ControllerExtension(`open.ux.preview.client.adp.controllers.${dialogName}`, overlay, rta); diff --git a/packages/preview-middleware-client/src/adp/init.ts b/packages/preview-middleware-client/src/adp/init.ts index db33430bf7..722119ed9a 100644 --- a/packages/preview-middleware-client/src/adp/init.ts +++ b/packages/preview-middleware-client/src/adp/init.ts @@ -1,22 +1,24 @@ import log from 'sap/base/Log'; import type RuntimeAuthoring from 'sap/ui/rta/RuntimeAuthoring'; -import init from '../cpe/init'; -import { initDialogs } from './init-dialogs'; + import { ExternalAction, showMessage, startPostMessageCommunication, enableTelemetry } from '@sap-ux-private/control-property-editor-common'; + import { ActionHandler } from '../cpe/types'; -import UI5Element from 'sap/ui/dt/Element'; import { getError } from '../utils/error'; -import { - getUi5Version, - getUI5VersionValidationMessage, - isLowerThanMinimalUi5Version, - Ui5VersionInfo -} from '../utils/version'; +import { getUi5Version, getUI5VersionValidationMessage, isLowerThanMinimalUi5Version } from '../utils/version'; + +import init from '../cpe/init'; +import { getApplicationType } from '../utils/application'; +import { getTextBundle } from '../i18n'; + +import { loadDefinitions } from './quick-actions/load'; +import { getAllSyncViewsIds } from './utils'; +import { initDialogs } from './init-dialogs'; export default async function (rta: RuntimeAuthoring) { const flexSettings = rta.getFlexSettings(); @@ -55,7 +57,10 @@ export default async function (rta: RuntimeAuthoring) { extPointService.init(subscribe); } - await init(rta); + const applicationType = getApplicationType(rta.getRootControlInstance().getManifest()); + const quickActionRegistries = await loadDefinitions(applicationType); + + await init(rta, quickActionRegistries); if (isLowerThanMinimalUi5Version(ui5VersionInfo)) { sendAction(showMessage({ message: getUI5VersionValidationMessage(ui5VersionInfo), shouldHideIframe: true })); @@ -63,10 +68,10 @@ export default async function (rta: RuntimeAuthoring) { } if (syncViewsIds.length > 0) { + const bundle = await getTextBundle(); sendAction( showMessage({ - message: - 'Have in mind that synchronous views are detected for this application and controller extensions are not supported for such views. Controller extension functionality on these views will be disabled.', + message: bundle.getText('ADP_SYNC_VIEWS_MESSAGE'), shouldHideIframe: false }) ); @@ -74,47 +79,3 @@ export default async function (rta: RuntimeAuthoring) { log.debug('ADP init executed.'); } - -/** - * Get Ids for all sync views - * - * @param ui5VersionInfo UI5 Version Information - * - * @returns array of Ids for application sync views - */ -async function getAllSyncViewsIds(ui5VersionInfo: Ui5VersionInfo): Promise { - const syncViewIds: string[] = []; - try { - if (isLowerThanMinimalUi5Version(ui5VersionInfo, { major: 1, minor: 120 })) { - const Element = (await import('sap/ui/core/Element')).default; - const elements = Element.registry.filter(() => true) as UI5Element[]; - elements.forEach((ui5Element) => { - if (isSyncView(ui5Element)) { - syncViewIds.push(ui5Element.getId()); - } - }); - } else { - const ElementRegistry = (await import('sap/ui/core/ElementRegistry')).default; - const elements = ElementRegistry.all() as Record; - Object.entries(elements).forEach(([key, ui5Element]) => { - if (isSyncView(ui5Element)) { - syncViewIds.push(key); - } - }); - } - } catch (error) { - log.error('Could not get application sync views', getError(error)); - } - - return syncViewIds; -} - -/** - * Check if element is sync view - * - * @param element UI5Element - * @returns boolean if element is sync view or not - */ -const isSyncView = (element: UI5Element): boolean => { - return element?.getMetadata()?.getName()?.includes('XMLView') && element?.oAsyncState === undefined; -}; diff --git a/packages/preview-middleware-client/src/adp/quick-actions/common/add-controller-to-page.ts b/packages/preview-middleware-client/src/adp/quick-actions/common/add-controller-to-page.ts new file mode 100644 index 0000000000..5c4fca62c5 --- /dev/null +++ b/packages/preview-middleware-client/src/adp/quick-actions/common/add-controller-to-page.ts @@ -0,0 +1,71 @@ +import OverlayRegistry from 'sap/ui/dt/OverlayRegistry'; +import FlexCommand from 'sap/ui/rta/command/FlexCommand'; +import UI5Element from 'sap/ui/core/Element'; + +import { SIMPLE_QUICK_ACTION_KIND, SimpleQuickAction } from '@sap-ux-private/control-property-editor-common'; + +import { getUi5Version } from '../../../utils/version'; +import { getAllSyncViewsIds, getControllerInfoForControl } from '../../utils'; +import { getRelevantControlFromActivePage } from '../../../cpe/quick-actions/utils'; +import type { + QuickActionContext, + SimpleQuickActionDefinition +} from '../../../cpe/quick-actions/quick-action-definition'; + +import { DialogNames, handler, isControllerExtensionEnabledForControl } from '../../init-dialogs'; +import { getExistingController } from '../../api-handler'; + +export const ADD_CONTROLLER_TO_PAGE_TYPE = 'add-controller-to-page'; +const CONTROL_TYPES = ['sap.f.DynamicPage', 'sap.uxap.ObjectPageLayout']; + + +/** + * Quick Action for adding controller to a page. + */ +export class AddControllerToPageQuickAction implements SimpleQuickActionDefinition { + readonly kind = SIMPLE_QUICK_ACTION_KIND; + readonly type = ADD_CONTROLLER_TO_PAGE_TYPE; + public get id(): string { + return `${this.context.key}-${this.type}`; + } + + isActive = false; + private controllerExists = false; + private control: UI5Element | undefined; + constructor(private context: QuickActionContext) {} + + async initialize(): Promise { + for (const control of getRelevantControlFromActivePage( + this.context.controlIndex, + this.context.view, + CONTROL_TYPES + )) { + const version = await getUi5Version(); + const syncViewsIds = await getAllSyncViewsIds(version); + const controlInfo = getControllerInfoForControl(control); + const data = await getExistingController(controlInfo.controllerName); + this.isActive = isControllerExtensionEnabledForControl(control, syncViewsIds, version); + this.controllerExists = data?.controllerExists; + this.control = control; + break; + } + } + + getActionObject(): SimpleQuickAction { + const key = this.controllerExists ? 'QUICK_ACTION_SHOW_PAGE_CONTROLLER' : 'QUICK_ACTION_ADD_PAGE_CONTROLLER'; + return { + kind: SIMPLE_QUICK_ACTION_KIND, + id: this.id, + enabled: this.isActive, + title: this.context.resourceBundle.getText(key) + }; + } + + async execute(): Promise { + if (this.control) { + const overlay = OverlayRegistry.getOverlay(this.control) || []; + await handler(overlay, this.context.rta, DialogNames.CONTROLLER_EXTENSION); + } + return []; + } +} diff --git a/packages/preview-middleware-client/src/adp/quick-actions/common/op-add-header-field.ts b/packages/preview-middleware-client/src/adp/quick-actions/common/op-add-header-field.ts new file mode 100644 index 0000000000..93dbbfbd8d --- /dev/null +++ b/packages/preview-middleware-client/src/adp/quick-actions/common/op-add-header-field.ts @@ -0,0 +1,71 @@ +import OverlayRegistry from 'sap/ui/dt/OverlayRegistry'; +import FlexCommand from 'sap/ui/rta/command/FlexCommand'; +import UI5Element from 'sap/ui/core/Element'; +import ObjectPageLayout from 'sap/uxap/ObjectPageLayout'; +import FlexBox from 'sap/m/FlexBox'; + +import { SIMPLE_QUICK_ACTION_KIND, SimpleQuickAction } from '@sap-ux-private/control-property-editor-common'; + +import { DialogNames, handler } from '../../../adp/init-dialogs'; + +import { getRelevantControlFromActivePage } from '../../../cpe/quick-actions/utils'; +import { QuickActionContext, SimpleQuickActionDefinition } from '../../../cpe/quick-actions/quick-action-definition'; +import { isA } from '../../../utils/core'; +export const OP_ADD_HEADER_FIELD_TYPE = 'op-add-header-field'; +const CONTROL_TYPES = ['sap.uxap.ObjectPageLayout']; + +/** + * Quick Action for adding a Header Field to an Object Page. + */ +export class AddHeaderFieldQuickAction implements SimpleQuickActionDefinition { + readonly kind = SIMPLE_QUICK_ACTION_KIND; + readonly type = OP_ADD_HEADER_FIELD_TYPE; + public get id(): string { + return `${this.context.key}-${this.type}`; + } + + isActive = false; + private control: UI5Element | undefined; + constructor(private context: QuickActionContext) {} + + initialize(): void { + for (const control of getRelevantControlFromActivePage( + this.context.controlIndex, + this.context.view, + CONTROL_TYPES + )) { + this.isActive = true; + this.control = control; + break; + } + } + + getActionObject(): SimpleQuickAction { + return { + kind: SIMPLE_QUICK_ACTION_KIND, + id: this.id, + enabled: this.isActive, + title: this.context.resourceBundle.getText('QUICK_ACTION_OP_ADD_HEADER_FIELD') + }; + } + + async execute(): Promise { + const objectPageLayout = getRelevantControlFromActivePage( + this.context.controlIndex, + this.context.view, + CONTROL_TYPES + )[0] as ObjectPageLayout; + + const headerContent = objectPageLayout.getHeaderContent(); + + // check if only flex box exist in the headerContent. + if (headerContent.length === 1 && isA('sap.m.FlexBox', headerContent[0])) { + const overlay = OverlayRegistry.getOverlay(headerContent[0]) || []; + await handler(overlay, this.context.rta, DialogNames.ADD_FRAGMENT, undefined, 'items'); + } else if (this.control) { + const overlay = OverlayRegistry.getOverlay(this.control) || []; + await handler(overlay, this.context.rta, DialogNames.ADD_FRAGMENT, undefined, 'headerContent'); + } + return []; + } +} diff --git a/packages/preview-middleware-client/src/adp/quick-actions/fe-v2/change-table-columns.ts b/packages/preview-middleware-client/src/adp/quick-actions/fe-v2/change-table-columns.ts new file mode 100644 index 0000000000..3798c7a1c1 --- /dev/null +++ b/packages/preview-middleware-client/src/adp/quick-actions/fe-v2/change-table-columns.ts @@ -0,0 +1,305 @@ +import OverlayUtil from 'sap/ui/dt/OverlayUtil'; +import FlexCommand from 'sap/ui/rta/command/FlexCommand'; +import UI5Element from 'sap/ui/core/Element'; +import type IconTabBar from 'sap/m/IconTabBar'; +import type IconTabFilter from 'sap/m/IconTabFilter'; +import type Table from 'sap/m/Table'; +import type SmartTable from 'sap/ui/comp/smarttable/SmartTable'; + +import type { NestedQuickAction, NestedQuickActionChild } from '@sap-ux-private/control-property-editor-common'; +import { NESTED_QUICK_ACTION_KIND } from '@sap-ux-private/control-property-editor-common'; + +import { QuickActionContext, NestedQuickActionDefinition } from '../../../cpe/quick-actions/quick-action-definition'; +import { getParentContainer, getRelevantControlFromActivePage } from '../../../cpe/quick-actions/utils'; +import { getControlById, isA, isManagedObject } from '../../../utils/core'; +import { getUi5Version, isLowerThanMinimalUi5Version } from '../../../utils/version'; +import ObjectPageSection from 'sap/uxap/ObjectPageSection'; +import ObjectPageSubSection from 'sap/uxap/ObjectPageSubSection'; +import TreeTable from 'sap/ui/table/TreeTable'; +import ObjectPageLayout from 'sap/uxap/ObjectPageLayout'; +import ManagedObject from 'sap/ui/base/ManagedObject'; + +export const CHANGE_TABLE_COLUMNS = 'change-table-columns'; +const SMART_TABLE_ACTION_ID = 'CTX_COMP_VARIANT_CONTENT'; +const M_TABLE_ACTION_ID = 'CTX_ADD_ELEMENTS_AS_CHILD'; +const SETTINGS_ID = 'CTX_SETTINGS'; +const ICON_TAB_BAR_TYPE = 'sap.m.IconTabBar'; +const SMART_TABLE_TYPE = 'sap.ui.comp.smarttable.SmartTable'; +const M_TABLE_TYPE = 'sap.m.Table'; +// maintain order if action id +const CONTROL_TYPES = [SMART_TABLE_TYPE, M_TABLE_TYPE, 'sap.ui.table.TreeTable', 'sap.ui.table.Table']; + +async function getActionId(table: UI5Element): Promise { + const { major, minor } = await getUi5Version(); + + if (isA(SMART_TABLE_TYPE, table)) { + if (major === 1 && minor === 96) { + return [SETTINGS_ID]; + } else { + return [SMART_TABLE_ACTION_ID]; + } + } + + return [M_TABLE_ACTION_ID, SETTINGS_ID]; +} + +export class ChangeTableColumnsQuickAction implements NestedQuickActionDefinition { + readonly kind = NESTED_QUICK_ACTION_KIND; + readonly type = CHANGE_TABLE_COLUMNS; + public get id(): string { + return `${this.context.key}-${this.type}`; + } + isActive = false; + isClearButtonEnabled = false; + children: NestedQuickActionChild[] = []; + tableMap: Record< + string, + { + table: UI5Element; + tableUpdateEventAttachedOnce: boolean; + iconTabBarFilterKey?: string; + changeColumnActionId: string; + sectionInfo?: { + section: ObjectPageSection; + subSection: ObjectPageSubSection; + layout?: ObjectPageLayout; + }; + } + > = {}; + private iconTabBar: IconTabBar | undefined; + constructor(private context: QuickActionContext) {} + + async initialize(): Promise { + // No action found in control design time for version < 1.96 + const version = await getUi5Version(); + if (isLowerThanMinimalUi5Version(version, { major: 1, minor: 96 })) { + this.isActive = false; + return; + } + const iconTabBarfilterMap = this.buildIconTabBarFilterMap(); + for (const table of getRelevantControlFromActivePage( + this.context.controlIndex, + this.context.view, + CONTROL_TYPES + )) { + const actions = await this.context.actionService.get(table.getId()); + const actionsIds = await getActionId(table); + const changeColumnAction = actionsIds.find( + (actionId) => actions.findIndex((action) => action.id === actionId) > -1 + ); + const tabKey = Object.keys(iconTabBarfilterMap).find((key) => table.getId().endsWith(key)); + if (changeColumnAction) { + const section = getParentContainer(table, 'sap.uxap.ObjectPageSection'); + if (section) { + this.collectChildrenInSection(section, table, changeColumnAction); + } else if (this.iconTabBar && tabKey) { + this.children.push({ + label: `'${iconTabBarfilterMap[tabKey]}' table`, + children: [] + }); + this.tableMap[`${this.children.length - 1}`] = { + table, + iconTabBarFilterKey: tabKey, + changeColumnActionId: changeColumnAction, + tableUpdateEventAttachedOnce: false + }; + } else { + this.processTable(table, changeColumnAction); + } + } + } + if (this.children.length > 0) { + this.isActive = true; + } + } + + private getTableLabel(table: UI5Element): string { + if (isA(SMART_TABLE_TYPE, table)) { + const header = table.getHeader(); + if (header) { + return `'${header}' table`; + } + } + if (isA(M_TABLE_TYPE, table)) { + const tilte = table?.getHeaderToolbar()?.getTitleControl()?.getText(); + if (tilte) { + return `'${tilte}' table`; + } + } + + return 'Unnamed table'; + } + + private buildIconTabBarFilterMap(): { [key: string]: string } { + const iconTabBarfilterMap: { [key: string]: string } = {}; + + // Assumption only a tab bar control per page. + const tabBar = getRelevantControlFromActivePage(this.context.controlIndex, this.context.view, [ + ICON_TAB_BAR_TYPE + ])[0]; + if (tabBar) { + const control = getControlById(tabBar.getId()); + if (isA(ICON_TAB_BAR_TYPE, control)) { + this.iconTabBar = control; + for (const item of control.getItems()) { + if (isManagedObject(item) && isA('sap.m.IconTabFilter', item)) { + iconTabBarfilterMap[item.getKey()] = item.getText(); + } + } + } + } + + return iconTabBarfilterMap; + } + + private collectChildrenInSection(section: ObjectPageSection, table: UI5Element, changeColumnAction: string): void { + const layout = getParentContainer(table, 'sap.uxap.ObjectPageLayout'); + const subSections = section.getSubSections(); + const subSection = getParentContainer(table, 'sap.uxap.ObjectPageSubSection'); + if (subSection) { + if (subSections?.length === 1) { + this.processTable(table, changeColumnAction, { section, subSection: subSections[0], layout }); + } else if (subSections.length > 1) { + const sectionChild = this.children.find((val) => val.label === `${section.getTitle()} section`); + let tableMapIndex = `${this.children.length - 1}`; + if (!sectionChild) { + tableMapIndex = `${tableMapIndex}/0`; + this.children.push({ + label: `'${section?.getTitle()}' section`, + children: [ + { + label: this.getTableLabel(table), + children: [] + } + ] + }); + } else { + tableMapIndex = `${tableMapIndex}/${sectionChild.children.length - 1}`; + sectionChild.children.push({ + label: this.getTableLabel(table), + children: [] + }); + } + + this.tableMap[tableMapIndex] = { + table, + changeColumnActionId: changeColumnAction, + sectionInfo: { section, subSection, layout }, + tableUpdateEventAttachedOnce: false + }; + } + } + } + + private processTable( + table: UI5Element, + changeColumnActionId: string, + sectionInfo?: { section: ObjectPageSection; subSection: ObjectPageSubSection; layout?: ObjectPageLayout } + ): void { + if (isA(SMART_TABLE_TYPE, table) || isA('sap.ui.table.TreeTable', table)) { + this.children.push({ + label: this.getTableLabel(table), + children: [] + }); + } + if (isA
(M_TABLE_TYPE, table)) { + this.children.push({ + label: this.getTableLabel(table), + children: [] + }); + } + this.tableMap[`${this.children.length - 1}`] = { + table, + changeColumnActionId, + sectionInfo: sectionInfo, + tableUpdateEventAttachedOnce: false + }; + } + + getActionObject(): NestedQuickAction { + return { + kind: NESTED_QUICK_ACTION_KIND, + id: this.id, + enabled: this.isActive, + title: + this.context.resourceBundle.getText('V2_QUICK_ACTION_CHANGE_TABLE_COLUMNS') ?? 'Change table columns', + children: this.children + }; + } + + private selectOverlay(table: UI5Element): void { + const controlOverlay = OverlayUtil.getClosestOverlayFor(table); + if (controlOverlay) { + controlOverlay.setSelected(true); + } + } + + async execute(path: string): Promise { + const { table, iconTabBarFilterKey, changeColumnActionId, sectionInfo } = this.tableMap[path]; + if (!table) { + return []; + } + + if (sectionInfo) { + const { layout, section, subSection } = sectionInfo; + layout?.setSelectedSection(section); + section.setSelectedSubSection(subSection); + this.selectOverlay(table); + } else { + getControlById(table.getId())?.getDomRef()?.scrollIntoView(); + this.selectOverlay(table); + } + + if (this.iconTabBar && iconTabBarFilterKey) { + this.iconTabBar.setSelectedKey(iconTabBarFilterKey); + } + + const executeAction = async () => await this.context.actionService.execute(table.getId(), changeColumnActionId); + if (isA(SMART_TABLE_TYPE, table)) { + await executeAction(); + } else if (isA
(M_TABLE_TYPE, table)) { + // if table is busy, i.e. lazy loading, then we subscribe to 'updateFinished' event and call action service when loading is done + // to avoid reopening the dialog after close + if (this.isTableLoaded(table)) { + await executeAction(); + } else { + table.attachEventOnce('updateFinished', executeAction, this); + } + } + + return []; + } + + private isAbsoluteAggregationBinding(element: ManagedObject, aggregationName: string): boolean { + const mBindingInfo = element.getBindingInfo(aggregationName); + const path = mBindingInfo?.path; + if (!path) { + return false; + } + return path.indexOf('/') === 0; + } + + /** + * Checks if table is loaded and has binding context available. + * This is needed to properly render change columns dialog. + * Based on {@link https://github.com/SAP/openui5/blob/rel-1.127/src/sap.ui.fl/src/sap/ui/fl/write/_internal/delegates/ODataV2ReadDelegate.js#L269-L271| ODataV2ReadDelegate.getPropertyInfo}. + * + * @param element - Table control. + * @returns True if binding context is available. + */ + private isTableLoaded(element: ManagedObject): boolean { + const aggregationName = 'items'; + if (this.isAbsoluteAggregationBinding(element, aggregationName)) { + const bindingInfo = element.getBindingInfo(aggregationName); + // check to be default model binding otherwise return undefined + if (typeof bindingInfo.model === 'string' && bindingInfo.model !== '') { + return false; + } + return bindingInfo.path !== undefined; + } else { + // here we explicitly request the default models binding context + const bindingContext = element.getBindingContext(); + return !!bindingContext; + } + } +} diff --git a/packages/preview-middleware-client/src/adp/quick-actions/fe-v2/lr-toggle-clear-filter-bar.ts b/packages/preview-middleware-client/src/adp/quick-actions/fe-v2/lr-toggle-clear-filter-bar.ts new file mode 100644 index 0000000000..1a25a19e6f --- /dev/null +++ b/packages/preview-middleware-client/src/adp/quick-actions/fe-v2/lr-toggle-clear-filter-bar.ts @@ -0,0 +1,84 @@ +import FlexCommand from 'sap/ui/rta/command/FlexCommand'; +import CommandFactory from 'sap/ui/rta/command/CommandFactory'; +import type FilterBar from 'sap/ui/comp/filterbar/FilterBar'; + +import { SIMPLE_QUICK_ACTION_KIND, SimpleQuickAction } from '@sap-ux-private/control-property-editor-common'; + +import { QuickActionContext, SimpleQuickActionDefinition } from '../../../cpe/quick-actions/quick-action-definition'; + +import { pageHasControlId } from '../../../cpe/quick-actions/utils'; +import { getControlById } from '../../../utils/core'; + +export const ENABLE_CLEAR_FILTER_BAR_TYPE = 'enable-clear-filter-bar'; +const PROPERTY_NAME = 'showClearOnFB'; + +const CONTROL_TYPE = 'sap.ui.comp.smartfilterbar.SmartFilterBar'; + +/** + * Quick Action for toggling the visibility of "clear filter bar" button in List Report page. + */ +export class ToggleClearFilterBarQuickAction implements SimpleQuickActionDefinition { + readonly kind = SIMPLE_QUICK_ACTION_KIND; + readonly type = ENABLE_CLEAR_FILTER_BAR_TYPE; + + public get id(): string { + return `${this.context.key}-${this.type}`; + } + + public get isActive(): boolean { + return !!this.filterBar; + } + + private isClearButtonEnabled = false; + private filterBar: FilterBar | undefined; + constructor(private context: QuickActionContext) {} + + initialize(): void { + const controls = this.context.controlIndex[CONTROL_TYPE] ?? []; + for (const control of controls) { + const isActionApplicable = pageHasControlId(this.context.view, control.controlId); + const modifiedControl = getControlById(control.controlId); + if (isActionApplicable && modifiedControl) { + this.isClearButtonEnabled = modifiedControl.getShowClearOnFB(); + this.filterBar = modifiedControl; + } + } + } + + getActionObject(): SimpleQuickAction { + const key = this.isClearButtonEnabled + ? 'V2_QUICK_ACTION_LR_DISABLE_CLEAR_FILTER_BAR' + : 'V2_QUICK_ACTION_LR_ENABLE_CLEAR_FILTER_BAR'; + return { + kind: SIMPLE_QUICK_ACTION_KIND, + id: this.id, + enabled: this.isActive, + title: this.context.resourceBundle.getText(key) + }; + } + + async execute(): Promise { + if (this.filterBar) { + const { flexSettings } = this.context; + + const modifiedValue = { + generator: flexSettings.generator, + propertyName: PROPERTY_NAME, + newValue: !this.isClearButtonEnabled + }; + + const command = await CommandFactory.getCommandFor( + this.filterBar, + 'Property', + modifiedValue, + null, + flexSettings + ); + + this.isClearButtonEnabled = !this.isClearButtonEnabled; + return [command]; + } + + return []; + } +} 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 new file mode 100644 index 0000000000..43f90858da --- /dev/null +++ b/packages/preview-middleware-client/src/adp/quick-actions/fe-v2/registry.ts @@ -0,0 +1,69 @@ +import XMLView from 'sap/ui/core/mvc/XMLView'; + +import type { + QuickActionActivationContext, + QuickActionDefinitionGroup +} from '../../../cpe/quick-actions/quick-action-definition'; +import { QuickActionDefinitionRegistry } from '../../../cpe/quick-actions/registry'; + +import { AddControllerToPageQuickAction } from '../common/add-controller-to-page'; + +import { ToggleClearFilterBarQuickAction } from './lr-toggle-clear-filter-bar'; +import { ChangeTableColumnsQuickAction } from './change-table-columns'; +import { AddHeaderFieldQuickAction } from '../common/op-add-header-field'; +import Control from 'sap/ui/core/Control'; + +type PageName = 'listReport' | 'objectPage'; + +const OBJECT_PAGE_TYPE = 'sap.suite.ui.generic.template.ObjectPage.view.Details'; +const LIST_REPORT_TYPE = 'sap.suite.ui.generic.template.ListReport.view.ListReport'; + +/** + * Quick Action provider for SAP Fiori Elements V2 applications. + */ +export default class FEV2QuickActionRegistry extends QuickActionDefinitionRegistry { + PAGE_NAME_MAP: Record = { + [LIST_REPORT_TYPE]: 'listReport', + [OBJECT_PAGE_TYPE]: 'objectPage' + }; + getDefinitions(context: QuickActionActivationContext): QuickActionDefinitionGroup[] { + const activePages = this.getActivePageContent(context.controlIndex); + + const definitionGroups: QuickActionDefinitionGroup[] = []; + for (let index = 0; index < activePages.length; index++) { + const { name, view } = activePages[index]; + if (name === 'listReport') { + definitionGroups.push({ + title: 'LIST REPORT', + definitions: [ + ToggleClearFilterBarQuickAction, + AddControllerToPageQuickAction, + ChangeTableColumnsQuickAction + ], + view, + key: name + index + }); + } else if (name === 'objectPage') { + definitionGroups.push({ + title: 'OBJECT PAGE', + definitions: [ + AddControllerToPageQuickAction, + ChangeTableColumnsQuickAction, + AddHeaderFieldQuickAction + ], + view, + key: name + index + }); + } + } + return definitionGroups; + } + + protected getComponentContainerFromPage(page: Control): Control | undefined { + if (page instanceof XMLView) { + return page.getContent()[0]; + } + return undefined; + } + +} diff --git a/packages/preview-middleware-client/src/adp/quick-actions/fe-v4/change-table-columns.ts b/packages/preview-middleware-client/src/adp/quick-actions/fe-v4/change-table-columns.ts new file mode 100644 index 0000000000..fb9fa0c004 --- /dev/null +++ b/packages/preview-middleware-client/src/adp/quick-actions/fe-v4/change-table-columns.ts @@ -0,0 +1,90 @@ +import OverlayUtil from 'sap/ui/dt/OverlayUtil'; +import FlexCommand from 'sap/ui/rta/command/FlexCommand'; +import Table from 'sap/ui/mdc/Table'; +import FlexRuntimeInfoAPI from 'sap/ui/fl/apply/api/FlexRuntimeInfoAPI'; + +import type { NestedQuickActionChild, NestedQuickAction } from '@sap-ux-private/control-property-editor-common'; +import { NESTED_QUICK_ACTION_KIND } from '@sap-ux-private/control-property-editor-common'; + +import { QuickActionContext, NestedQuickActionDefinition } from '../../../cpe/quick-actions/quick-action-definition'; +import { getRelevantControlFromActivePage } from '../../../cpe/quick-actions/utils'; +import { getControlById } from '../../../utils/core'; + +export const CHANGE_TABLE_COLUMNS = 'change-table-columns'; +const ACTION_ID = 'CTX_SETTINGS0'; +const CONTROL_TYPE = 'sap.ui.mdc.Table'; + + +/** + * Quick Action for changing table columns. + */ +export class ChangeTableColumnsQuickAction implements NestedQuickActionDefinition { + readonly kind = NESTED_QUICK_ACTION_KIND; + readonly type = CHANGE_TABLE_COLUMNS; + public get id(): string { + return `${this.context.key}-${this.type}`; + } + isActive = false; + isClearButtonEnabled = false; + children: NestedQuickActionChild[] = []; + tableMap: Record = {}; + constructor(private context: QuickActionContext) {} + + async initialize(): Promise { + let index = 0; + for (const smartTable of getRelevantControlFromActivePage(this.context.controlIndex, this.context.view, [ + CONTROL_TYPE + ])) { + const hasVariantManagement = FlexRuntimeInfoAPI.hasVariantManagement({ element: smartTable }); + if (!hasVariantManagement) { + continue; + } + const actions = await this.context.actionService.get(smartTable.getId()); + const changeColumnAction = actions.find((action) => action.id === ACTION_ID); + if (changeColumnAction) { + this.children.push({ + label: `'${(smartTable as Table).getHeader()}' table`, + children: [] + }); + this.tableMap[`${this.children.length - 1}`] = index; + index++; + } + } + if (this.children.length > 0) { + this.isActive = true; + } + } + + getActionObject(): NestedQuickAction { + return { + kind: NESTED_QUICK_ACTION_KIND, + id: this.id, + enabled: this.isActive, + title: this.context.resourceBundle.getText('V4_QUICK_ACTION_CHANGE_TABLE_COLUMNS'), + children: this.children + }; + } + + async execute(path: string): Promise { + const index = this.tableMap[path]; + const smartTables = getRelevantControlFromActivePage(this.context.controlIndex, this.context.view, [ + CONTROL_TYPE + ]); + for (let i = 0; i < smartTables.length; i++) { + if (i === index) { + const section = getControlById(smartTables[i].getId()); + const controlOverlay = OverlayUtil.getClosestOverlayFor(section); + if (controlOverlay) { + controlOverlay.setSelected(true); + } + const hasVariantManagement = FlexRuntimeInfoAPI.hasVariantManagement({ element: smartTables[i] }); + if (!hasVariantManagement) { + continue; + } + await this.context.actionService.execute(smartTables[i].getId(), ACTION_ID); + } + } + + return []; + } +} diff --git a/packages/preview-middleware-client/src/adp/quick-actions/fe-v4/lr-toggle-clear-filter-bar.ts b/packages/preview-middleware-client/src/adp/quick-actions/fe-v4/lr-toggle-clear-filter-bar.ts new file mode 100644 index 0000000000..b609d4cd64 --- /dev/null +++ b/packages/preview-middleware-client/src/adp/quick-actions/fe-v4/lr-toggle-clear-filter-bar.ts @@ -0,0 +1,97 @@ +import FlexCommand from 'sap/ui/rta/command/FlexCommand'; +import CommandFactory from 'sap/ui/rta/command/CommandFactory'; +import FilterBar from 'sap/ui/mdc/FilterBar'; + +import { SIMPLE_QUICK_ACTION_KIND, SimpleQuickAction } from '@sap-ux-private/control-property-editor-common'; + +import { QuickActionContext, SimpleQuickActionDefinition } from '../../../cpe/quick-actions/quick-action-definition'; +import { pageHasControlId } from '../../../cpe/quick-actions/utils'; +import { getControlById } from '../../../utils/core'; +import { getAppComponent, getPageName, getReference } from './utils'; + +export const ENABLE_CLEAR_FILTER_BAR_TYPE = 'enable-clear-filter-bar'; +const PROPERTY_PATH = 'controlConfiguration/@com.sap.vocabularies.UI.v1.SelectionFields/showClearButton'; +const CONTROL_TYPE = 'sap.fe.macros.controls.FilterBar'; + +/** + * Quick Action for toggling the visibility of "clear filter bar" button in List Report page. + */ +export class ToggleClearFilterBarQuickAction implements SimpleQuickActionDefinition { + readonly kind = SIMPLE_QUICK_ACTION_KIND; + readonly type = ENABLE_CLEAR_FILTER_BAR_TYPE; + readonly forceRefreshAfterExecution = true; + public get id(): string { + return `${this.context.key}-${this.type}`; + } + isActive = false; + private isClearButtonEnabled = false; + constructor(private context: QuickActionContext) {} + + initialize(): void { + const controls = this.context.controlIndex[CONTROL_TYPE] ?? []; + for (const control of controls) { + const isActionApplicable = pageHasControlId(this.context.view, control.controlId); + const filterBar = getControlById(control.controlId); + if (isActionApplicable && filterBar) { + this.isActive = true; + this.isClearButtonEnabled = filterBar.getShowClearButton(); + } + } + } + + getActionObject(): SimpleQuickAction { + const key = this.isClearButtonEnabled + ? 'V4_QUICK_ACTION_LR_DISABLE_CLEAR_FILTER_BAR' + : 'V4_QUICK_ACTION_LR_ENABLE_CLEAR_FILTER_BAR'; + return { + kind: SIMPLE_QUICK_ACTION_KIND, + id: this.id, + enabled: this.isActive, + title: this.context.resourceBundle.getText(key) + }; + } + + async execute(): Promise { + const controls = this.context.controlIndex[CONTROL_TYPE] ?? []; + const control = controls[0]; + if (control) { + const modifiedControl = getControlById(control.controlId); + if (!modifiedControl) { + return []; + } + + const { flexSettings } = this.context; + const parent = modifiedControl.getParent(); + if (!parent) { + return []; + } + + const modifiedValue = { + reference: getReference(modifiedControl), + appComponent: getAppComponent(modifiedControl), + changeType: 'appdescr_fe_changePageConfiguration', + parameters: { + page: getPageName(parent), + entityPropertyChange: { + propertyPath: PROPERTY_PATH, + propertyValue: !this.isClearButtonEnabled, + operation: 'UPSERT' + } + } + }; + + const command = await CommandFactory.getCommandFor( + modifiedControl, + 'appDescriptor', + modifiedValue, + null, + flexSettings + ); + + this.isClearButtonEnabled = !this.isClearButtonEnabled; + return [command]; + } + + return []; + } +} 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 new file mode 100644 index 0000000000..622afb10cf --- /dev/null +++ b/packages/preview-middleware-client/src/adp/quick-actions/fe-v4/registry.ts @@ -0,0 +1,58 @@ +import type { + QuickActionActivationContext, + QuickActionDefinitionGroup +} from '../../../cpe/quick-actions/quick-action-definition'; +import { QuickActionDefinitionRegistry } from '../../../cpe/quick-actions/registry'; + +import { AddControllerToPageQuickAction } from '../common/add-controller-to-page'; +import { ToggleClearFilterBarQuickAction } from './lr-toggle-clear-filter-bar'; +import { ChangeTableColumnsQuickAction } from './change-table-columns'; +import { AddHeaderFieldQuickAction } from '../common/op-add-header-field'; + +type PageName = 'listReport' | 'objectPage'; + +const LIST_REPORT_TYPE = 'sap.fe.templates.ListReport.ListReport'; +const OBJECT_PAGE_TYPE = 'sap.fe.templates.ObjectPage.ObjectPage'; + +/** + * Quick Action provider for SAP Fiori Elements V4 applications. + */ +export default class FEV4QuickActionRegistry extends QuickActionDefinitionRegistry { + PAGE_NAME_MAP: Record = { + [LIST_REPORT_TYPE]: 'listReport', + [OBJECT_PAGE_TYPE]: 'objectPage' + }; + getDefinitions(context: QuickActionActivationContext): QuickActionDefinitionGroup[] { + const activePages = this.getActivePageContent(context.controlIndex); + + const definitionGroups: QuickActionDefinitionGroup[] = []; + for (let index = 0; index < activePages.length; index++) { + const { name, view } = activePages[index]; + + if (name === 'listReport') { + definitionGroups.push({ + title: 'LIST REPORT', + definitions: [ + ToggleClearFilterBarQuickAction, + AddControllerToPageQuickAction, + ChangeTableColumnsQuickAction + ], + view, + key: name + index + }); + } else if (name === 'objectPage') { + definitionGroups.push({ + title: 'OBJECT PAGE', + definitions: [ + AddControllerToPageQuickAction, + ChangeTableColumnsQuickAction, + AddHeaderFieldQuickAction + ], + view, + key: name + index + }); + } + } + return definitionGroups; + } +} diff --git a/packages/preview-middleware-client/src/adp/quick-actions/fe-v4/utils.ts b/packages/preview-middleware-client/src/adp/quick-actions/fe-v4/utils.ts new file mode 100644 index 0000000000..0462725199 --- /dev/null +++ b/packages/preview-middleware-client/src/adp/quick-actions/fe-v4/utils.ts @@ -0,0 +1,47 @@ +import type ManagedObject from 'sap/ui/base/ManagedObject'; +import type { Manifest } from 'sap/ui/rta/RuntimeAuthoring'; +import Component from 'sap/ui/core/Component'; +import type TemplateComponent from 'sap/fe/core/TemplateComponent'; +import type AppComponent from 'sap/fe/core/AppComponent'; + +import { isA } from '../../../utils/core'; + +/** + * Get the containing app component of a control. + * + * @param control - UI5 control instance. + * @returns App component to which the control belongs. + */ +export function getAppComponent(control: ManagedObject): AppComponent | undefined { + const ownerComponent = Component.getOwnerComponentFor(control); + if (isA('sap.fe.core.TemplateComponent', ownerComponent)) { + return ownerComponent.getAppComponent(); + } + return undefined; +} +/** + * Get the containing page name of a control. + * + * @param control - UI5 control instance. + * @returns Page name to which the control belongs. + */ +export function getPageName(control: ManagedObject): string | undefined { + const component = Component.getOwnerComponentFor(control); + if (!isA('sap.fe.core.TemplateComponent', component)) { + return undefined; + } + const view = component.getRootControl(); + return view.getId().split('::').pop(); +} + +/** + * Gets a reference id for a control. + * + * @param control - UI5 control instance. + * @returns Reference id. + */ +export function getReference(control: ManagedObject): string { + // probably same as flex setting id or base id TODO: CONFIRM + const manifest = getAppComponent(control)?.getManifest() as Manifest; + return manifest?.['sap.app']?.id ?? ''; +} diff --git a/packages/preview-middleware-client/src/adp/quick-actions/load.ts b/packages/preview-middleware-client/src/adp/quick-actions/load.ts new file mode 100644 index 0000000000..9251c42bfb --- /dev/null +++ b/packages/preview-middleware-client/src/adp/quick-actions/load.ts @@ -0,0 +1,23 @@ +import type { QuickActionDefinitionRegistry } from '../../cpe/quick-actions/registry'; +import type { ApplicationType } from '../../utils/application'; + +/** + * Loads the appropriate Quick Action registries for the given application type. + * + * @param appType - Application type. + * @returns Quick Action registries. + */ +export async function loadDefinitions(appType: ApplicationType): Promise[]> { + if (appType === 'fe-v2') { + const FEV2QuickActionRegistry = (await import('open/ux/preview/client/adp/quick-actions/fe-v2/registry')) + .default; + + return [new FEV2QuickActionRegistry()]; + } + if (appType === 'fe-v4') { + const FEV4QuickActionRegistry = (await import('open/ux/preview/client/adp/quick-actions/fe-v4/registry')) + .default; + return [new FEV4QuickActionRegistry()]; + } + return []; +} diff --git a/packages/preview-middleware-client/src/adp/utils.ts b/packages/preview-middleware-client/src/adp/utils.ts index 2373f0faaa..300db7b954 100644 --- a/packages/preview-middleware-client/src/adp/utils.ts +++ b/packages/preview-middleware-client/src/adp/utils.ts @@ -1,5 +1,14 @@ import MessageToast from 'sap/m/MessageToast'; +import Element from 'sap/ui/core/Element'; import type FlexCommand from 'sap/ui/rta/command/FlexCommand'; +import type DTElement from 'sap/ui/dt/Element'; +import type ManagedObject from 'sap/ui/base/ManagedObject'; +import type ElementOverlay from 'sap/ui/dt/ElementOverlay'; +import Log from 'sap/base/Log'; +import FlexUtils from 'sap/ui/fl/Utils'; + +import { getError } from '../utils/error'; +import { isLowerThanMinimalUi5Version, Ui5VersionInfo } from '../utils/version'; export interface Deferred { promise: Promise; @@ -63,3 +72,77 @@ export function notifyUser(message: string, duration: number = 5000) { duration }); } + +/** + * Check if element is sync view + * + * @param element Design time Element + * @returns boolean if element is sync view or not + */ +function isSyncView(element: DTElement): boolean { + return element?.getMetadata()?.getName()?.includes('XMLView') && element?.oAsyncState === undefined; +} + +/** + * Get Ids for all sync views + * + * @param ui5VersionInfo UI5 Version Information + * + * @returns array of Ids for application sync views + */ +export async function getAllSyncViewsIds(ui5VersionInfo: Ui5VersionInfo): Promise { + const syncViewIds: string[] = []; + try { + if (isLowerThanMinimalUi5Version(ui5VersionInfo, { major: 1, minor: 120 })) { + const elements = Element.registry.filter(() => true) as DTElement[]; + elements.forEach((ui5Element) => { + if (isSyncView(ui5Element)) { + syncViewIds.push(ui5Element.getId()); + } + }); + } else { + const ElementRegistry = (await import('sap/ui/core/ElementRegistry')).default; + const elements = ElementRegistry.all() as Record; + Object.entries(elements).forEach(([key, ui5Element]) => { + if (isSyncView(ui5Element)) { + syncViewIds.push(key); + } + }); + } + } catch (error) { + Log.error('Could not get application sync views', getError(error)); + } + + return syncViewIds; +} + +interface ControllerInfo { + controllerName: string; + viewId: string; +} + +/** + * Gets controller name and view ID for the given UI5 control. + * + * @param control UI5 control. + * @returns The controller name and view ID. + */ + +export function getControllerInfoForControl(control: ManagedObject): ControllerInfo { + const view = FlexUtils.getViewForControl(control); + const controllerName = view.getController().getMetadata().getName(); + const viewId = view.getId(); + return { controllerName, viewId }; +} + +/** + * Gets controller name and view ID for the given overlay control. + * + * @param overlayControl The overlay control. + * @returns The controller name and view ID. + */ + +export function getControllerInfo(overlayControl: ElementOverlay): ControllerInfo { + const control = overlayControl.getElement(); + return getControllerInfoForControl(control); +} diff --git a/packages/preview-middleware-client/src/cpe/changes/service.ts b/packages/preview-middleware-client/src/cpe/changes/service.ts index 3671b42009..3fee887b93 100644 --- a/packages/preview-middleware-client/src/cpe/changes/service.ts +++ b/packages/preview-middleware-client/src/cpe/changes/service.ts @@ -265,8 +265,8 @@ export class ChangeService { try { if (typeof command.getCommands === 'function') { const subCommands = command.getCommands(); - subCommands.forEach((command) => { - const pendingChange = this.prepareChangeType(command, inactiveCommandCount, i); + subCommands.forEach((subCommand) => { + const pendingChange = this.prepareChangeType(subCommand, inactiveCommandCount, i); if (pendingChange) { activeChanges.push(pendingChange); } diff --git a/packages/preview-middleware-client/src/cpe/control-data.ts b/packages/preview-middleware-client/src/cpe/control-data.ts index f7b363b1f8..e1ce85dc04 100644 --- a/packages/preview-middleware-client/src/cpe/control-data.ts +++ b/packages/preview-middleware-client/src/cpe/control-data.ts @@ -18,9 +18,7 @@ import { UI5ControlProperty } from './types'; import DataType from 'sap/ui/base/DataType'; type AnalyzedType = Pick; -interface Name { - getName(): string; -} + /** * A property is disabled if it is an array or the type is 'any' * - since we currently don't have a good editor for it Otherwise, it is enabled. @@ -73,7 +71,7 @@ function analyzePropertyType(property: ManagedObjectMetadataProperties): Analyze const analyzedType: AnalyzedType = { primitiveType: 'any', ui5Type: null, - enumValues: null, + enumValues: undefined, isArray: false }; const propertyType = property?.getType(); @@ -105,8 +103,11 @@ function analyzePropertyType(property: ManagedObjectMetadataProperties): Analyze if (propertyDataType && !(propertyDataType instanceof DataType)) { return analyzedType; } + // enum values are created differently and use DataType as prototype, which only has stubs for instance functions -> getName returns undefined + // array and base types also return undefined, but we have already handled those above + // https://github.com/SAP/openui5/blob/203ce22763a76e28b7a422f6c635a42480f733f1/src/sap.ui.core/src/sap/ui/base/DataType.js#L430 // eslint-disable-next-line @typescript-eslint/no-unsafe-call - const name = (Object.getPrototypeOf(propertyDataType) as Name).getName(); + const name = (Object.getPrototypeOf(propertyDataType) as DataType).getName(); if (!name) { analyzedType.primitiveType = 'enum'; } else { @@ -115,8 +116,8 @@ function analyzePropertyType(property: ManagedObjectMetadataProperties): Analyze analyzedType.ui5Type = typeName; // Determine base type for SAP types - if (analyzedType.primitiveType === 'enum') { - analyzedType.enumValues = sap.ui.require(analyzedType.ui5Type.split('.').join('/')); + if (analyzedType.primitiveType === 'enum' && typeof propertyDataType?.getEnumValues === 'function') { + analyzedType.enumValues = propertyDataType.getEnumValues(); } } diff --git a/packages/preview-middleware-client/src/cpe/init.ts b/packages/preview-middleware-client/src/cpe/init.ts index dcfe3a6c26..859e187ea4 100644 --- a/packages/preview-middleware-client/src/cpe/init.ts +++ b/packages/preview-middleware-client/src/cpe/init.ts @@ -1,3 +1,6 @@ +import Log from 'sap/base/Log'; +import type RuntimeAuthoring from 'sap/ui/rta/RuntimeAuthoring'; + import type { ExternalAction } from '@sap-ux-private/control-property-editor-common'; import { startPostMessageCommunication, @@ -5,21 +8,24 @@ import { enableTelemetry, appLoaded } from '@sap-ux-private/control-property-editor-common'; -import type RuntimeAuthoring from 'sap/ui/rta/RuntimeAuthoring'; import type { ActionHandler, Service } from './types'; -import { initOutline } from './outline/index'; +import { OutlineService } from './outline/service'; import { SelectionService } from './selection'; import { ChangeService } from './changes/service'; import { loadDefaultLibraries } from './documentation'; -import Log from 'sap/base/Log'; import { logger } from './logger'; import { getIcons } from './ui5-utils'; import { WorkspaceConnectorService } from './connector-service'; import { RtaService } from './rta-service'; import { getError } from '../utils/error'; +import { QuickActionService } from './quick-actions/quick-action-service'; +import type { QuickActionDefinitionRegistry } from './quick-actions/registry'; -export default function init(rta: RuntimeAuthoring): Promise { +export default function init( + rta: RuntimeAuthoring, + registries: QuickActionDefinitionRegistry[] = [] +): Promise { Log.info('Initializing Control Property Editor'); // enable telemetry if requested @@ -42,7 +48,17 @@ export default function init(rta: RuntimeAuthoring): Promise { const changesService = new ChangeService({ rta }, selectionService); const connectorService = new WorkspaceConnectorService(); const rtaService = new RtaService(rta); - const services: Service[] = [selectionService, changesService, connectorService, rtaService]; + const outlineService = new OutlineService(rta); + const quickActionService = new QuickActionService(rta, outlineService, registries); + const services: Service[] = [ + selectionService, + changesService, + connectorService, + outlineService, + rtaService, + quickActionService + ]; + try { loadDefaultLibraries(); const { sendAction } = startPostMessageCommunication( @@ -60,13 +76,11 @@ export default function init(rta: RuntimeAuthoring): Promise { ); for (const service of services) { - service.init(sendAction, subscribe); + service + .init(sendAction, subscribe) + ?.catch((reason) => Log.error('Service Initalization Failed: ', getError(reason))); } - // For initOutline to complete the RTA needs to already running (to access RTA provided services). - // That can only happen if the plugin initialization has completed. - initOutline(rta, sendAction).catch((error) => - Log.error('Error during initialization of Control Property Editor', getError(error)) - ); + const icons = getIcons(); sendAction(iconsLoaded(icons)); diff --git a/packages/preview-middleware-client/src/cpe/outline/utils.ts b/packages/preview-middleware-client/src/cpe/outline/editable.ts similarity index 53% rename from packages/preview-middleware-client/src/cpe/outline/utils.ts rename to packages/preview-middleware-client/src/cpe/outline/editable.ts index b7791b2d36..150cb53eaf 100644 --- a/packages/preview-middleware-client/src/cpe/outline/utils.ts +++ b/packages/preview-middleware-client/src/cpe/outline/editable.ts @@ -2,10 +2,7 @@ import { buildControlData } from '../control-data'; import { getRuntimeControl } from '../utils'; import OverlayUtil from 'sap/ui/dt/OverlayUtil'; import OverlayRegistry from 'sap/ui/dt/OverlayRegistry'; -import { getComponent } from '../ui5-utils'; -import Component from 'sap/ui/core/Component'; -import { Manifest } from 'sap/ui/rta/RuntimeAuthoring'; -import { isLowerThanMinimalUi5Version, Ui5VersionInfo } from '../../utils/version'; +import { getComponent } from '../../utils/core'; export const isEditable = (id = ''): boolean => { let editable = false; @@ -30,28 +27,3 @@ export const isEditable = (id = ''): boolean => { } return editable; }; - -/** - * Function that checks if control is reuse component - * - * @param controlId id control - * @param ui5VersionInfo UI5 version information - * @returns boolean if control is from reused component view - */ -export const isReuseComponent = (controlId: string, ui5VersionInfo: Ui5VersionInfo): boolean => { - if (isLowerThanMinimalUi5Version(ui5VersionInfo, { major: 1, minor: 115 })) { - return false; - } - - const component = Component.getComponentById(controlId); - if (!component) { - return false; - } - - const manifest = component.getManifest() as Manifest; - if (!manifest) { - return false; - } - - return manifest['sap.app']?.type === 'component'; -}; diff --git a/packages/preview-middleware-client/src/cpe/outline/index.ts b/packages/preview-middleware-client/src/cpe/outline/index.ts deleted file mode 100644 index 5c479495f2..0000000000 --- a/packages/preview-middleware-client/src/cpe/outline/index.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { ExternalAction } from '@sap-ux-private/control-property-editor-common'; -import { outlineChanged, showMessage } from '@sap-ux-private/control-property-editor-common'; - -import type RuntimeAuthoring from 'sap/ui/rta/RuntimeAuthoring'; -import type OutlineService from 'sap/ui/rta/command/OutlineService'; - -import { transformNodes } from './nodes'; -import Log from 'sap/base/Log'; -import { getError } from '../../utils/error'; - -/** - * - * @param rta runtimeAuthoring object. - * @param sendAction send action method. - */ -export async function initOutline(rta: RuntimeAuthoring, sendAction: (action: ExternalAction) => void): Promise { - const outline = await rta.getService('outline'); - const scenario = rta.getFlexSettings().scenario; - let hasSentWarning = false; - const reuseComponentsIds = new Set(); - async function syncOutline() { - try { - const viewNodes = await outline.get(); - const outlineNodes = await transformNodes(viewNodes, scenario, reuseComponentsIds); - sendAction(outlineChanged(outlineNodes)); - if(reuseComponentsIds.size > 0 && scenario === 'ADAPTATION_PROJECT' && !hasSentWarning) { - sendAction(showMessage({ message: 'Have in mind that reuse components are detected for some views in this application and controller extensions and adding fragments are not supported for such views. Controller extension and adding fragment functionality on these views will be disabled.', shouldHideIframe: false})); - hasSentWarning = true; - } - - } catch (error) { - Log.error('Outline sync failed!', getError(error)); - } - } - await syncOutline(); - outline.attachEvent('update', syncOutline); -} diff --git a/packages/preview-middleware-client/src/cpe/outline/nodes.ts b/packages/preview-middleware-client/src/cpe/outline/nodes.ts index 18df0b70af..80e0ec220f 100644 --- a/packages/preview-middleware-client/src/cpe/outline/nodes.ts +++ b/packages/preview-middleware-client/src/cpe/outline/nodes.ts @@ -1,8 +1,14 @@ import type { OutlineNode } from '@sap-ux-private/control-property-editor-common'; import type { OutlineViewNode } from 'sap/ui/rta/command/OutlineService'; import type { Scenario } from 'sap/ui/fl/Scenario'; -import { isEditable, isReuseComponent } from './utils'; + import { getUi5Version, Ui5VersionInfo } from '../../utils/version'; +import { getControlById } from '../../utils/core'; + +import type { ControlTreeIndex } from '../types'; +import { isReuseComponent } from '../utils'; + +import { isEditable } from './editable'; interface AdditionalData { text?: string; @@ -16,7 +22,7 @@ interface AdditionalData { * @returns An object containing the text and the technical name of the control. */ function getAdditionalData(id: string): AdditionalData { - const control = sap.ui.getCore().byId(id); + const control = getControlById(id); if (!control) { return {}; } @@ -71,6 +77,20 @@ function addChildToExtensionPoint(id: string, children: OutlineNode[]) { hasDefaultContent: false }); } +/** + * Creates conrol index for all controls in the app. + * + * @param {ControlTreeIndex} controlIndex - Control index for the ui5 app. + * @param {OutlineNode} node - control node added to the outline. + */ +function indexNode(controlIndex: ControlTreeIndex, node: OutlineNode): void { + const indexedControls = controlIndex[node.controlType]; + if (indexedControls) { + indexedControls.push(node); + } else { + controlIndex[node.controlType] = [node]; + } +} /** * Transform node. @@ -78,12 +98,14 @@ function addChildToExtensionPoint(id: string, children: OutlineNode[]) { * @param input outline view node * @param scenario type of project * @param reuseComponentsIds ids of reuse components that are filled when outline nodes are transformed - * @returns {Promise} transformed outline tree nodes + * @param controlIndex Control tree index + * @returns transformed outline tree nodes */ export async function transformNodes( input: OutlineViewNode[], scenario: Scenario, - reuseComponentsIds: Set + reuseComponentsIds: Set, + controlIndex: ControlTreeIndex ): Promise { const stack = [...input]; const items: OutlineNode[] = []; @@ -100,8 +122,8 @@ export async function transformNodes( const technicalName = current.technicalName.split('.').slice(-1)[0]; const transformedChildren = isAdp - ? await handleDuplicateNodes(children, scenario, reuseComponentsIds) - : await transformNodes(children, scenario, reuseComponentsIds); + ? await handleDuplicateNodes(children, scenario, reuseComponentsIds, controlIndex) + : await transformNodes(children, scenario, reuseComponentsIds, controlIndex); const node: OutlineNode = { controlId: current.id, @@ -112,6 +134,7 @@ export async function transformNodes( children: transformedChildren }; + indexNode(controlIndex, node); fillReuseComponents(reuseComponentsIds, current, scenario, ui5VersionInfo); items.push(node); @@ -167,12 +190,14 @@ function fillReuseComponents( * @param children outline view node children * @param scenario type of project * @param reuseComponentsIds ids of reuse components that are filled when outline nodes are transformed + * @param controlIndex Control tree index * @returns transformed outline tree nodes */ export async function handleDuplicateNodes( children: OutlineViewNode[], scenario: Scenario, - reuseComponentsIds: Set + reuseComponentsIds: Set, + controlIndex: ControlTreeIndex ): Promise { const extPointIDs = new Set(); @@ -185,5 +210,5 @@ export async function handleDuplicateNodes( const uniqueChildren = children.filter((child) => !extPointIDs.has(child.id)); - return transformNodes(uniqueChildren, scenario, reuseComponentsIds); + return transformNodes(uniqueChildren, scenario, reuseComponentsIds, controlIndex); } diff --git a/packages/preview-middleware-client/src/cpe/outline/service.ts b/packages/preview-middleware-client/src/cpe/outline/service.ts new file mode 100644 index 0000000000..1628c17fd3 --- /dev/null +++ b/packages/preview-middleware-client/src/cpe/outline/service.ts @@ -0,0 +1,73 @@ +import Log from 'sap/base/Log'; +import type RuntimeAuthoring from 'sap/ui/rta/RuntimeAuthoring'; +import type RTAOutlineService from 'sap/ui/rta/command/OutlineService'; + +import type { ExternalAction } from '@sap-ux-private/control-property-editor-common'; +import { outlineChanged, SCENARIO, showMessage } from '@sap-ux-private/control-property-editor-common'; + +import { getError } from '../../utils/error'; +import { getTextBundle } from '../../i18n'; +import { ControlTreeIndex } from '../types'; +import { transformNodes } from './nodes'; + +export const OUTLINE_CHANGE_EVENT = 'OUTLINE_CHANGED'; + +export interface OutlineChangedEventDetail { + controlIndex: ControlTreeIndex; +} +/** + * A Class of WorkspaceConnectorService + */ +export class OutlineService extends EventTarget { + constructor(private rta: RuntimeAuthoring) { + super(); + } + + /** + * Initializes connector service. + * + * @param sendAction action sender function + */ + public async init(sendAction: (action: ExternalAction) => void): Promise { + const outline = await this.rta.getService('outline'); + const scenario = this.rta.getFlexSettings().scenario; + const resourceBundle = await getTextBundle(); + const key = 'ADP_REUSE_COMPONENTS_MESSAGE'; + const message = resourceBundle.getText(key) ?? key; + let hasSentWarning = false; + const reuseComponentsIds = new Set(); + const syncOutline = async () => { + try { + const viewNodes = await outline.get(); + const controlIndex: ControlTreeIndex = {}; + const outlineNodes = await transformNodes(viewNodes, scenario, reuseComponentsIds, controlIndex); + + const event = new CustomEvent(OUTLINE_CHANGE_EVENT, { + detail: { + controlIndex + } + }); + + this.dispatchEvent(event); + sendAction(outlineChanged(outlineNodes)); + if (reuseComponentsIds.size > 0 && scenario === SCENARIO.AdaptationProject && !hasSentWarning) { + sendAction( + showMessage({ + message, + shouldHideIframe: false + }) + ); + hasSentWarning = true; + } + } catch (error) { + Log.error('Outline sync failed!', getError(error)); + } + }; + await syncOutline(); + outline.attachEvent('update', syncOutline); + } + + public onOutlineChange(handler: (event: CustomEvent) => void | Promise): void { + this.addEventListener(OUTLINE_CHANGE_EVENT, handler as EventListener); + } +} diff --git a/packages/preview-middleware-client/src/cpe/quick-actions/quick-action-definition.ts b/packages/preview-middleware-client/src/cpe/quick-actions/quick-action-definition.ts new file mode 100644 index 0000000000..6434c02933 --- /dev/null +++ b/packages/preview-middleware-client/src/cpe/quick-actions/quick-action-definition.ts @@ -0,0 +1,115 @@ +import type FlexCommand from 'sap/ui/rta/command/FlexCommand'; +import type RuntimeAuthoring from 'sap/ui/rta/RuntimeAuthoring'; +import type { FlexSettings, Manifest } from 'sap/ui/rta/RuntimeAuthoring'; +import type { ActionService } from 'sap/ui/rta/service/Action'; +import type XMLView from 'sap/ui/core/mvc/XMLView'; + +import type { + NestedQuickAction, + SimpleQuickAction, + NESTED_QUICK_ACTION_KIND, + SIMPLE_QUICK_ACTION_KIND +} from '@sap-ux-private/control-property-editor-common'; + +import type { TextBundle } from '../../i18n'; +import type { ControlTreeIndex } from '../types'; + +export interface QuickActionActivationContext { + controlIndex: ControlTreeIndex; + actionService: ActionService; + manifest: Manifest; +} + +export interface QuickActionContext { + controlIndex: ControlTreeIndex; + actionService: ActionService; + resourceBundle: TextBundle; + view: XMLView; + key: string; + + /** + * RTA should not be used directly by quick actions. + * + * Currently it is only used for actions opening ADP dialogs, + * but this should be removed in the future. + * + * @deprecated + */ + rta: RuntimeAuthoring; + flexSettings: FlexSettings; + manifest: Manifest; +} + +export type QuickActionActivationData = { + isActive: boolean; + title: string; +}; + +interface QuickActionDefinitionBase { + /** + * Used to identify between different Quick Action definitions. + */ + readonly type: string; + /** + * Used to identify Quick Action instances. + * All currently loaded actions must have unique ids. + */ + readonly id: string; + /** + * Most actions have side effects that already triggers Quick Action reload, + * however if that is not the case this property should be set to "true" to force Quick Action reload after the action is executed. + */ + readonly forceRefreshAfterExecution?: boolean; + /** + * Indicates that the Quick Action is applicable to the given context and should be displayed. + */ + isActive: boolean; + /** + * Initializes the action and checks if it should be enabled in current context. + */ + initialize: () => void | Promise; +} + +export interface SimpleQuickActionDefinition extends QuickActionDefinitionBase { + readonly kind: typeof SIMPLE_QUICK_ACTION_KIND; + /** + * Creates a UI representation object for the action. + * + * @returns UI representation object for the action. + */ + getActionObject: () => SimpleQuickAction; + + /** + * Executes the Quick Action. + */ + execute: () => FlexCommand[] | Promise; +} + +export interface NestedQuickActionDefinition extends QuickActionDefinitionBase { + readonly kind: typeof NESTED_QUICK_ACTION_KIND; + + /** + * Creates a UI representation object for the action. + * + * @returns UI representation object for the action. + */ + getActionObject: () => NestedQuickAction; + /** + * Executes the Quick Action. + * + * @param path - Path to the specific child action that needs to be executed (e.g '0/1'). + */ + execute: (path: string) => FlexCommand[] | Promise; +} +export type QuickActionDefinition = SimpleQuickActionDefinition | NestedQuickActionDefinition; + +export interface QuickActionDefinitionConstructor { + new (context: QuickActionContext): T; +} + +export interface QuickActionDefinitionGroup { + title: string; + definitions: QuickActionDefinitionConstructor[]; + view: XMLView; + key: string; +} diff --git a/packages/preview-middleware-client/src/cpe/quick-actions/quick-action-service.ts b/packages/preview-middleware-client/src/cpe/quick-actions/quick-action-service.ts new file mode 100644 index 0000000000..bac57eac43 --- /dev/null +++ b/packages/preview-middleware-client/src/cpe/quick-actions/quick-action-service.ts @@ -0,0 +1,142 @@ +import RuntimeAuthoring from 'sap/ui/rta/RuntimeAuthoring'; +import { ActionService } from 'sap/ui/rta/service/Action'; +import Log from 'sap/base/Log'; + +import { + executeQuickAction, + ExternalAction, + quickActionListChanged, + SIMPLE_QUICK_ACTION_KIND, + NESTED_QUICK_ACTION_KIND, + QuickActionExecutionPayload, + QuickActionGroup, + updateQuickAction +} from '@sap-ux-private/control-property-editor-common'; + +import { ActionSenderFunction, ControlTreeIndex, Service, SubscribeFunction } from '../types'; + +import { QuickActionActivationContext, QuickActionContext, QuickActionDefinition } from './quick-action-definition'; +import { QuickActionDefinitionRegistry } from './registry'; +import { OutlineService } from '../outline/service'; +import { getTextBundle, TextBundle } from '../../i18n'; + +/** + * Service providing Quick Actions. + */ +export class QuickActionService implements Service { + private sendAction: ActionSenderFunction = () => {}; + private actions: QuickActionDefinition[] = []; + + private actionService: ActionService; + private texts: TextBundle; + + /** + * Qucik action service constructor.zrf + * + * @param rta - RTA object. + * @param outlineService - Outline service instance. + * @param registries - Quick action registries. + */ + constructor( + private readonly rta: RuntimeAuthoring, + private readonly outlineService: OutlineService, + private readonly registries: QuickActionDefinitionRegistry[] + ) {} + + /** + * Initialize selection service. + * + * @param sendAction - Action sender function. + * @param subscribe - Subscriber function. + */ + public async init(sendAction: ActionSenderFunction, subscribe: SubscribeFunction): Promise { + this.sendAction = sendAction; + this.actionService = await this.rta.getService('action'); + this.texts = await getTextBundle(); + + subscribe(async (action: ExternalAction): Promise => { + if (executeQuickAction.match(action)) { + const actionInstance = this.actions + .filter((quickActionDefinition) => quickActionDefinition.id === action.payload.id) + .pop(); + if (!actionInstance) { + return; + } + const commands = await this.executeAction(actionInstance, action.payload); + + for (const command of commands) { + await this.rta.getCommandStack().pushAndExecute(command); + } + + if (actionInstance.forceRefreshAfterExecution) { + this.sendAction(updateQuickAction(actionInstance.getActionObject())); + } + } + }); + + this.outlineService.onOutlineChange(async (event) => { + await this.reloadQuickActions(event.detail.controlIndex); + }); + } + + /** + * Prepares a list of currently applicable Quick Actions and sends them to the UI. + * + * @param controlIndex - Control tree index. + */ + public async reloadQuickActions(controlIndex: ControlTreeIndex): Promise { + const context: QuickActionActivationContext = { + controlIndex, + manifest: this.rta.getRootControlInstance().getManifest(), + actionService: this.actionService + }; + + const groups: QuickActionGroup[] = []; + for (const registry of this.registries) { + for (const { title, definitions, view, key } of registry.getDefinitions(context)) { + const group: QuickActionGroup = { + title, + actions: [] + }; + const actionContext: QuickActionContext = { + ...context, + view, + key, + rta: this.rta, + flexSettings: this.rta.getFlexSettings(), + resourceBundle: this.texts + }; + for (const Definition of definitions) { + try { + const instance = new Definition(actionContext); + await instance.initialize(); + this.addAction(group, instance); + } catch { + Log.warning(`Failed to initialize ${Definition.name} quick action.`); + } + } + groups.push(group); + } + } + + this.sendAction(quickActionListChanged(groups)); + } + + private addAction(group: QuickActionGroup, instance: QuickActionDefinition): void { + if (instance.isActive) { + const quickAction = instance.getActionObject(); + group.actions.push(quickAction); + this.actions.push(instance); + } + } + + private executeAction(actionInstance: QuickActionDefinition, payload: QuickActionExecutionPayload) { + if (payload.kind === SIMPLE_QUICK_ACTION_KIND && actionInstance.kind === SIMPLE_QUICK_ACTION_KIND) { + return actionInstance.execute(); + } + if (payload.kind === NESTED_QUICK_ACTION_KIND && actionInstance.kind === NESTED_QUICK_ACTION_KIND) { + return actionInstance.execute(payload.path); + } + return Promise.resolve([]); + } +} diff --git a/packages/preview-middleware-client/src/cpe/quick-actions/registry.ts b/packages/preview-middleware-client/src/cpe/quick-actions/registry.ts new file mode 100644 index 0000000000..db3f83bd45 --- /dev/null +++ b/packages/preview-middleware-client/src/cpe/quick-actions/registry.ts @@ -0,0 +1,156 @@ +import NavContainer from 'sap/m/NavContainer'; +import FlexibleColumnLayout from 'sap/f/FlexibleColumnLayout'; +import { LayoutType } from 'sap/f/library'; +import Control from 'sap/ui/core/Control'; +import XMLView from 'sap/ui/core/mvc/XMLView'; +import Log from 'sap/base/Log'; + +import { QuickActionActivationContext, QuickActionDefinitionGroup } from './quick-action-definition'; + +import type { ControlTreeIndex } from '../types'; +import { getControlById } from '../../utils/core'; +import { getRootControlFromComponentContainer } from '../utils'; + +const NAV_CONTAINER_CONTROL_TYPE = 'sap.m.NavContainer'; +const FLEXIBLE_COLUMN_LAYOUT_CONTROL_TYPE = 'sap.f.FlexibleColumnLayout'; + +export interface ActivePage { + name: T; + view: XMLView; +} + +/** + * Base class for Quick Action definition providers. + * + */ +export abstract class QuickActionDefinitionRegistry { + /** + * Mapping of page view name to page type name. + */ + PAGE_NAME_MAP: Record = {}; + + /** + * Provides a list of Quick Action definitions that are applicable for the given context. + * + * @param _context - Activation context. + */ + getDefinitions(_context: QuickActionActivationContext): QuickActionDefinitionGroup[] { + throw new Error('Not implemented!'); + } + + /** + * Finds component container from the page control. + * + * @param page - Page control provided by containers. + * @returns ComponentContainer control. + */ + protected getComponentContainerFromPage(page: Control): Control | undefined { + return page; + } + + /** + * Returns a list of Active pages based on the provided control index. + * + * @param controlIndex - Control tree index. + * @returns A list of Active pages. + */ + protected getActivePageContent(controlIndex: ControlTreeIndex): ActivePage[] { + const views = this.getActiveViews(controlIndex); + const pages: ActivePage[] = []; + for (const view of views) { + const name = view.getViewName(); + const pageName = this.PAGE_NAME_MAP[name]; + if (pageName) { + pages.push({ + name: pageName, + view + }); + } else { + Log.warning(`Could not find matching page for view of type "${name}".`); + } + } + return pages; + } + + /** + * Get all the root views of currently active pages. + * + * @param controlIndex - Control index. + * @returns List of page root views. + */ + private getActiveViews(controlIndex: ControlTreeIndex): XMLView[] { + const pages = this.getActivePages(controlIndex); + const views: XMLView[] = []; + + for (const page of pages) { + if (page) { + const container = this.getComponentContainerFromPage(page); + if (container) { + const rootControl = getRootControlFromComponentContainer(container); + if (rootControl) { + views.push(rootControl); + } + } + } + } + return views; + } + + /** + * Finds active page controls from the control tree index. + * + * @param controlIndex - Control tree index. + * @returns A list of page controls. + */ + private getActivePages(controlIndex: ControlTreeIndex): (Control | undefined)[] { + const navContainerNode = controlIndex[NAV_CONTAINER_CONTROL_TYPE]?.[0]; + if (navContainerNode) { + const control = getControlById(navContainerNode.controlId); + if (control instanceof NavContainer) { + return [control.getCurrentPage()]; + } + } + + const flexibleLayoutNode = controlIndex[FLEXIBLE_COLUMN_LAYOUT_CONTROL_TYPE]?.[0]; + if (flexibleLayoutNode) { + const control = getControlById(flexibleLayoutNode.controlId); + if (control instanceof FlexibleColumnLayout) { + return this.getVisibleFlexibleColumnLayoutPages(control); + } + } + + return []; + } + + /** + * Finds the visible Flexible Column Layout pages. + * + * @param control - Flexible Column Layout control. + * @returns A list of visible pages. + */ + + private getVisibleFlexibleColumnLayoutPages(control: FlexibleColumnLayout): (Control | undefined)[] { + const layout = control.getLayout(); + switch (layout) { + case LayoutType.OneColumn: + return [control.getCurrentBeginColumnPage()]; + case LayoutType.MidColumnFullScreen: + return [control.getCurrentMidColumnPage()]; + case LayoutType.EndColumnFullScreen: + return [control.getCurrentEndColumnPage()]; + case LayoutType.ThreeColumnsBeginExpandedEndHidden: + case LayoutType.ThreeColumnsMidExpanded: + case LayoutType.ThreeColumnsMidExpandedEndHidden: + case LayoutType.ThreeColumnsEndExpanded: + return [ + control.getCurrentBeginColumnPage(), + control.getCurrentMidColumnPage(), + control.getCurrentEndColumnPage() + ]; + case LayoutType.TwoColumnsMidExpanded: + case LayoutType.TwoColumnsBeginExpanded: + return [control.getCurrentBeginColumnPage(), control.getCurrentMidColumnPage()]; + } + return []; + } +} diff --git a/packages/preview-middleware-client/src/cpe/quick-actions/utils.ts b/packages/preview-middleware-client/src/cpe/quick-actions/utils.ts new file mode 100644 index 0000000000..52ab6723e7 --- /dev/null +++ b/packages/preview-middleware-client/src/cpe/quick-actions/utils.ts @@ -0,0 +1,114 @@ +import UI5Element from 'sap/ui/core/Element'; +import Control from 'sap/ui/core/Control'; +import ManagedObject from 'sap/ui/base/ManagedObject'; +import { FEAppPage } from 'sap/ui/rta/RuntimeAuthoring'; + +import { getControlById, isA } from '../../utils/core'; + +import type { ControlTreeIndex } from '../types'; +import Component from 'sap/ui/core/Component'; + +export interface FEAppPageInfo { + page: FEAppPage; + isInvisible: boolean; +} +export interface FEAppPagesMap { + [key: string]: FEAppPageInfo[]; +} + +/** + * Checks if control is visible in the page. + * + * @param page - Page control. + * @param controlId - UI5 control id. + * @returns True if control is visible in the page. + */ +export function pageHasControlId(page: Control, controlId: string): boolean { + const controlDomElement = getControlById(controlId)?.getDomRef(); + return !!controlDomElement && !!page?.getDomRef()?.contains(controlDomElement); +} + + +/** + * Checks if control is a child element of the rootControl. + * + * @param control - UI5 Control to be tested. + * @param rootControl - UI5 root control. + * @returns True if control is the child of the specified rootControl. + */ +function isDescendantOfPage(control: ManagedObject | null | undefined, rootControl: ManagedObject) { + let currentControl = control; + while (currentControl) { + if (currentControl === rootControl) { + return true; + } + // if parent is a reusable component, use oContainer to find the parent + if ( + isA('sap.ui.core.Component', currentControl) && + (currentControl as unknown as { oContainer: Control })?.oContainer + ) { + currentControl = (currentControl as unknown as { oContainer: Control }).oContainer.getParent(); + } else { + currentControl = currentControl.getParent(); + } + } + return false; +} + +/** + * Find all controls in page that match the provided types. + * + * @param controlIndex - Control tree index. + * @param activePage - Active page control. + * @param controlTypes - Relevant control types. + * @returns A list of UI5 controls. + */ +export function getRelevantControlFromActivePage( + controlIndex: ControlTreeIndex, + activePage: Control, + controlTypes: string[] +): UI5Element[] { + const relevantControls: UI5Element[] = []; + for (const type of controlTypes) { + const controls = controlIndex[type] ?? []; + for (const control of controls) { + const ui5Control = getControlById(control.controlId); + const parent = ui5Control?.getParent(); + const isActionApplicable = isDescendantOfPage(parent, activePage); + + if (isActionApplicable && ui5Control) { + // if parent control added, discard adding child control. + // Relevant for cases where wrapper exists eg: sap.m.Table exist in sap.ui.comp.smarttable.SmartTable + const parentFound = relevantControls.findIndex( + (relevantControl) => relevantControl.getId() === ui5Control.getParent()?.getId() + ); + if (parentFound === -1) { + relevantControls.push(ui5Control); + } + } + } + } + return relevantControls; +} + +export function getParentContainer( + control: ManagedObject | null | undefined, + type: string +): T | undefined { + let currentControl = control; + while (currentControl) { + if (isA(type, currentControl)) { + return currentControl; + } + // if parent is a reusable component, use oContainer to find the parent + if ( + isA('sap.ui.core.Component', currentControl) && + (currentControl as unknown as { oContainer: Control })?.oContainer + ) { + currentControl = (currentControl as unknown as { oContainer: Control }).oContainer.getParent(); + } else { + currentControl = currentControl.getParent(); + } + } + return undefined; +} \ No newline at end of file diff --git a/packages/preview-middleware-client/src/cpe/rta-service.ts b/packages/preview-middleware-client/src/cpe/rta-service.ts index 3b553a5cb5..f936cbc8a4 100644 --- a/packages/preview-middleware-client/src/cpe/rta-service.ts +++ b/packages/preview-middleware-client/src/cpe/rta-service.ts @@ -9,6 +9,7 @@ import { } from '@sap-ux-private/control-property-editor-common'; import { ActionSenderFunction, SubscribeFunction } from './types'; import RuntimeAuthoring from 'sap/ui/rta/RuntimeAuthoring'; + /** * A Class of RtaService */ diff --git a/packages/preview-middleware-client/src/cpe/selection.ts b/packages/preview-middleware-client/src/cpe/selection.ts index 9093803e8e..48b35c0d3a 100644 --- a/packages/preview-middleware-client/src/cpe/selection.ts +++ b/packages/preview-middleware-client/src/cpe/selection.ts @@ -19,7 +19,7 @@ import Log from 'sap/base/Log'; import { getDocumentation } from './documentation'; import OverlayRegistry from 'sap/ui/dt/OverlayRegistry'; import OverlayUtil from 'sap/ui/dt/OverlayUtil'; -import { getComponent } from './ui5-utils'; +import { getComponent } from '../utils/core'; import { getError } from '../utils/error'; export interface PropertyChangeParams { diff --git a/packages/preview-middleware-client/src/cpe/types.ts b/packages/preview-middleware-client/src/cpe/types.ts index e61ac97bb1..1ea1e1ea19 100644 --- a/packages/preview-middleware-client/src/cpe/types.ts +++ b/packages/preview-middleware-client/src/cpe/types.ts @@ -1,4 +1,4 @@ -import type { ExternalAction } from '@sap-ux-private/control-property-editor-common'; +import type { ExternalAction, OutlineNode } from '@sap-ux-private/control-property-editor-common'; import type RuntimeAuthoring from 'sap/ui/rta/RuntimeAuthoring'; export interface UI5AdaptationOptions { @@ -10,7 +10,7 @@ export type PropertyValue = boolean | object | number | string; export interface UI5ControlProperty { defaultValue: unknown; - enumValues: { [key: string]: string } | null; + enumValues: Record | undefined; isArray: boolean; isDeprecated: boolean; isEnabled: boolean; @@ -35,8 +35,12 @@ export interface UI5ControlData { export type ActionHandler = (action: ExternalAction) => Promise; export type ActionSenderFunction = (action: ExternalAction) => void; export type SubscribeFunction = (handler: ActionHandler) => void; +export type UnSubscribeFunction = (handler: ActionHandler) => void; export interface Service { - init(sendAction: ActionSenderFunction, subscribe: SubscribeFunction): void; + init(sendAction: ActionSenderFunction, subscribe: SubscribeFunction): void | Promise; } +export interface ControlTreeIndex { + [controlType: string]: OutlineNode[] +} \ No newline at end of file diff --git a/packages/preview-middleware-client/src/cpe/ui5-utils.ts b/packages/preview-middleware-client/src/cpe/ui5-utils.ts index afa21cd86e..8760b21db0 100644 --- a/packages/preview-middleware-client/src/cpe/ui5-utils.ts +++ b/packages/preview-middleware-client/src/cpe/ui5-utils.ts @@ -1,23 +1,6 @@ import type { IconDetails } from '@sap-ux-private/control-property-editor-common'; -import Component from 'sap/ui/core/Component'; -import type { ID } from 'sap/ui/core/library'; import IconPool from 'sap/ui/core/IconPool'; -/** - * Gets Component by id. - * - * @param id - unique identifier for control - * @returns Component | undefined - */ -export function getComponent(id: ID): T | undefined { - if (Component?.get) { - return Component.get(id) as T; - } else { - // Older version must be still supported until maintenance period. - return sap.ui.getCore().getComponent(id) as T; // NOSONAR - } -} - /** * Get ui5 icons. * diff --git a/packages/preview-middleware-client/src/cpe/utils.ts b/packages/preview-middleware-client/src/cpe/utils.ts index 70ec99ea12..14cfe30eeb 100644 --- a/packages/preview-middleware-client/src/cpe/utils.ts +++ b/packages/preview-middleware-client/src/cpe/utils.ts @@ -2,6 +2,14 @@ import type ManagedObject from 'sap/ui/base/ManagedObject'; import type Control from 'sap/ui/core/Control'; import type ElementOverlay from 'sap/ui/dt/ElementOverlay'; import DataType from 'sap/ui/base/DataType'; +import type { Manifest } from 'sap/ui/rta/RuntimeAuthoring'; +import ComponentContainer from 'sap/ui/core/ComponentContainer'; +import XMLView from 'sap/ui/core/mvc/XMLView'; +import UIComponent from 'sap/ui/core/UIComponent'; + + +import { getComponent } from '../utils/core'; +import { isLowerThanMinimalUi5Version, Ui5VersionInfo } from '../utils/version'; export interface PropertiesInfo { defaultValue: string; @@ -14,6 +22,7 @@ export interface Properties { [key: string]: PropertiesInfo; } + export interface ManagedObjectMetadataProperties { name: string; defaultValue: string | null; @@ -60,3 +69,48 @@ export async function getLibrary(controlName: string): Promise { }); }); } + +/** + * Function that checks if control is reuse component + * + * @param controlId id control + * @param ui5VersionInfo UI5 version information + * @returns boolean if control is from reused component view + */ +export function isReuseComponent(controlId: string, ui5VersionInfo: Ui5VersionInfo): boolean { + if (isLowerThanMinimalUi5Version(ui5VersionInfo, { major: 1, minor: 115 })) { + return false; + } + + const component = getComponent(controlId); + if (!component) { + return false; + } + + const manifest = component.getManifest() as Manifest; + if (!manifest) { + return false; + } + + return manifest['sap.app']?.type === 'component'; +} + +/** + * Gets the root view of component for the provided ComponentContainer control. + * + * @param container ComponentContainer control. + * @returns XMLView which is the root control of the component if it exists. + */ +export function getRootControlFromComponentContainer(container: Control): XMLView | undefined { + if (container instanceof ComponentContainer) { + const componentId = container.getComponent(); + const component = getComponent(componentId); + if (component instanceof UIComponent) { + const rootControl = component.getRootControl(); + if (rootControl instanceof XMLView) { + return rootControl; + } + } + } + return undefined; +} diff --git a/packages/preview-middleware-client/src/i18n.ts b/packages/preview-middleware-client/src/i18n.ts new file mode 100644 index 0000000000..074d6a086b --- /dev/null +++ b/packages/preview-middleware-client/src/i18n.ts @@ -0,0 +1,34 @@ +import ResourceBundle from 'sap/base/i18n/ResourceBundle'; + +const BUNDLE_CACHE: Record = {}; + +export async function getResourceBundle(key: string): Promise { + const cachedBundle = BUNDLE_CACHE[key]; + + if (cachedBundle) { + return cachedBundle; + } + + const bundle = await ResourceBundle.create({ + bundleUrl: '/preview/client/messagebundle.properties', + url: '/preview/client/messagebundle.properties', + supportedLocales: [''], + locale: '', + async: true + }); + BUNDLE_CACHE[key] = bundle; + return bundle; +} + +export class TextBundle { + constructor(private bundle: ResourceBundle) {} + + getText(key: string, args?: string[]): string { + return this.bundle.getText(key, args) ?? key; + } +} + +export async function getTextBundle(key = 'open.ux.preview.client'): Promise { + const bundle = await getResourceBundle(key); + return new TextBundle(bundle); +} diff --git a/packages/preview-middleware-client/src/manifest.json b/packages/preview-middleware-client/src/manifest.json index 3837052639..441c0a711c 100644 --- a/packages/preview-middleware-client/src/manifest.json +++ b/packages/preview-middleware-client/src/manifest.json @@ -2,4 +2,4 @@ "sap.app": { "id": "open.ux.preview.client" } -} \ No newline at end of file +} diff --git a/packages/preview-middleware-client/src/messagebundle.properties b/packages/preview-middleware-client/src/messagebundle.properties new file mode 100644 index 0000000000..fd65804d33 --- /dev/null +++ b/packages/preview-middleware-client/src/messagebundle.properties @@ -0,0 +1,17 @@ +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 + +V2_QUICK_ACTION_CHANGE_TABLE_COLUMNS=Change table columns + +V2_QUICK_ACTION_LR_ENABLE_CLEAR_FILTER_BAR=Enable clear filterbar button +V2_QUICK_ACTION_LR_DISABLE_CLEAR_FILTER_BAR=Disable clear filterbar button + + +V4_QUICK_ACTION_CHANGE_TABLE_COLUMNS=Change table columns + +V4_QUICK_ACTION_LR_ENABLE_CLEAR_FILTER_BAR=Enable clear filterbar button +V4_QUICK_ACTION_LR_DISABLE_CLEAR_FILTER_BAR=Disable clear filterbar button + +ADP_SYNC_VIEWS_MESSAGE = Have in mind that synchronous views are detected for this application and controller extensions are not supported for such views. Controller extension functionality on these views will be disabled. +ADP_REUSE_COMPONENTS_MESSAGE = Have in mind that reuse components are detected for some views in this application and controller extensions and adding fragments are not supported for such views. Controller extension and adding fragment functionality on these views will be disabled. diff --git a/packages/preview-middleware-client/src/utils/application.ts b/packages/preview-middleware-client/src/utils/application.ts new file mode 100644 index 0000000000..4c7fa2cfa0 --- /dev/null +++ b/packages/preview-middleware-client/src/utils/application.ts @@ -0,0 +1,27 @@ +import type { Manifest } from 'sap/ui/rta/RuntimeAuthoring'; + +export type ApplicationType = 'fe-v2' | 'fe-v4' | 'freestyle'; + +/** + * Determines application type based on the manifest.json. + * + * @param manifest - Application Manifest. + * @returns Application type. + */ +export function getApplicationType(manifest: Manifest): ApplicationType { + if (manifest['sap.ui.generic.app'] || manifest['sap.ovp']) { + return 'fe-v2'; + } else if (manifest['sap.ui5']?.routing?.targets) { + let hasV4pPages = false; + Object.keys(manifest?.['sap.ui5']?.routing?.targets ?? []).forEach((target) => { + if (manifest?.['sap.ui5']?.routing?.targets?.[target]?.name?.startsWith('sap.fe.templates.')) { + hasV4pPages = true; + } + }); + if (hasV4pPages) { + return 'fe-v4'; + } + } + + return 'freestyle'; +} diff --git a/packages/preview-middleware-client/src/utils/core.ts b/packages/preview-middleware-client/src/utils/core.ts new file mode 100644 index 0000000000..73108bc327 --- /dev/null +++ b/packages/preview-middleware-client/src/utils/core.ts @@ -0,0 +1,61 @@ +import Component from 'sap/ui/core/Component'; +import type { ID } from 'sap/ui/core/library'; +import type ManagedObject from 'sap/ui/base/ManagedObject'; +import Element from 'sap/ui/core/Element'; + +/** + * Gets Component by id. + * + * @param id - unique identifier for control + * @returns Component | undefined + */ +export function getComponent(id: ID): T | undefined { + if (Component?.getComponentById) { + return Component.getComponentById(id) as T; + } else if (Component?.get) { + // Older version must be still supported until maintenance period. + return Component.get(id) as T; // NOSONAR + } else { + // Older version must be still supported until maintenance period. + return sap.ui.getCore().getComponent(id) as T; // NOSONAR + } +} + +/** + * Returns control by its global ID. + * + * @param id Id of the control. + * @returns Control instance if it exists. + */ +export function getControlById(id: string): T | undefined { + if (typeof Element.getElementById === 'function') { + return Element.getElementById(id) as T; + } else { + return sap.ui.getCore().byId(id) as T; + } +} + +/** + * Checks wether this object is an instance of a ManagedObject. + * + * @param element An object. + * @returns True if element is an instance of a ManagedObject. + */ +export function isManagedObject(element: object | undefined): element is ManagedObject { + if (typeof (element as unknown as { isA?: (_type: string) => boolean })?.isA === 'function') { + return (element as unknown as { isA: (_type: string) => boolean }).isA('sap.ui.base.ManagedObject'); + } + + return false; +} + +/** + * Checks whether this object is an instance of the named type. + * + * @param type - Type to check for. + * @param element - Object to check + * @returns Whether this object is an instance of the given type. + */ +export function isA(type: string, element: ManagedObject | undefined): element is T { + return !!element?.isA(type); +} diff --git a/packages/preview-middleware-client/test/__mock__/sap/base/i18n/ResourceBundle.ts b/packages/preview-middleware-client/test/__mock__/sap/base/i18n/ResourceBundle.ts index a07ad7f4ef..5fbebab786 100644 --- a/packages/preview-middleware-client/test/__mock__/sap/base/i18n/ResourceBundle.ts +++ b/packages/preview-middleware-client/test/__mock__/sap/base/i18n/ResourceBundle.ts @@ -1,8 +1,36 @@ +import { readFile } from 'fs/promises'; +import { join } from 'path'; + +import { propertiesToI18nEntry } from '@sap-ux/i18n'; + export const mockBundle = { getText: jest.fn(), hasText: jest.fn() }; export default { - create: jest.fn().mockReturnValue(mockBundle) + create: jest.fn().mockImplementation(async (params: { bundleUrl?: string }) => { + if (params.bundleUrl === '/preview/client/messagebundle.properties') { + const path = join(__dirname, '..', '..', '..', '..', '..', 'src', 'messagebundle.properties'); + + const text = await readFile(path, { encoding: 'utf-8' }); + const entries = propertiesToI18nEntry(text, ''); + + const cache = new Map(); + for (const { key, value } of entries) { + cache.set(key.value, value.value); + } + const bundle = { + getText: (key: string) => { + return cache.get(key); + }, + hasText: (key: string) => { + return cache.has(key); + } + }; + return bundle; + } else { + return mockBundle; + } + }) }; diff --git a/packages/preview-middleware-client/test/__mock__/sap/f/FlexibleColumnLayout.ts b/packages/preview-middleware-client/test/__mock__/sap/f/FlexibleColumnLayout.ts new file mode 100644 index 0000000000..7188a01a19 --- /dev/null +++ b/packages/preview-middleware-client/test/__mock__/sap/f/FlexibleColumnLayout.ts @@ -0,0 +1,4 @@ +// add required functionality for testing here +export default class { + +}; diff --git a/packages/preview-middleware-client/test/__mock__/sap/f/library.ts b/packages/preview-middleware-client/test/__mock__/sap/f/library.ts new file mode 100644 index 0000000000..bb94a57be7 --- /dev/null +++ b/packages/preview-middleware-client/test/__mock__/sap/f/library.ts @@ -0,0 +1,11 @@ +export const LayoutType = { + EndColumnFullScreen: 'EndColumnFullScreen', + MidColumnFullScreen: 'MidColumnFullScreen', + OneColumn: 'OneColumn', + ThreeColumnsBeginExpandedEndHidden: 'ThreeColumnsBeginExpandedEndHidden', + ThreeColumnsEndExpanded: 'ThreeColumnsEndExpanded', + ThreeColumnsMidExpanded: 'ThreeColumnsMidExpanded', + ThreeColumnsMidExpandedEndHidden: 'ThreeColumnsMidExpandedEndHidden', + TwoColumnsBeginExpanded: 'TwoColumnsBeginExpanded', + TwoColumnsMidExpanded: 'TwoColumnsMidExpanded' +} as const; diff --git a/packages/preview-middleware-client/test/__mock__/sap/fe/core/AppComponent.ts b/packages/preview-middleware-client/test/__mock__/sap/fe/core/AppComponent.ts new file mode 100644 index 0000000000..616fe50580 --- /dev/null +++ b/packages/preview-middleware-client/test/__mock__/sap/fe/core/AppComponent.ts @@ -0,0 +1,10 @@ +import UIComponentMock from 'mock/sap/ui/core/UIComponent'; +export default class AppComponentMock extends UIComponentMock { + getManifest() { + return { + 'sap.app': { + id: 'test.id' + } + }; + } +} diff --git a/packages/preview-middleware-client/test/__mock__/sap/fe/core/TemplateComponent.ts b/packages/preview-middleware-client/test/__mock__/sap/fe/core/TemplateComponent.ts new file mode 100644 index 0000000000..d759014eb7 --- /dev/null +++ b/packages/preview-middleware-client/test/__mock__/sap/fe/core/TemplateComponent.ts @@ -0,0 +1,10 @@ +import UIComponentMock from 'mock/sap/ui/core/UIComponent'; +import AppComponentMock from './AppComponent'; +export default class TemplateComponentMock extends UIComponentMock { + isA(type: string): boolean { + return type === 'sap.fe.core.TemplateComponent'; + } + getAppComponent(): AppComponentMock { + return new AppComponentMock(); + } +} diff --git a/packages/preview-middleware-client/test/__mock__/sap/m/FlexBox.ts b/packages/preview-middleware-client/test/__mock__/sap/m/FlexBox.ts new file mode 100644 index 0000000000..a9bcf1537f --- /dev/null +++ b/packages/preview-middleware-client/test/__mock__/sap/m/FlexBox.ts @@ -0,0 +1,5 @@ +export default class FlexBoxMock { + isA(type: string): boolean { + return type === 'sap.m.FlexBox' + } +} \ No newline at end of file diff --git a/packages/preview-middleware-client/test/__mock__/sap/m/NavContainer.ts b/packages/preview-middleware-client/test/__mock__/sap/m/NavContainer.ts new file mode 100644 index 0000000000..c5611ebd3a --- /dev/null +++ b/packages/preview-middleware-client/test/__mock__/sap/m/NavContainer.ts @@ -0,0 +1,4 @@ +// add required functionality for testing here +export default class { + getCurrentPage = jest.fn(); +} diff --git a/packages/preview-middleware-client/test/__mock__/sap/ui/base/DataType.ts b/packages/preview-middleware-client/test/__mock__/sap/ui/base/DataType.ts index 0c28639b8e..99bbfc95bf 100644 --- a/packages/preview-middleware-client/test/__mock__/sap/ui/base/DataType.ts +++ b/packages/preview-middleware-client/test/__mock__/sap/ui/base/DataType.ts @@ -1,10 +1,85 @@ export const getNameMock = jest.fn(); +export const registry = new Map(); + export default class DataTypeMock { - getName(): string | undefined { - return getNameMock(); + getName(): string { + return undefined as unknown as string; + } + getBaseType(): DataTypeMock | undefined { + return undefined; + } + + static getType(name: string) { + const type = registry.get(name); + if (type) { + return type; + } + console.warn(`Data type "${name} is not found, returning default mock.`); + } + + static registerEnum(name: string, options: Record) { + const enumType = createEnum(name, options); + registry.set(name, enumType); } - static getType() { - return new DataTypeMock(); + + static registerType(name: string, baseType?: DataTypeMock) { + const dataType = createType(name, baseType); + registry.set(name, dataType); + } +} +export class PrimitiveDataTypeMock extends DataTypeMock { + constructor(private name: string, private baseType?: DataTypeMock) { + super(); + } + + getName() { + return this.name; + } + + getBaseType() { + return this.baseType; } -} \ No newline at end of file +} + +function createType(name: string, baseType?: DataTypeMock): DataTypeMock { + const base = baseType ?? DataTypeMock.prototype; + const dataType = Object.create(base) as DataTypeMock; + + dataType.getName = function () { + return name; + }; + + dataType.getBaseType = function () { + return baseType; + }; + + return dataType; +} + +const StringType = createType('string'); +registry.set(StringType.getName(), StringType); + +function createEnum(name: string, options: Record): DataTypeMock { + const dataType = Object.create(DataTypeMock.prototype) as DataTypeMock & { + getEnumValues: () => Record; + }; + + dataType.getName = function () { + return name; + }; + + dataType.getBaseType = function () { + return StringType; + }; + + dataType.getEnumValues = function () { + return options; + }; + + return dataType; +} + +DataTypeMock.registerType('sap.ui.core.URI', StringType); +DataTypeMock.registerType('sap.ui.core.CSSSize', StringType); +DataTypeMock.registerEnum('sap.ui.core.aria.HasPopup', { None: 'None', Menu: 'Menu', ListBox: 'ListBox' }); diff --git a/packages/preview-middleware-client/test/__mock__/sap/ui/base/ManagedObject.ts b/packages/preview-middleware-client/test/__mock__/sap/ui/base/ManagedObject.ts new file mode 100644 index 0000000000..b857df27e7 --- /dev/null +++ b/packages/preview-middleware-client/test/__mock__/sap/ui/base/ManagedObject.ts @@ -0,0 +1,8 @@ +import ManagedObject from 'sap/ui/base/ManagedObject'; + +// add required functionality for testing here +export class ManagedObjectMock { + isA = jest.fn(); +} + +export default ManagedObjectMock as unknown as ManagedObject & typeof ManagedObject; diff --git a/packages/preview-middleware-client/test/__mock__/sap/ui/core/Component.ts b/packages/preview-middleware-client/test/__mock__/sap/ui/core/Component.ts index 3376b84c23..3d3bcc6035 100644 --- a/packages/preview-middleware-client/test/__mock__/sap/ui/core/Component.ts +++ b/packages/preview-middleware-client/test/__mock__/sap/ui/core/Component.ts @@ -1,9 +1,15 @@ +import ManagedObject from 'sap/ui/base/ManagedObject'; +import Component from 'sap/ui/core/Component'; + export default class ComponentMock { static get(_id: string) {} static create() { return new ComponentMock(); } - static getComponentById(_id: string) { + static getComponentById(_id: string): ComponentMock | undefined { return undefined; } -} \ No newline at end of file + static getOwnerComponentFor(_control: ManagedObject): Component | undefined { + return undefined; + } +} diff --git a/packages/preview-middleware-client/test/__mock__/sap/ui/core/ComponentContainer.ts b/packages/preview-middleware-client/test/__mock__/sap/ui/core/ComponentContainer.ts new file mode 100644 index 0000000000..f0129d4cf3 --- /dev/null +++ b/packages/preview-middleware-client/test/__mock__/sap/ui/core/ComponentContainer.ts @@ -0,0 +1,7 @@ +import UIComponentMOck from './UIComponent'; + +export default class { + getComponent() { + new UIComponentMOck(); + } +} diff --git a/packages/preview-middleware-client/test/__mock__/sap/ui/core/UIComponent.ts b/packages/preview-middleware-client/test/__mock__/sap/ui/core/UIComponent.ts index 530cb9a89d..8a8d86d753 100644 --- a/packages/preview-middleware-client/test/__mock__/sap/ui/core/UIComponent.ts +++ b/packages/preview-middleware-client/test/__mock__/sap/ui/core/UIComponent.ts @@ -1 +1,3 @@ -export default class UIComponent {} +export default class UIComponentMock { + getRootControl = jest.fn(); +} diff --git a/packages/preview-middleware-client/test/__mock__/sap/ui/core/mvc/XMLView.ts b/packages/preview-middleware-client/test/__mock__/sap/ui/core/mvc/XMLView.ts index 5885245e6c..1aa6b636bc 100644 --- a/packages/preview-middleware-client/test/__mock__/sap/ui/core/mvc/XMLView.ts +++ b/packages/preview-middleware-client/test/__mock__/sap/ui/core/mvc/XMLView.ts @@ -1,4 +1,10 @@ // add required functionality for testing here -export default { - create: jest.fn() -}; +export default class { + isA = jest.fn().mockImplementation((type) => type === 'sap.ui.core.mvc.XMLView') + create = jest.fn(); + getContent = jest.fn(); + getParent = jest.fn(); + getViewName = jest.fn(); + getDomRef = jest.fn(); + getId = jest.fn(); +} diff --git a/packages/preview-middleware-client/test/__mock__/sap/ui/fl/apply/api/FlexRuntimeInfoAPI.ts b/packages/preview-middleware-client/test/__mock__/sap/ui/fl/apply/api/FlexRuntimeInfoAPI.ts new file mode 100644 index 0000000000..17f033122a --- /dev/null +++ b/packages/preview-middleware-client/test/__mock__/sap/ui/fl/apply/api/FlexRuntimeInfoAPI.ts @@ -0,0 +1,3 @@ +export default { + hasVariantManagement: jest.fn() +}; diff --git a/packages/preview-middleware-client/test/__mock__/sap/ui/rta/RuntimeAuthoring.ts b/packages/preview-middleware-client/test/__mock__/sap/ui/rta/RuntimeAuthoring.ts index 2d93a78b55..d43b534dfd 100644 --- a/packages/preview-middleware-client/test/__mock__/sap/ui/rta/RuntimeAuthoring.ts +++ b/packages/preview-middleware-client/test/__mock__/sap/ui/rta/RuntimeAuthoring.ts @@ -1,20 +1,25 @@ import type { RTAOptions } from 'sap/ui/rta/RuntimeAuthoring'; -import type RuntimeAuthoring from 'sap/ui/rta/RuntimeAuthoring'; +import RuntimeAuthoring from 'sap/ui/rta/RuntimeAuthoring'; class RuntimeAuthoringMock { constructor(_: RTAOptions) {} public getDefaultPlugins = jest.fn(); public getService = jest.fn(); - public getCommandStack = jest.fn(); + public getCommandStack = jest.fn().mockReturnValue({ + pushAndExecute: jest.fn() + }); + public getSelection = jest.fn().mockReturnValue([{ setSelected: jest.fn() }, { setSelected: jest.fn() }]); public getFlexSettings = jest.fn().mockReturnValue({}); public attachEvent = jest.fn(); public destroy = jest.fn(); public start = jest.fn(); - public attachStop = jest.fn(); + public attachStop = jest.fn(); public stop = jest.fn(); public attachUndoRedoStackModified = jest.fn(); public attachModeChanged = jest.fn(); - public attachSelectionChange = jest.fn(); + public attachSelectionChange = jest.fn().mockImplementation((newHandler: (event: Event) => Promise) => { + return newHandler; + }); public setPlugins = jest.fn(); public canUndo = jest.fn(); public canRedo = jest.fn(); @@ -23,6 +28,9 @@ class RuntimeAuthoringMock { public undo = jest.fn(); public redo = jest.fn(); public save = jest.fn(); + public getRootControlInstance = jest.fn().mockReturnValue({ + getManifest: jest.fn().mockReturnValue({}) + }); public _serializeToLrep = jest.fn(); } diff --git a/packages/preview-middleware-client/test/unit/adp/init-dialogs.test.ts b/packages/preview-middleware-client/test/unit/adp/init-dialogs.test.ts index 9907eb6a12..78f11f7913 100644 --- a/packages/preview-middleware-client/test/unit/adp/init-dialogs.test.ts +++ b/packages/preview-middleware-client/test/unit/adp/init-dialogs.test.ts @@ -21,7 +21,7 @@ import { 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 * as cpeUtils from '../../../src/cpe/outline/utils'; +import * as cpeUtils from '../../../src/cpe/utils'; describe('Dialogs', () => { describe('initDialogs', () => { @@ -154,36 +154,28 @@ describe('Dialogs', () => { }); it('should return false when overlays array is empty', () => { - expect(isControllerExtensionEnabled([], syncViewsIds, { major: 1, minor: 118 })).toBe( - false - ); + expect(isControllerExtensionEnabled([], syncViewsIds, { major: 1, minor: 118 })).toBe(false); }); it('should return true when overlays length is 1 and clickedControlId is not in syncViewsIds and it is not reuse component', () => { FlUtils.getViewForControl = jest.fn().mockReturnValue({ getId: jest.fn().mockReturnValue('asyncViewId2') }); jest.spyOn(cpeUtils, 'isReuseComponent').mockReturnValue(false); const overlays: ElementOverlay[] = [elementOverlayMock]; - expect( - isControllerExtensionEnabled(overlays, syncViewsIds, { major: 1, minor: 112 }) - ).toBe(true); + expect(isControllerExtensionEnabled(overlays, syncViewsIds, { major: 1, minor: 112 })).toBe(true); }); it('should return false when overlays length is 1 and clickedControlId is not in syncViewsIds and it is reuse component', () => { FlUtils.getViewForControl = jest.fn().mockReturnValue({ getId: jest.fn().mockReturnValue('asyncViewId4') }); const overlays: ElementOverlay[] = [elementOverlayMock]; jest.spyOn(cpeUtils, 'isReuseComponent').mockReturnValue(true); - expect( - isControllerExtensionEnabled(overlays, syncViewsIds, { major: 1, minor: 118 }) - ).toBe(false); + expect(isControllerExtensionEnabled(overlays, syncViewsIds, { major: 1, minor: 118 })).toBe(false); }); it('should return false when overlays length is more than 1', () => { FlUtils.getViewForControl = jest.fn().mockReturnValue({ getId: jest.fn().mockReturnValue('syncViewId3') }); const overlays: ElementOverlay[] = [elementOverlayMock, elementOverlayMock]; const syncViewsIds = ['syncViewId1', 'syncViewId2']; - expect( - isControllerExtensionEnabled(overlays, syncViewsIds, { major: 1, minor: 118 }) - ).toBe(false); + expect(isControllerExtensionEnabled(overlays, syncViewsIds, { major: 1, minor: 118 })).toBe(false); }); }); }); diff --git a/packages/preview-middleware-client/test/unit/adp/init.test.ts b/packages/preview-middleware-client/test/unit/adp/init.test.ts index 327d32ec79..6e1431da9b 100644 --- a/packages/preview-middleware-client/test/unit/adp/init.test.ts +++ b/packages/preview-middleware-client/test/unit/adp/init.test.ts @@ -2,7 +2,7 @@ import * as common from '@sap-ux-private/control-property-editor-common'; import init from '../../../src/adp/init'; import { fetchMock } from 'mock/window'; import * as ui5Utils from '../../../src/cpe/ui5-utils'; -import * as outline from '../../../src/cpe/outline'; +import { OutlineService } from '../../../src/cpe/outline/service'; import VersionInfo from 'mock/sap/ui/VersionInfo'; import RuntimeAuthoringMock from 'mock/sap/ui/rta/RuntimeAuthoring'; import { RTAOptions } from 'sap/ui/rta/RuntimeAuthoring'; @@ -39,7 +39,7 @@ describe('adp', () => { .mockImplementationOnce(() => Promise.resolve(apiJson)) .mockImplementation(() => Promise.resolve({ json: jest.fn().mockResolvedValue({}) })); - initOutlineSpy = jest.spyOn(outline, 'initOutline').mockImplementation(() => { + initOutlineSpy = jest.spyOn(OutlineService.prototype, 'init').mockImplementation(() => { return Promise.resolve(); }); @@ -147,7 +147,7 @@ describe('adp', () => { payload: undefined }); - expect(sendActionMock).toHaveBeenNthCalledWith(3, { + expect(sendActionMock).toHaveBeenNthCalledWith(4, { type: '[ext] show-dialog-message', payload: { message: @@ -176,7 +176,7 @@ describe('adp', () => { await init(rtaMock as unknown as RuntimeAuthoring); - expect(sendActionMock).toHaveBeenNthCalledWith(3, { + expect(sendActionMock).toHaveBeenNthCalledWith(4, { type: '[ext] show-dialog-message', payload: { message: 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 new file mode 100644 index 0000000000..13cfee7640 --- /dev/null +++ b/packages/preview-middleware-client/test/unit/adp/quick-actions/fe-v2.test.ts @@ -0,0 +1,502 @@ +import FlexBox from 'sap/m/FlexBox'; +import RuntimeAuthoring, { RTAOptions } from 'sap/ui/rta/RuntimeAuthoring'; +import RuntimeAuthoringMock from 'mock/sap/ui/rta/RuntimeAuthoring'; + +import { quickActionListChanged, executeQuickAction } from '@sap-ux-private/control-property-editor-common'; + +jest.mock('../../../../src/adp/init-dialogs', () => { + return { + ...jest.requireActual('../../../../src/adp/init-dialogs'), + handler: jest.fn() + }; +}); +import { QuickActionService } from '../../../../src/cpe/quick-actions/quick-action-service'; +import { OutlineService } from '../../../../src/cpe/outline/service'; + +import FEV2QuickActionRegistry from '../../../../src/adp/quick-actions/fe-v2/registry'; +import { sapCoreMock } from 'mock/window'; +import NavContainer from 'mock/sap/m/NavContainer'; +import XMLView from 'mock/sap/ui/core/mvc/XMLView'; +import ComponentContainer from 'mock/sap/ui/core/ComponentContainer'; +import UIComponentMock from 'mock/sap/ui/core/UIComponent'; +import Component from 'mock/sap/ui/core/Component'; +import CommandFactory from 'mock/sap/ui/rta/command/CommandFactory'; +import FlexUtils from 'mock/sap/ui/fl/Utils'; + +import { fetchMock } from 'mock/window'; +import { mockOverlay } from 'mock/sap/ui/dt/OverlayRegistry'; + +describe('FE V2 quick actions', () => { + let sendActionMock: jest.Mock; + let subscribeMock: jest.Mock; + + beforeEach(() => { + sendActionMock = jest.fn(); + subscribeMock = jest.fn(); + jest.clearAllMocks(); + }); + + afterEach(() => { + fetchMock.mockRestore(); + }); + + describe('ListReport', () => { + describe('clear filter bar button', () => { + test('initialize and execute action', async () => { + sapCoreMock.byId.mockImplementation((id) => { + if (id == 'SmartFilterBar') { + return { + getShowClearOnFB: jest.fn().mockImplementation(() => false), + getDomRef: () => ({}) + }; + } + if (id == 'NavContainer') { + const container = new NavContainer(); + const component = new UIComponentMock(); + const view = new XMLView(); + const pageView = 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; + } + }); + + CommandFactory.getCommandFor.mockImplementation((control, type, value, _, settings) => { + return { type, value, settings }; + }); + + const rtaMock = new RuntimeAuthoringMock({} as RTAOptions) as unknown as RuntimeAuthoring; + const registry = new FEV2QuickActionRegistry(); + const service = new QuickActionService(rtaMock, new OutlineService(rtaMock), [registry]); + await service.init(sendActionMock, subscribeMock); + + await service.reloadQuickActions({ + 'sap.ui.comp.smartfilterbar.SmartFilterBar': [ + { + controlId: 'SmartFilterBar' + } as any + ], + 'sap.m.NavContainer': [ + { + controlId: 'NavContainer' + } as any + ] + }); + + expect(sendActionMock).toHaveBeenCalledWith( + quickActionListChanged([ + { + title: 'LIST REPORT', + actions: [ + { + 'kind': 'simple', + id: 'listReport0-enable-clear-filter-bar', + title: 'Enable clear filterbar button', + enabled: true + } + ] + } + ]) + ); + + await subscribeMock.mock.calls[0][0]( + executeQuickAction({ id: 'listReport0-enable-clear-filter-bar', kind: 'simple' }) + ); + expect(rtaMock.getCommandStack().pushAndExecute).toHaveBeenCalledWith({ + 'settings': {}, + 'type': 'Property', + 'value': { + 'generator': undefined, + 'newValue': true, + 'propertyName': 'showClearOnFB' + } + }); + }); + }); + + describe('add controller to the page', () => { + test('initialize and execute action', async () => { + const pageView = new XMLView(); + FlexUtils.getViewForControl.mockImplementation(() => { + return { + getId: () => 'MyView', + getController: () => { + return { + getMetadata: () => { + return { + getName: () => 'MyController' + }; + } + }; + } + }; + }); + fetchMock.mockResolvedValue({ + json: jest + .fn() + .mockReturnValueOnce({ + controllerExists: false, + controllerPath: '', + controllerPathFromRoot: '', + isRunningInBAS: false + }) + .mockReturnValueOnce({ controllers: [] }), + 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; + } + }); + + const rtaMock = new RuntimeAuthoringMock({} as RTAOptions) as unknown as RuntimeAuthoring; + const registry = new FEV2QuickActionRegistry(); + const service = new QuickActionService(rtaMock, new OutlineService(rtaMock), [registry]); + await service.init(sendActionMock, subscribeMock); + + await service.reloadQuickActions({ + 'sap.f.DynamicPage': [ + { + controlId: 'DynamicPage' + } as any + ], + 'sap.m.NavContainer': [ + { + controlId: 'NavContainer' + } as any + ] + }); + + expect(sendActionMock).toHaveBeenCalledWith( + quickActionListChanged([ + { + title: 'LIST REPORT', + actions: [ + { + 'kind': 'simple', + id: 'listReport0-add-controller-to-page', + title: 'Add controller to page', + enabled: true + } + ] + } + ]) + ); + + await subscribeMock.mock.calls[0][0]( + executeQuickAction({ id: 'listReport0-add-controller-to-page', kind: 'simple' }) + ); + const { handler } = jest.requireMock<{ handler: () => Promise }>( + '../../../../src/adp/init-dialogs' + ); + + expect(handler).toHaveBeenCalledWith(mockOverlay, rtaMock, 'ControllerExtension'); + }); + }); + + describe('change table columns', () => { + test('initialize and execute action', async () => { + const pageView = new XMLView(); + + const scrollIntoView = jest.fn(); + sapCoreMock.byId.mockImplementation((id) => { + if (id == 'SmartTable') { + return { + isA: (type: string) => type === 'sap.ui.comp.smarttable.SmartTable', + getHeader: () => 'MyTable', + getId: () => id, + getDomRef: () => ({ + scrollIntoView + }), + getParent: () => pageView, + getBusy: () => false + }; + } + 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; + } + }); + + const execute = jest.fn(); + const rtaMock = new RuntimeAuthoringMock({} as RTAOptions) as unknown as RuntimeAuthoring; + jest.spyOn(rtaMock, 'getService').mockImplementation((serviceName: string): any => { + if (serviceName === 'action') { + return { + get: (controlId: string) => { + if (controlId === 'SmartTable') { + return [{ id: 'CTX_COMP_VARIANT_CONTENT' }]; + } + }, + execute + }; + } + }); + const registry = new FEV2QuickActionRegistry(); + const service = new QuickActionService(rtaMock, new OutlineService(rtaMock), [registry]); + await service.init(sendActionMock, subscribeMock); + + await service.reloadQuickActions({ + 'sap.ui.comp.smarttable.SmartTable': [ + { + controlId: 'SmartTable' + } as any + ], + 'sap.m.NavContainer': [ + { + controlId: 'NavContainer' + } as any + ] + }); + + expect(sendActionMock).toHaveBeenCalledWith( + quickActionListChanged([ + { + title: 'LIST REPORT', + actions: [ + { + 'kind': 'nested', + id: 'listReport0-change-table-columns', + title: 'Change table columns', + enabled: true, + children: [ + { + children: [], + label: `'MyTable' table` + } + ] + } + ] + } + ]) + ); + + await subscribeMock.mock.calls[0][0]( + executeQuickAction({ id: 'listReport0-change-table-columns', kind: 'nested', path: '0' }) + ); + + expect(scrollIntoView).toHaveBeenCalled(); + expect(execute).toHaveBeenCalledWith('SmartTable', 'CTX_COMP_VARIANT_CONTENT'); + }); + }); + }); + describe('ObjectPage', () => { + describe('add header field', () => { + test('initialize and execute action', async () => { + const pageView = new XMLView(); + FlexUtils.getViewForControl.mockImplementation(() => { + return { + getId: () => 'MyView', + getController: () => { + return { + getMetadata: () => { + return { + getName: () => 'MyController' + }; + } + }; + } + }; + }); + fetchMock.mockResolvedValue({ + json: jest + .fn() + .mockReturnValueOnce({ + controllerExists: false, + controllerPath: '', + controllerPathFromRoot: '', + isRunningInBAS: false + }) + .mockReturnValueOnce({ controllers: [] }), + text: jest.fn(), + ok: true + }); + + sapCoreMock.byId.mockImplementation((id) => { + if (id == 'ObjectPageLayout') { + return { + getDomRef: () => ({}), + getParent: () => pageView, + getHeaderContent: () => { + return [new FlexBox()] + } + }; + } + 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.ObjectPage.view.Details' + ); + 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; + } + }); + + const rtaMock = new RuntimeAuthoringMock({} as RTAOptions) as unknown as RuntimeAuthoring; + const registry = new FEV2QuickActionRegistry(); + const service = new QuickActionService(rtaMock, new OutlineService(rtaMock), [registry]); + await service.init(sendActionMock, subscribeMock); + + await service.reloadQuickActions({ + 'sap.uxap.ObjectPageLayout': [ + { + controlId: 'ObjectPageLayout' + } as any + ], + 'sap.m.NavContainer': [ + { + controlId: 'NavContainer' + } as any + ] + }); + + expect(sendActionMock).toHaveBeenCalledWith( + quickActionListChanged([ + { + title: 'OBJECT PAGE', + actions: [ + { + kind: 'simple', + id: 'objectPage0-add-controller-to-page', + enabled: true, + title: 'Add controller to page' + }, + { + kind: 'simple', + id: 'objectPage0-op-add-header-field', + title: 'Add Header Field', + enabled: true + } + ] + } + ]) + ); + + await subscribeMock.mock.calls[0][0]( + executeQuickAction({ id: 'objectPage0-op-add-header-field', kind: 'simple' }) + ); + const { handler } = jest.requireMock<{ handler: () => Promise }>( + '../../../../src/adp/init-dialogs' + ); + + expect(handler).toHaveBeenCalledWith(mockOverlay, rtaMock, 'AddFragment', undefined, 'items'); + }); + }); + }); +}); diff --git a/packages/preview-middleware-client/test/unit/adp/quick-actions/fe-v4.test.ts b/packages/preview-middleware-client/test/unit/adp/quick-actions/fe-v4.test.ts new file mode 100644 index 0000000000..1b78687d89 --- /dev/null +++ b/packages/preview-middleware-client/test/unit/adp/quick-actions/fe-v4.test.ts @@ -0,0 +1,515 @@ +import RuntimeAuthoring, { RTAOptions } from 'sap/ui/rta/RuntimeAuthoring'; +import FlexBox from 'sap/m/FlexBox'; +import RuntimeAuthoringMock from 'mock/sap/ui/rta/RuntimeAuthoring'; + +import { quickActionListChanged, executeQuickAction } from '@sap-ux-private/control-property-editor-common'; + +jest.mock('../../../../src/adp/init-dialogs', () => { + return { + ...jest.requireActual('../../../../src/adp/init-dialogs'), + handler: jest.fn() + }; +}); +import { QuickActionService } from '../../../../src/cpe/quick-actions/quick-action-service'; +import { OutlineService } from '../../../../src/cpe/outline/service'; + +import FEV4QuickActionRegistry from 'open/ux/preview/client/adp/quick-actions/fe-v4/registry'; +import { sapCoreMock } from 'mock/window'; +import NavContainer from 'mock/sap/m/NavContainer'; +import XMLView from 'mock/sap/ui/core/mvc/XMLView'; +import ComponentContainer from 'mock/sap/ui/core/ComponentContainer'; +import TemplateComponentMock from 'mock/sap/fe/core/TemplateComponent'; +import Component from 'mock/sap/ui/core/Component'; +import CommandFactory from 'mock/sap/ui/rta/command/CommandFactory'; +import FlexUtils from 'mock/sap/ui/fl/Utils'; + +import { fetchMock } from 'mock/window'; +import { mockOverlay } from 'mock/sap/ui/dt/OverlayRegistry'; +import ComponentMock from 'mock/sap/ui/core/Component'; +import UIComponent from 'sap/ui/core/UIComponent'; +import AppComponentMock from 'mock/sap/fe/core/AppComponent'; +import FlexRuntimeInfoAPI from 'mock/sap/ui/fl/apply/api/FlexRuntimeInfoAPI'; + +describe('FE V2 quick actions', () => { + let sendActionMock: jest.Mock; + let subscribeMock: jest.Mock; + + beforeEach(() => { + sendActionMock = jest.fn(); + subscribeMock = jest.fn(); + jest.clearAllMocks(); + }); + + afterEach(() => { + fetchMock.mockRestore(); + }); + + describe('ListReport', () => { + describe('clear filter bar button', () => { + test('initialize and execute action', async () => { + const appComponent = new AppComponentMock(); + const component = new TemplateComponentMock(); + jest.spyOn(component, 'getAppComponent').mockReturnValue(appComponent); + jest.spyOn(ComponentMock, 'getOwnerComponentFor').mockImplementation(() => { + return component as unknown as UIComponent; + }); + sapCoreMock.byId.mockImplementation((id) => { + if (id == 'FilterBar') { + return { + getShowClearButton: jest.fn().mockImplementation(() => false), + getDomRef: () => ({}), + getParent: () => ({}) + }; + } + if (id == 'NavContainer') { + const container = new NavContainer(); + const pageView = new XMLView(); + pageView.getDomRef.mockImplementation(() => { + return { + contains: () => true + }; + }); + pageView.getId.mockReturnValue('test.app::ProductsList'); + pageView.getViewName.mockImplementation(() => 'sap.fe.templates.ListReport.ListReport'); + const componentContainer = new ComponentContainer(); + jest.spyOn(componentContainer, 'getComponent').mockImplementation(() => { + return 'component-id'; + }); + jest.spyOn(Component, 'getComponentById').mockImplementation((id: string | undefined) => { + if (id === 'component-id') { + return component; + } + }); + container.getCurrentPage.mockImplementation(() => { + return componentContainer; + }); + component.getRootControl.mockImplementation(() => { + return pageView; + }); + return container; + } + }); + + CommandFactory.getCommandFor.mockImplementation((control, type, value, _, settings) => { + return { type, value, settings }; + }); + + const rtaMock = new RuntimeAuthoringMock({} as RTAOptions) as unknown as RuntimeAuthoring; + const registry = new FEV4QuickActionRegistry(); + const service = new QuickActionService(rtaMock, new OutlineService(rtaMock), [registry]); + await service.init(sendActionMock, subscribeMock); + + await service.reloadQuickActions({ + 'sap.fe.macros.controls.FilterBar': [ + { + controlId: 'FilterBar' + } as any + ], + 'sap.m.NavContainer': [ + { + controlId: 'NavContainer' + } as any + ] + }); + + expect(sendActionMock).toHaveBeenCalledWith( + quickActionListChanged([ + { + title: 'LIST REPORT', + actions: [ + { + 'kind': 'simple', + id: 'listReport0-enable-clear-filter-bar', + title: 'Enable clear filterbar button', + enabled: true + } + ] + } + ]) + ); + + await subscribeMock.mock.calls[0][0]( + executeQuickAction({ id: 'listReport0-enable-clear-filter-bar', kind: 'simple' }) + ); + expect(rtaMock.getCommandStack().pushAndExecute).toHaveBeenCalledWith({ + settings: {}, + type: 'appDescriptor', + value: { + appComponent, + reference: 'test.id', + changeType: 'appdescr_fe_changePageConfiguration', + parameters: { + page: 'ProductsList', + entityPropertyChange: { + propertyPath: + 'controlConfiguration/@com.sap.vocabularies.UI.v1.SelectionFields/showClearButton', + propertyValue: true, + operation: 'UPSERT' + } + } + } + }); + }); + }); + + describe('add controller to the page', () => { + test('initialize and execute action', async () => { + const pageView = new XMLView(); + FlexUtils.getViewForControl.mockImplementation(() => { + return { + getId: () => 'MyView', + getController: () => { + return { + getMetadata: () => { + return { + getName: () => 'MyController' + }; + } + }; + } + }; + }); + fetchMock.mockResolvedValue({ + json: jest + .fn() + .mockReturnValueOnce({ + controllerExists: false, + controllerPath: '', + controllerPathFromRoot: '', + isRunningInBAS: false + }) + .mockReturnValueOnce({ controllers: [] }), + text: jest.fn(), + ok: true + }); + const appComponent = new AppComponentMock(); + const component = new TemplateComponentMock(); + jest.spyOn(component, 'getAppComponent').mockReturnValue(appComponent); + jest.spyOn(ComponentMock, 'getOwnerComponentFor').mockImplementation(() => { + return component as unknown as UIComponent; + }); + sapCoreMock.byId.mockImplementation((id) => { + if (id == 'DynamicPage') { + return { + getDomRef: () => ({}), + getParent: () => pageView + }; + } + if (id == 'NavContainer') { + const container = new NavContainer(); + const component = new TemplateComponentMock(); + pageView.getDomRef.mockImplementation(() => { + return { + contains: () => true + }; + }); + pageView.getId.mockReturnValue('test.app::ProductsList'); + pageView.getViewName.mockImplementation(() => 'sap.fe.templates.ListReport.ListReport'); + const componentContainer = new ComponentContainer(); + jest.spyOn(componentContainer, 'getComponent').mockImplementation(() => { + return 'component-id'; + }); + jest.spyOn(Component, 'getComponentById').mockImplementation((id: string | undefined) => { + if (id === 'component-id') { + return component; + } + }); + container.getCurrentPage.mockImplementation(() => { + return componentContainer; + }); + component.getRootControl.mockImplementation(() => { + return pageView; + }); + return container; + } + }); + + const rtaMock = new RuntimeAuthoringMock({} as RTAOptions) as unknown as RuntimeAuthoring; + const registry = new FEV4QuickActionRegistry(); + const service = new QuickActionService(rtaMock, new OutlineService(rtaMock), [registry]); + await service.init(sendActionMock, subscribeMock); + + await service.reloadQuickActions({ + 'sap.f.DynamicPage': [ + { + controlId: 'DynamicPage' + } as any + ], + 'sap.m.NavContainer': [ + { + controlId: 'NavContainer' + } as any + ] + }); + + expect(sendActionMock).toHaveBeenCalledWith( + quickActionListChanged([ + { + title: 'LIST REPORT', + actions: [ + { + 'kind': 'simple', + id: 'listReport0-add-controller-to-page', + title: 'Add controller to page', + enabled: true + } + ] + } + ]) + ); + + await subscribeMock.mock.calls[0][0]( + executeQuickAction({ id: 'listReport0-add-controller-to-page', kind: 'simple' }) + ); + const { handler } = jest.requireMock<{ handler: () => Promise }>( + '../../../../src/adp/init-dialogs' + ); + + expect(handler).toHaveBeenCalledWith(mockOverlay, rtaMock, 'ControllerExtension'); + }); + }); + + describe('change table columns', () => { + test('initialize and execute action', async () => { + const pageView = new XMLView(); + jest.spyOn(FlexRuntimeInfoAPI, 'hasVariantManagement').mockReturnValue(true); + const scrollIntoView = jest.fn(); + const appComponent = new AppComponentMock(); + const component = new TemplateComponentMock(); + jest.spyOn(component, 'getAppComponent').mockReturnValue(appComponent); + jest.spyOn(ComponentMock, 'getOwnerComponentFor').mockImplementation(() => { + return component as unknown as UIComponent; + }); + sapCoreMock.byId.mockImplementation((id) => { + if (id == 'Table') { + return { + isA: (type: string) => type === 'sap.ui.mdc.Table', + getHeader: () => 'MyTable', + getId: () => id, + getDomRef: () => ({ + scrollIntoView + }), + getParent: () => pageView, + getBusy: () => false + }; + } + if (id == 'NavContainer') { + const container = new NavContainer(); + const component = new TemplateComponentMock(); + pageView.getDomRef.mockImplementation(() => { + return { + contains: () => true + }; + }); + pageView.getId.mockReturnValue('test.app::ProductsList'); + pageView.getViewName.mockImplementation(() => 'sap.fe.templates.ListReport.ListReport'); + const componentContainer = new ComponentContainer(); + jest.spyOn(componentContainer, 'getComponent').mockImplementation(() => { + return 'component-id'; + }); + jest.spyOn(Component, 'getComponentById').mockImplementation((id: string | undefined) => { + if (id === 'component-id') { + return component; + } + }); + container.getCurrentPage.mockImplementation(() => { + return componentContainer; + }); + component.getRootControl.mockImplementation(() => { + return pageView; + }); + return container; + } + }); + + const execute = jest.fn(); + const rtaMock = new RuntimeAuthoringMock({} as RTAOptions) as unknown as RuntimeAuthoring; + jest.spyOn(rtaMock, 'getService').mockImplementation((serviceName: string): any => { + if (serviceName === 'action') { + return { + get: (controlId: string) => { + if (controlId === 'Table') { + return [{ id: 'CTX_SETTINGS0' }]; + } + }, + execute + }; + } + }); + const registry = new FEV4QuickActionRegistry(); + const service = new QuickActionService(rtaMock, new OutlineService(rtaMock), [registry]); + await service.init(sendActionMock, subscribeMock); + + await service.reloadQuickActions({ + 'sap.ui.mdc.Table': [ + { + controlId: 'Table' + } as any + ], + 'sap.m.NavContainer': [ + { + controlId: 'NavContainer' + } as any + ] + }); + + expect(sendActionMock).toHaveBeenCalledWith( + quickActionListChanged([ + { + title: 'LIST REPORT', + actions: [ + { + 'kind': 'nested', + id: 'listReport0-change-table-columns', + title: 'Change table columns', + enabled: true, + children: [ + { + children: [], + label: `'MyTable' table` + } + ] + } + ] + } + ]) + ); + + await subscribeMock.mock.calls[0][0]( + executeQuickAction({ id: 'listReport0-change-table-columns', kind: 'nested', path: '0' }) + ); + + expect(execute).toHaveBeenCalledWith('Table', 'CTX_SETTINGS0'); + }); + }); + + describe('ObjectPage', () => { + describe('add header field', () => { + test('initialize and execute action', async () => { + const pageView = new XMLView(); + FlexUtils.getViewForControl.mockImplementation(() => { + return { + getId: () => 'MyView', + getController: () => { + return { + getMetadata: () => { + return { + getName: () => 'MyController' + }; + } + }; + } + }; + }); + fetchMock.mockResolvedValue({ + json: jest + .fn() + .mockReturnValueOnce({ + controllerExists: false, + controllerPath: '', + controllerPathFromRoot: '', + isRunningInBAS: false + }) + .mockReturnValueOnce({ controllers: [] }), + text: jest.fn(), + ok: true + }); + const appComponent = new AppComponentMock(); + const component = new TemplateComponentMock(); + jest.spyOn(component, 'getAppComponent').mockReturnValue(appComponent); + jest.spyOn(ComponentMock, 'getOwnerComponentFor').mockImplementation(() => { + return component as unknown as UIComponent; + }); + sapCoreMock.byId.mockImplementation((id) => { + if (id == 'ObjectPageLayout') { + return { + getId: () => 'ObjectPageLayout', + getDomRef: () => ({}), + getParent: () => pageView, + getHeaderContent: () => { + return [new FlexBox()] + } + }; + } + if (id == 'NavContainer') { + const container = new NavContainer(); + const component = new TemplateComponentMock(); + pageView.getDomRef.mockImplementation(() => { + return { + contains: () => true + }; + }); + pageView.getId.mockReturnValue('test.app::ProductDetails'); + pageView.getViewName.mockImplementation(() => 'sap.fe.templates.ObjectPage.ObjectPage'); + const componentContainer = new ComponentContainer(); + jest.spyOn(componentContainer, 'getComponent').mockImplementation(() => { + return 'component-id'; + }); + jest.spyOn(Component, 'getComponentById').mockImplementation((id: string | undefined) => { + if (id === 'component-id') { + return component; + } + }); + container.getCurrentPage.mockImplementation(() => { + return componentContainer; + }); + component.getRootControl.mockImplementation(() => { + return pageView; + }); + return container; + } + }); + + const rtaMock = new RuntimeAuthoringMock({} as RTAOptions) as unknown as RuntimeAuthoring; + const registry = new FEV4QuickActionRegistry(); + const service = new QuickActionService(rtaMock, new OutlineService(rtaMock), [registry]); + await service.init(sendActionMock, subscribeMock); + + await service.reloadQuickActions({ + 'sap.uxap.ObjectPageLayout': [ + { + controlId: 'ObjectPageLayout' + } as any + ], + 'sap.m.NavContainer': [ + { + controlId: 'NavContainer' + } as any + ] + }); + + expect(sendActionMock).toHaveBeenCalledWith( + quickActionListChanged([ + { + title: 'OBJECT PAGE', + actions: [ + { + kind: 'simple', + id: 'objectPage0-add-controller-to-page', + enabled: true, + title: 'Add controller to page' + }, + { + kind: 'simple', + id: 'objectPage0-op-add-header-field', + title: 'Add Header Field', + enabled: true + } + ] + } + ]) + ); + + await subscribeMock.mock.calls[0][0]( + executeQuickAction({ id: 'objectPage0-op-add-header-field', kind: 'simple' }) + ); + const { handler } = jest.requireMock<{ handler: () => Promise }>( + '../../../../src/adp/init-dialogs' + ); + + expect(handler).toHaveBeenCalledWith(mockOverlay, rtaMock, 'AddFragment', undefined, 'items'); + }); + }); + }); + }); +}); diff --git a/packages/preview-middleware-client/test/unit/adp/quick-actions/load.test.ts b/packages/preview-middleware-client/test/unit/adp/quick-actions/load.test.ts new file mode 100644 index 0000000000..69520cd139 --- /dev/null +++ b/packages/preview-middleware-client/test/unit/adp/quick-actions/load.test.ts @@ -0,0 +1,14 @@ +import { loadDefinitions } from '../../../../src/adp/quick-actions/load'; +import FEV4QuickActionRegistry from 'open/ux/preview/client/adp/quick-actions/fe-v4/registry'; +import FEV2QuickActionRegistry from 'open/ux/preview/client/adp/quick-actions/fe-v2/registry'; + +describe('quick action dyncmic loading', () => { + test('fe-v2', async () => { + const definitions = await loadDefinitions('fe-v2'); + expect(definitions[0]).toBeInstanceOf(FEV2QuickActionRegistry); + }); + test('fe-v4', async () => { + const definitions = await loadDefinitions('fe-v4'); + expect(definitions[0]).toBeInstanceOf(FEV4QuickActionRegistry); + }); +}); diff --git a/packages/preview-middleware-client/test/unit/cpe/__snapshots__/control-data.test.ts.snap b/packages/preview-middleware-client/test/unit/cpe/__snapshots__/control-data.test.ts.snap index 8a8cf09a1d..45d97368ba 100644 --- a/packages/preview-middleware-client/test/unit/cpe/__snapshots__/control-data.test.ts.snap +++ b/packages/preview-middleware-client/test/unit/cpe/__snapshots__/control-data.test.ts.snap @@ -6,10 +6,10 @@ Object { "name": "sap.m.Button", "properties": Array [ Object { - "editor": "dropdown", + "editor": "input", "isEnabled": false, + "isIcon": true, "name": "activeIcon", - "options": Array [], "readableName": "Active Icon", "type": "string", "ui5Type": "sap.ui.core.URI", @@ -86,10 +86,10 @@ Object { "value": undefined, }, Object { - "editor": "dropdown", + "editor": "input", "isEnabled": false, + "isIcon": false, "name": "width", - "options": Array [], "readableName": "Width", "type": "string", "ui5Type": "sap.ui.core.CSSSize", diff --git a/packages/preview-middleware-client/test/unit/cpe/init.test.ts b/packages/preview-middleware-client/test/unit/cpe/init.test.ts index a9ac5ffb18..b6df820d48 100644 --- a/packages/preview-middleware-client/test/unit/cpe/init.test.ts +++ b/packages/preview-middleware-client/test/unit/cpe/init.test.ts @@ -1,16 +1,19 @@ -import init from '../../../src/cpe/init'; import * as common from '@sap-ux-private/control-property-editor-common'; -import * as flexChange from '../../../src/cpe/changes/flex-change'; -import * as outline from '../../../src/cpe/outline'; -import type Event from 'sap/ui/base/Event'; + +import RuntimeAuthoringMock from 'mock/sap/ui/rta/RuntimeAuthoring'; +import VersionInfo from 'mock/sap/ui/VersionInfo'; import Log from 'mock/sap/base/Log'; import { fetchMock, sapCoreMock } from 'mock/window'; + +import init from '../../../src/cpe/init'; +import * as flexChange from '../../../src/cpe/changes/flex-change'; +import { OutlineService } from '../../../src/cpe/outline/service'; import * as ui5Utils from '../../../src/cpe/ui5-utils'; import connector from '../../../src/flp/WorkspaceConnector'; -import VersionInfo from 'mock/sap/ui/VersionInfo'; +import RuntimeAuthoring, { RTAOptions } from 'sap/ui/rta/RuntimeAuthoring'; describe('main', () => { - let sendActionMock: jest.Mock; + const sendActionMock = jest.fn(); VersionInfo.load.mockResolvedValue({ version: '1.120.4' }); const applyChangeSpy = jest .spyOn(flexChange, 'applyChange') @@ -22,7 +25,8 @@ describe('main', () => { 'Error: Applying property changes failed: Error: "" is of type string, expected boolean for property "enabled" of Element sap.m.Buttonx#v2flex::sap.suite.ui.generic.template.ListReport.view.ListReport::SEPMRA_C_PD_Product--action::SEPMRA_PROD_MAN.SEPMRA_PROD_MAN_Entities::SEPMRA_C_PD_ProductCopy' ) }); - const initOutlineSpy = jest.spyOn(outline, 'initOutline'); + const initOutlineSpy = jest.spyOn(OutlineService.prototype, 'init'); + beforeAll(() => { const apiJson = { json: () => { @@ -33,9 +37,20 @@ describe('main', () => { .mockImplementationOnce(() => Promise.resolve(apiJson)) .mockImplementation(() => Promise.resolve({ json: jest.fn().mockResolvedValue({}) })); }); + + let rta: RuntimeAuthoring; + beforeEach(() => { - sendActionMock = jest.fn(); + rta = new RuntimeAuthoringMock({} as RTAOptions); + RuntimeAuthoringMock.prototype.getFlexSettings = jest.fn().mockReturnValue({ + layer: 'VENDOR', + scenario: common.SCENARIO.UiAdaptation + } as any); + RuntimeAuthoringMock.prototype.getRootControlInstance = jest.fn().mockReturnValue({ + getManifest: jest.fn().mockReturnValue({ 'sap.app': { id: 'testId' } }) + }); }); + afterEach(() => { applyChangeSpy.mockClear(); initOutlineSpy.mockClear(); @@ -64,21 +79,6 @@ describe('main', () => { jest.spyOn(ui5Utils, 'getIcons').mockImplementation(() => { return mockIconResult; }); - const attachSelectionChange = jest.fn().mockImplementation((newHandler: (event: Event) => Promise) => { - return newHandler; - }); - - const rta = { - attachSelectionChange, - getSelection: jest.fn().mockReturnValue([{ setSelected: jest.fn() }, { setSelected: jest.fn() }]), - attachUndoRedoStackModified: jest.fn(), - getFlexSettings: jest.fn().mockReturnValue({ layer: 'VENDOR', scenario: common.SCENARIO.UiAdaptation }), - getRootControlInstance: jest.fn().mockReturnValue({ - getManifest: jest.fn().mockReturnValue({ 'sap.app': { id: 'testId' } }) - }), - attachStop: jest.fn(), - attachModeChanged: jest.fn() - } as any; const spyPostMessage = jest.spyOn(common, 'startPostMessageCommunication').mockImplementation(() => { return { sendAction: sendActionMock, dispose: jest.fn() }; @@ -86,6 +86,7 @@ describe('main', () => { test('init - 1', async () => { initOutlineSpy.mockResolvedValue(); + // const rta = new RuntimeAuthoringMock(); await init(rta); const callBackFn = spyPostMessage.mock.calls[0][1]; // apply change without error @@ -105,41 +106,18 @@ describe('main', () => { await connector.storage.removeItem('sap.ui.fl.testFile'); //assert - expect(applyChangeSpy).toBeCalledWith({ rta: rta }, payload); - expect(initOutlineSpy).toBeCalledWith(rta, sendActionMock); + expect(applyChangeSpy).toBeCalledWith({ rta }, payload); + expect(initOutlineSpy).toHaveBeenCalledTimes(1); }); test('init - rta exception', async () => { const error = new Error('Cannot init outline'); initOutlineSpy.mockRejectedValue(error); + + // act await init(rta); - const callBackFn = spyPostMessage.mock.calls[0][1]; - const payload = { - controlId: - 'v2flex::sap.suite.ui.generic.template.ListReport.view.ListReport::SEPMRA_C_PD_Product--action::SEPMRA_PROD_MAN.SEPMRA_PROD_MAN_Entities::SEPMRA_C_PD_ProductCopy', - propertyName: 'enabled', - value: 'falsee' - }; - // apply change - await callBackFn({ - type: '[ext] change-property', - payload - }); // assert - expect(applyChangeSpy).toBeCalledWith({ rta: rta }, payload); - expect(sendActionMock).toHaveBeenNthCalledWith(1, { - type: '[ext] icons-loaded', - payload: mockIconResult - }); - expect(sendActionMock).toHaveBeenNthCalledWith(2, { - type: '[ext] app-loaded', - payload: undefined - }); - expect(sendActionMock).toHaveBeenNthCalledWith(3, { - type: '[ext] change-stack-modified', - payload: { saved: [], pending: [] } - }); - expect(initOutlineSpy).toBeCalledWith(rta, sendActionMock); - expect(Log.error).toBeCalledWith('Error during initialization of Control Property Editor', error); + expect(initOutlineSpy).toHaveBeenCalledTimes(1); + expect(Log.error).toBeCalledWith('Service Initalization Failed: ', error); }); }); diff --git a/packages/preview-middleware-client/test/unit/cpe/outline/nodes.test.ts b/packages/preview-middleware-client/test/unit/cpe/outline/nodes.test.ts index 0f0d8ed024..80fdee98b6 100644 --- a/packages/preview-middleware-client/test/unit/cpe/outline/nodes.test.ts +++ b/packages/preview-middleware-client/test/unit/cpe/outline/nodes.test.ts @@ -3,14 +3,23 @@ import type { OutlineNode } from '@sap-ux-private/control-property-editor-common import type { OutlineViewNode } from 'sap/ui/rta/command/OutlineService'; import type { Scenario } from 'sap/ui/fl/Scenario'; +import type { ControlTreeIndex } from 'open/ux/preview/client/cpe/types'; import { transformNodes as tn } from '../../../../src/cpe/outline/nodes'; + import { sapCoreMock } from 'mock/window'; import ComponentMock from 'mock/sap/ui/core/Component'; import VersionInfo from 'mock/sap/ui/VersionInfo'; -jest.mock('../../../../src/cpe/outline/utils', () => { +jest.mock('../../../../src/cpe/outline/editable', () => { + return { + ...jest.requireActual('../../../../src/cpe/outline/editable'), + isEditable: () => false + }; +}); + +jest.mock('../../../../src/cpe/utils', () => { return { - isEditable: () => false, + ...jest.requireActual('../../../../src/cpe/utils'), isReuseComponent: () => true }; }); @@ -19,8 +28,9 @@ describe('outline nodes', () => { const transformNodes = ( nodes: OutlineViewNode[], scenario: Scenario, - reuseComponentsIds: Set = new Set() - ): Promise => tn(nodes, scenario, reuseComponentsIds); + reuseComponentsIds: Set = new Set(), + controlIndex: ControlTreeIndex = {} + ): Promise => tn(nodes, scenario, reuseComponentsIds, controlIndex); sapCoreMock.byId.mockReturnValue({ getMetadata: jest.fn().mockReturnValue({ getProperty: jest diff --git a/packages/preview-middleware-client/test/unit/cpe/outline/index.test.ts b/packages/preview-middleware-client/test/unit/cpe/outline/service.test.ts similarity index 88% rename from packages/preview-middleware-client/test/unit/cpe/outline/index.test.ts rename to packages/preview-middleware-client/test/unit/cpe/outline/service.test.ts index cdea7c2aba..85b4c9fb5b 100644 --- a/packages/preview-middleware-client/test/unit/cpe/outline/index.test.ts +++ b/packages/preview-middleware-client/test/unit/cpe/outline/service.test.ts @@ -1,5 +1,5 @@ import { sapCoreMock } from 'mock/window'; -import { initOutline } from '../../../../src/cpe/outline/index'; +import { OutlineService } from '../../../../src/cpe/outline/service'; import * as nodes from '../../../../src/cpe/outline/nodes'; import RuntimeAuthoringMock from 'mock/sap/ui/rta/RuntimeAuthoring'; import { RTAOptions } from 'sap/ui/rta/RuntimeAuthoring'; @@ -9,6 +9,7 @@ import Log from 'sap/base/Log'; jest.useFakeTimers(); describe('index', () => { + const mockSendAction = jest.fn(); const mockAttachEvent = jest.fn(); const transformNodesSpy = jest.spyOn(nodes, 'transformNodes'); @@ -51,7 +52,8 @@ describe('index', () => { visible: true } ]); - await initOutline(rtaMock as unknown as RuntimeAuthoring, mockSendAction); + const service = new OutlineService(rtaMock as unknown as RuntimeAuthoring); + await service.init(mockSendAction); expect(transformNodesSpy.mock.calls[0][0]).toBe('mockViewNodes'); expect(mockSendAction).toMatchInlineSnapshot(` [MockFunction] { @@ -84,7 +86,8 @@ describe('index', () => { test('initOutline - exception', async () => { transformNodesSpy.mockRejectedValue('error'); - await initOutline(rtaMock as unknown as RuntimeAuthoring, mockSendAction); + const service = new OutlineService(rtaMock as unknown as RuntimeAuthoring); + await service.init(mockSendAction); // transformNodesSpy called but rejected. expect(transformNodesSpy).toHaveBeenCalled(); expect(mockSendAction).not.toHaveBeenCalled(); @@ -104,7 +107,8 @@ describe('index', () => { ]); transformNodesSpy.mockRejectedValue('error'); - await initOutline(rtaMock as unknown as RuntimeAuthoring, mockSendAction); + const service = new OutlineService(rtaMock as unknown as RuntimeAuthoring); + await service.init(mockSendAction); expect(transformNodesSpy.mock.calls[0][0]).toBe('mockViewNodes'); }); @@ -116,7 +120,8 @@ describe('index', () => { rtaMock.getFlexSettings.mockReturnValue({ scenario: 'ADAPTATION_PROJECT' }); - await initOutline(rtaMock as unknown as RuntimeAuthoring, mockSendAction); + const service = new OutlineService(rtaMock as unknown as RuntimeAuthoring); + await service.init(mockSendAction); expect(mockSendAction).toHaveBeenNthCalledWith(2, { type: '[ext] show-dialog-message', payload: { diff --git a/packages/preview-middleware-client/test/unit/cpe/outline/utils.test.ts b/packages/preview-middleware-client/test/unit/cpe/outline/utils.test.ts index 5326d8d56a..3ba6f1c24f 100644 --- a/packages/preview-middleware-client/test/unit/cpe/outline/utils.test.ts +++ b/packages/preview-middleware-client/test/unit/cpe/outline/utils.test.ts @@ -1,5 +1,6 @@ import OverlayRegistry from 'mock/sap/ui/dt/OverlayRegistry'; -import { isEditable, isReuseComponent } from '../../../../src/cpe/outline/utils'; +import { isEditable } from '../../../../src/cpe/outline/editable'; +import { isReuseComponent } from '../../../../src/cpe/utils'; import OverlayUtil from 'mock/sap/ui/dt/OverlayUtil'; import ComponentMock from 'mock/sap/ui/core/Component'; import { sapCoreMock } from 'mock/window'; diff --git a/packages/preview-middleware-client/test/unit/cpe/quick-actions/service.test.ts b/packages/preview-middleware-client/test/unit/cpe/quick-actions/service.test.ts new file mode 100644 index 0000000000..aea1fdc683 --- /dev/null +++ b/packages/preview-middleware-client/test/unit/cpe/quick-actions/service.test.ts @@ -0,0 +1,101 @@ +import type FlexCommand from 'sap/ui/rta/command/FlexCommand'; +import RuntimeAuthoring, { RTAOptions } from 'sap/ui/rta/RuntimeAuthoring'; +import RuntimeAuthoringMock from 'mock/sap/ui/rta/RuntimeAuthoring'; + +import { + SimpleQuickAction, + quickActionListChanged, + executeQuickAction +} from '@sap-ux-private/control-property-editor-common'; + +import { QuickActionService } from '../../../../src/cpe/quick-actions/quick-action-service'; +import { OutlineService } from '../../../../src/cpe/outline/service'; +import { + QuickActionActivationContext, + QuickActionContext, + QuickActionDefinitionGroup, + SimpleQuickActionDefinition +} from 'open/ux/preview/client/cpe/quick-actions/quick-action-definition'; +import { QuickActionDefinitionRegistry } from 'open/ux/preview/client/cpe/quick-actions/registry'; + +class MockDefinition implements SimpleQuickActionDefinition { + readonly kind = 'simple'; + readonly type = 'MOCK_DEFINITION'; + public get id(): string { + return `${this.context.key}-${this.type}`; + } + isActive = false; + constructor(private context: QuickActionContext) {} + getActionObject(): SimpleQuickAction { + return { + kind: this.kind, + + id: this.id, + enabled: this.isActive, + title: 'Mock Action' + }; + } + initialize(): void { + this.isActive = true; + } + execute(): FlexCommand[] { + return [ + { + id: 'mock command' + } as unknown as FlexCommand + ]; + } +} + +class MockRegistry extends QuickActionDefinitionRegistry { + getDefinitions(_context: QuickActionActivationContext): QuickActionDefinitionGroup[] { + return [ + { + key: 'mock', + title: 'mock', + view: jest.fn() as any, + definitions: [MockDefinition] + } + ]; + } +} + +describe('quick action service', () => { + let sendActionMock: jest.Mock; + let subscribeMock: jest.Mock; + + beforeEach(() => { + sendActionMock = jest.fn(); + subscribeMock = jest.fn(); + }); + + test('initialize simple action definition', async () => { + const rtaMock = new RuntimeAuthoringMock({} as RTAOptions) as unknown as RuntimeAuthoring; + const registry = new MockRegistry(); + const service = new QuickActionService(rtaMock, new OutlineService(rtaMock), [registry]); + await service.init(sendActionMock, subscribeMock); + + await service.reloadQuickActions({}); + + expect(sendActionMock).toHaveBeenCalledWith( + quickActionListChanged([ + { + title: 'mock', + actions: [ + { + 'kind': 'simple', + id: 'mock-MOCK_DEFINITION', + title: 'Mock Action', + enabled: true + } + ] + } + ]) + ); + + await subscribeMock.mock.calls[0][0](executeQuickAction({ id: 'mock-MOCK_DEFINITION', kind: 'simple' })); + expect(rtaMock.getCommandStack().pushAndExecute).toHaveBeenCalledWith({ + id: 'mock command' + }); + }); +}); diff --git a/packages/preview-middleware-client/test/unit/cpe/ui5-utils.test.ts b/packages/preview-middleware-client/test/unit/cpe/ui5-utils.test.ts index 29f0e5bedc..b57322ac81 100644 --- a/packages/preview-middleware-client/test/unit/cpe/ui5-utils.test.ts +++ b/packages/preview-middleware-client/test/unit/cpe/ui5-utils.test.ts @@ -1,8 +1,7 @@ import IconPool from 'mock/sap/ui/core/IconPool'; -import Component from 'sap/ui/core/Component'; import { sapCoreMock } from 'mock/window'; import type Element from 'sap/ui/core/Element'; -import { getComponent, getIcons } from '../../../src/cpe/ui5-utils'; +import { getIcons } from '../../../src/cpe/ui5-utils'; describe('ui5Utils', () => { const testElement = {} as Element; @@ -14,21 +13,6 @@ describe('ui5Utils', () => { jest.clearAllMocks(); }); - test('getComponent - deprecated', () => { - (Component as any).get = undefined; - const component = getComponent(testComponent.id); - expect(sapCoreMock.getComponent).toBeCalledWith(testComponent.id); - expect(component).toStrictEqual(testComponent); - }); - - test('getComponent', () => { - Component.get = jest.fn().mockReturnValue(testComponent); - const component = getComponent(testComponent.id); - expect(Component.get).toBeCalledWith(testComponent.id); - expect(sapCoreMock.getComponent).not.toBeCalled(); - expect(component).toStrictEqual(testComponent); - }); - describe('getIcons', () => { const testIcons = { Reject: { diff --git a/packages/preview-middleware-client/test/unit/cpe/utils.test.ts b/packages/preview-middleware-client/test/unit/cpe/utils.test.ts index fafcc47abb..abc9903fad 100644 --- a/packages/preview-middleware-client/test/unit/cpe/utils.test.ts +++ b/packages/preview-middleware-client/test/unit/cpe/utils.test.ts @@ -42,7 +42,6 @@ describe('getRuntimeControl', () => { }); }); describe('getLibrary', () => { - afterEach(() => { sapMock.ui.require.mockReset(); }); diff --git a/packages/preview-middleware-client/test/unit/utils/application.test.ts b/packages/preview-middleware-client/test/unit/utils/application.test.ts new file mode 100644 index 0000000000..0829811b3d --- /dev/null +++ b/packages/preview-middleware-client/test/unit/utils/application.test.ts @@ -0,0 +1,56 @@ +import { getApplicationType } from '../../../src/utils/application'; + +describe('getApplicationType - fev2', () => { + const manifestMockListreportApp = { + ['sap.ui.generic.app']: { + pages: [ + { + entitySet: 'Products' + } + ] + } + } as any; + + const manifestMockOVPApp = { + ['sap.ovp']: { + pages: [ + { + entitySet: 'Products' + } + ] + } + } as any; + test('fev2 - lrp app', () => { + expect(getApplicationType(manifestMockListreportApp)).toBe('fe-v2'); + }); + test('fev2 - ovp app', () => { + expect(getApplicationType(manifestMockOVPApp)).toBe('fe-v2'); + }); +}); + +describe('getApplicationType - fev4', () => { + const manifestMockListreportApp = { + ['sap.ui5']: { + routing: { + targets: { + 'ListReport|Products': { + name: 'sap.fe.templates.ListReport', + settings: {} + } + } + } + } + } as any; + + test('fev4 - lrp app', () => { + expect(getApplicationType(manifestMockListreportApp)).toBe('fe-v4'); + }); +}); + +describe('getApplicationType - freestyle', () => { + const manifestMockListreportApp = {} as any; + + test('freestyle app', () => { + expect(getApplicationType(manifestMockListreportApp)).toBe('freestyle'); + }); +}); diff --git a/packages/preview-middleware-client/test/unit/utils/core.test.ts b/packages/preview-middleware-client/test/unit/utils/core.test.ts new file mode 100644 index 0000000000..75ae395e12 --- /dev/null +++ b/packages/preview-middleware-client/test/unit/utils/core.test.ts @@ -0,0 +1,91 @@ +import { sapCoreMock } from 'mock/window'; +import type Element from 'sap/ui/core/Element'; +import ManagedObjectMock from 'mock/sap/ui/base/ManagedObject'; +import { isA, isManagedObject } from '../../../src/utils/core'; + +describe('ui5Utils', () => { + const testElement = {} as Element; + const testComponent = { id: '~id' }; + sapCoreMock.byId.mockReturnValue(testElement); + sapCoreMock.getComponent.mockReturnValue(testComponent); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('getComponent', () => { + beforeEach(() => { + jest.resetModules(); + }); + + test('sap.ui.getCore().getComponent (deprecated)', async () => { + jest.mock('sap/ui/core/Component', () => { + return {}; + }); + + const { getComponent } = await import('../../../src/utils/core'); + const component = getComponent(testComponent.id); + + expect(sapCoreMock.getComponent).toBeCalledWith(testComponent.id); + expect(component).toStrictEqual(testComponent); + }); + + test('Component.get (deprecated)', async () => { + const Component = { + get: jest.fn().mockReturnValue(testComponent) + }; + jest.mock('sap/ui/core/Component', () => { + return Component; + }); + const { getComponent } = await import('../../../src/utils/core'); + const component = getComponent(testComponent.id); + + expect(Component.get).toBeCalledWith(testComponent.id); + expect(sapCoreMock.getComponent).not.toBeCalled(); + expect(component).toStrictEqual(testComponent); + }); + + test('Component.getComponentById', async () => { + const Component = { + get: jest.fn().mockReturnValue(testComponent), + getComponentById: jest.fn().mockReturnValue(testComponent) + }; + jest.mock('sap/ui/core/Component', () => { + return Component; + }); + + const { getComponent } = await import('../../../src/utils/core'); + const component = getComponent(testComponent.id); + + expect(Component.getComponentById).toBeCalledWith(testComponent.id); + expect(Component.get).not.toBeCalled(); + expect(sapCoreMock.getComponent).not.toBeCalled(); + expect(component).toStrictEqual(testComponent); + }); + }); +}); + +describe('isManagedObject', () => { + test('empty object', () => { + expect(isManagedObject({})).toBe(false); + }); + + test('does not implement isA', () => { + expect(isManagedObject({ isA: 5 })).toBe(false); + }); + + test('isA checks for "sap.ui.base.ManagedObject" ', () => { + expect(isManagedObject({ isA: (type: string) => type === 'sap.ui.base.ManagedObject' })).toBe(true); + }); +}); + +describe('isA', () => { + test('calls "isA" on ManagedObject', () => { + const managedObject = new ManagedObjectMock(); + const spy = jest.spyOn(managedObject, 'isA').mockImplementation((type: string | string[]) => { + return type === 'sap.ui.base.ManagedObject'; + }); + expect(isA('sap.ui.base.ManagedObject', managedObject)).toBe(true); + expect(spy).toHaveBeenCalledWith('sap.ui.base.ManagedObject'); + }); +}); diff --git a/packages/preview-middleware-client/tsconfig.json b/packages/preview-middleware-client/tsconfig.json index dbba6066a5..7bba167c6f 100644 --- a/packages/preview-middleware-client/tsconfig.json +++ b/packages/preview-middleware-client/tsconfig.json @@ -7,7 +7,10 @@ "types" ], "compilerOptions": { - "lib": ["ES2020", "DOM"], + "lib": [ + "ES2020", + "DOM" + ], "target": "ES2022", "module": "ES2022", "skipLibCheck": true, @@ -21,7 +24,8 @@ "paths": { "open/ux/preview/client/*": [ "./src/*" - ],"mock/*": [ + ], + "mock/*": [ "./test/__mock__/*" ] }, @@ -33,9 +37,12 @@ "references": [ { "path": "../control-property-editor-common" - }, + }, { "path": "../eslint-plugin-fiori-tools" + }, + { + "path": "../i18n" } ] } diff --git a/packages/preview-middleware-client/types/sap.fe.core.ts b/packages/preview-middleware-client/types/sap.fe.core.ts new file mode 100644 index 0000000000..8fdf719344 --- /dev/null +++ b/packages/preview-middleware-client/types/sap.fe.core.ts @@ -0,0 +1,17 @@ +declare module 'sap/fe/core/AppComponent' { + import UIComponent from 'sap/ui/core/UIComponent'; + interface AppComponent extends UIComponent {} + + export default AppComponent; +} + +declare module 'sap/fe/core/TemplateComponent' { + import UIComponent from 'sap/ui/core/UIComponent'; + import type AppComponent from 'sap/fe/core/AppComponent'; + interface TemplateComponent extends UIComponent { + getAppComponent(): AppComponent; + } + + export default TemplateComponent; +} + 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 aaab92baca..6a7b959071 100644 --- a/packages/preview-middleware-client/types/sap.ui.fl.d.ts +++ b/packages/preview-middleware-client/types/sap.ui.fl.d.ts @@ -28,8 +28,8 @@ declare module 'sap/ui/fl/Change' { export default Change; } /** - * Available since version `1.102` of SAPUI5 -**/ + * Available since version `1.102` of SAPUI5 + **/ declare module 'sap/ui/fl/Scenario' { const scenario = { AppVariant: 'APP_VARIANT', @@ -80,7 +80,9 @@ declare module 'sap/ui/fl/write/api/connectors/ObjectStorageConnector' { clear(): void; getItem(key: string): unknown; getItems(): Promise; - fileChangeRequestNotifier: ((fileName: string, kind: 'create' | 'delete', changeType?: string) => void) | undefined; + fileChangeRequestNotifier: + | ((fileName: string, kind: 'create' | 'delete', changeType?: string) => void) + | undefined; } class ObjectStorageConnector { @@ -91,3 +93,13 @@ declare module 'sap/ui/fl/write/api/connectors/ObjectStorageConnector' { export default ObjectStorageConnector; } + +declare module 'sap/ui/fl/apply/api/FlexRuntimeInfoAPI' { + import type UI5Element from 'sap/ui/core/Element'; + + class FlexRuntimeInfoAPI { + static hasVariantManagement(parameters: { element: UI5Element }): boolean; + } + + export default FlexRuntimeInfoAPI; +} diff --git a/packages/preview-middleware-client/types/sap.ui.rta.d.ts b/packages/preview-middleware-client/types/sap.ui.rta.d.ts index f7cb8c975e..f4119c5c61 100644 --- a/packages/preview-middleware-client/types/sap.ui.rta.d.ts +++ b/packages/preview-middleware-client/types/sap.ui.rta.d.ts @@ -86,7 +86,7 @@ declare module 'sap/ui/rta/command/CommandFactory' { static async getCommandFor( control: Element | ManagedObject | string, - commandType: string, + commandType: string, // type of settings: object, designTimeMetadata?: DesignTimeMetadata | null, flexSettings?: FlexSettings @@ -106,7 +106,7 @@ declare module 'sap/ui/rta/command/OutlineService' { instanceName?: string; name?: string; icon?: string; - component?: boolean; + component?: boolean; } export interface AggregationOutlineViewNode extends BaseOutlineViewNode { @@ -159,6 +159,9 @@ declare module 'sap/ui/rta/RuntimeAuthoring' { }; 'sap.ui5': { [key: string]: string; + routing?: { + targets?: Record; + }; flexEnabled?: boolean; }; }; @@ -206,6 +209,14 @@ declare module 'sap/ui/rta/RuntimeAuthoring' { validateAppVersion: boolean; } + export interface FEAppPage { + hasStyleClass(className: string): boolean; + getContent(): { + getComponentInstance(): Component; + }[]; + getDomRef(): Element | null; + } + export default class RuntimeAuthoring { constructor(_: RTAOptions) {} @@ -223,6 +234,9 @@ declare module 'sap/ui/rta/RuntimeAuthoring' { setPlugins: (defaultPlugins: object) => void; getRootControlInstance: () => { getManifest(): Manifest; + getRootControl(): { + getPages(): FEAppPage[]; + }; } & Component; stop: (bSkipSave, bSkipRestart) => Promise; attachStop: (handler: (event: Event) => void) => void; @@ -248,3 +262,38 @@ declare module 'sap/ui/rta/api/startAdaptation' { export default startAdaptation; } + +declare module 'sap/ui/rta/service/Action' { + export type ActionObject = { + /** + * ID of the action. + */ + id: string; + /** + * Group name in case the action has been grouped with other action(s). + */ + group: string; + /** + * Icon name. + */ + icon: string; + /** + * Indicates whether the action is active and can be executed. + */ + enabled: boolean; + /** + * Sorting rank for visual representation of the action position. + */ + rank: number; + /** + * Action name + */ + text: string; + }; + export type ActionService = { + get: (controlId: string) => Promise; + get: (controlIds: string[]) => Promise; + execute: (controlId: string, actionId: string) => Promise; + execute: (controlIds: string[], actionId: string) => Promise; + }; +} diff --git a/packages/preview-middleware/CHANGELOG.md b/packages/preview-middleware/CHANGELOG.md index c36cf393ce..4ee97bf3c2 100644 --- a/packages/preview-middleware/CHANGELOG.md +++ b/packages/preview-middleware/CHANGELOG.md @@ -1,5 +1,40 @@ # @sap-ux/preview-middleware +## 0.16.60 + +### Patch Changes + +- 0b7af6a: remove z-index for sticky Search and filter bar and added updating highlighting control logic + +## 0.16.59 + +### Patch Changes + +- b1628da: Add quick actions to adaptation editor + +## 0.16.58 + +### Patch Changes + +- Updated dependencies [d962ce1] + - @sap-ux/project-access@1.27.1 + - @sap-ux/adp-tooling@0.12.45 + +## 0.16.57 + +### Patch Changes + +- Updated dependencies [1294b1c] + - @sap-ux/adp-tooling@0.12.44 + +## 0.16.56 + +### Patch Changes + +- Updated dependencies [df29368] + - @sap-ux/project-access@1.27.0 + - @sap-ux/adp-tooling@0.12.43 + ## 0.16.55 ### Patch Changes diff --git a/packages/preview-middleware/package.json b/packages/preview-middleware/package.json index b9cf0ef5b4..c88fa33ad8 100644 --- a/packages/preview-middleware/package.json +++ b/packages/preview-middleware/package.json @@ -9,7 +9,7 @@ "bugs": { "url": "https://github.com/SAP/open-ux-tools/issues?q=is%3Aopen+is%3Aissue+label%3Abug+label%3Apreview-middleware" }, - "version": "0.16.55", + "version": "0.16.60", "license": "Apache-2.0", "author": "@SAP/ux-tools-team", "main": "dist/index.js", diff --git a/packages/project-access/CHANGELOG.md b/packages/project-access/CHANGELOG.md index 6c7beec666..8f2ef79e7a 100644 --- a/packages/project-access/CHANGELOG.md +++ b/packages/project-access/CHANGELOG.md @@ -1,5 +1,22 @@ # @sap-ux/project-access +## 1.27.1 + +### Patch Changes + +- d962ce1: Move hasUI5CliV3 to project-access for common re-use + +## 1.27.0 + +### Minor Changes + +- df29368: Method `createCapI18nEntries` - handle absolute path to cds file instead of relative path + +### Patch Changes + +- Updated dependencies [df29368] + - @sap-ux/i18n@0.2.0 + ## 1.26.9 ### Patch Changes diff --git a/packages/project-access/package.json b/packages/project-access/package.json index 4d3087174b..f241cbc5fb 100644 --- a/packages/project-access/package.json +++ b/packages/project-access/package.json @@ -1,6 +1,6 @@ { "name": "@sap-ux/project-access", - "version": "1.26.9", + "version": "1.27.1", "description": "Library to access SAP Fiori tools projects", "repository": { "type": "git", diff --git a/packages/project-access/src/index.ts b/packages/project-access/src/index.ts index fbd359af5c..7d4b59f959 100644 --- a/packages/project-access/src/index.ts +++ b/packages/project-access/src/index.ts @@ -42,7 +42,8 @@ export { toReferenceUri, filterDataSourcesByType, updatePackageScript, - findCapProjectRoot + findCapProjectRoot, + hasUI5CliV3 } from './project'; export * from './types'; export * from './library'; diff --git a/packages/project-access/src/project/access.ts b/packages/project-access/src/project/access.ts index c1a08e9e45..9454f1f6ae 100644 --- a/packages/project-access/src/project/access.ts +++ b/packages/project-access/src/project/access.ts @@ -129,7 +129,7 @@ class ApplicationAccessImp implements ApplicationAccess { /** * Maintains new translation entries in CAP i18n files. * - * @param filePath file in which the translation entry will be used. + * @param filePath absolute path to file in which the translation entry will be used. * @param newI18nEntries translation entries to write in the i18n file. * @returns boolean or exception */ diff --git a/packages/project-access/src/project/i18n/write.ts b/packages/project-access/src/project/i18n/write.ts index 9663a87d3e..04e73afa4c 100644 --- a/packages/project-access/src/project/i18n/write.ts +++ b/packages/project-access/src/project/i18n/write.ts @@ -11,7 +11,7 @@ import type { Editor } from 'mem-fs-editor'; * Maintains new translation entries in CAP i18n files. * * @param root project root. - * @param filePath file in which the translation entry will be used. + * @param filePath absolute path to file in which the translation entry will be used. * @param newI18nEntries translation entries to write in the i18n file. * @param fs optional `mem-fs-editor` instance. If provided, `mem-fs-editor` api is used instead of `fs` of node * In case of CAP project, some CDS APIs are used internally which depends on `fs` of node and not `mem-fs-editor`. diff --git a/packages/project-access/src/project/index.ts b/packages/project-access/src/project/index.ts index ca515d40d5..4bb0612d77 100644 --- a/packages/project-access/src/project/index.ts +++ b/packages/project-access/src/project/index.ts @@ -38,5 +38,5 @@ export { export { getWebappPath, readUi5Yaml } from './ui5-config'; export { getMtaPath } from './mta'; export { createApplicationAccess, createProjectAccess } from './access'; -export { updatePackageScript } from './script'; +export { updatePackageScript, hasUI5CliV3 } from './script'; export { getSpecification, getSpecificationPath, refreshSpecificationDistTags } from './specification'; diff --git a/packages/project-access/src/project/script.ts b/packages/project-access/src/project/script.ts index bbdbfc1d53..18480c7a47 100644 --- a/packages/project-access/src/project/script.ts +++ b/packages/project-access/src/project/script.ts @@ -3,6 +3,7 @@ import { FileName } from '../constants'; import type { Package } from '../types'; import type { Editor } from 'mem-fs-editor'; import { readJSON, updatePackageJSON } from '../file'; +import semVer from 'semver'; /** * Updates the package.json with a new script. @@ -26,3 +27,18 @@ export async function updatePackageScript( packageJson.scripts[scriptName] = script; await updatePackageJSON(filePath, packageJson, fs); } + +/** + * Check if dev dependencies contains @ui5/cli version greater than 2. + * + * @param devDependencies dev dependencies from package.json + * @returns boolean + */ +export function hasUI5CliV3(devDependencies: any): boolean { + let isV3 = false; + const ui5CliSemver = semVer.coerce(devDependencies['@ui5/cli']); + if (ui5CliSemver && semVer.gte(ui5CliSemver, '3.0.0')) { + isV3 = true; + } + return isV3; +} diff --git a/packages/project-access/src/types/access/index.ts b/packages/project-access/src/types/access/index.ts index 57c7eab809..4ce8bddfa5 100644 --- a/packages/project-access/src/types/access/index.ts +++ b/packages/project-access/src/types/access/index.ts @@ -70,7 +70,7 @@ export interface ApplicationAccess extends BaseAccess { /** * Maintains new translation entries in CAP i18n files. * - * @param filePath file in which the translation entry will be used. + * @param filePath absolute path to file in which the translation entry will be used. * @param newI18nEntries translation entries to write in the i18n file. * @returns boolean or exception */ diff --git a/packages/project-access/test/project/script.test.ts b/packages/project-access/test/project/script.test.ts index 3e27a661a9..31cf578437 100644 --- a/packages/project-access/test/project/script.test.ts +++ b/packages/project-access/test/project/script.test.ts @@ -1,7 +1,7 @@ import { join } from 'path'; import { create as createStorage } from 'mem-fs'; import { create } from 'mem-fs-editor'; -import { FileName, updatePackageScript } from '../../src'; +import { FileName, updatePackageScript, hasUI5CliV3 } from '../../src'; describe('Test updatePackageScript()', () => { const sampleRoot = join(__dirname, '../test-data/json/package'); @@ -25,4 +25,10 @@ describe('Test updatePackageScript()', () => { } `); }); + + test('hasUI5CliV3', async () => { + expect(hasUI5CliV3({ '@ui5/cli': '3.0.0' })).toBe(true); + expect(hasUI5CliV3({})).toBe(false); + expect(hasUI5CliV3({ '@ui5/cli': '2.0.0' })).toBe(false); + }); }); diff --git a/packages/telemetry/CHANGELOG.md b/packages/telemetry/CHANGELOG.md index f934dc436e..3e0fcd22b7 100644 --- a/packages/telemetry/CHANGELOG.md +++ b/packages/telemetry/CHANGELOG.md @@ -1,5 +1,19 @@ # @sap-ux/telemetry +## 0.5.27 + +### Patch Changes + +- Updated dependencies [d962ce1] + - @sap-ux/project-access@1.27.1 + +## 0.5.26 + +### Patch Changes + +- Updated dependencies [df29368] + - @sap-ux/project-access@1.27.0 + ## 0.5.25 ### Patch Changes diff --git a/packages/telemetry/package.json b/packages/telemetry/package.json index 1667de9947..bac2d86415 100644 --- a/packages/telemetry/package.json +++ b/packages/telemetry/package.json @@ -1,6 +1,6 @@ { "name": "@sap-ux/telemetry", - "version": "0.5.25", + "version": "0.5.27", "description": "Library for sending usage telemetry data", "repository": { "type": "git", diff --git a/packages/ui-components/CHANGELOG.md b/packages/ui-components/CHANGELOG.md index e1a901264a..b8096b0d01 100644 --- a/packages/ui-components/CHANGELOG.md +++ b/packages/ui-components/CHANGELOG.md @@ -1,5 +1,23 @@ # @sap-ux/ui-components +## 1.17.9 + +### Patch Changes + +- ea0674c: UIContextualMenu.getUIcontextualMenuCalloutStyles - change parameter from `props?: IContextualMenuProps` to `styles?: IStyleFunctionOrObject` and make parameter as optional + +## 1.17.8 + +### Patch Changes + +- b124873: UIContextualMenu.getUIContextualMenuItemStyles - make `props` param optional + +## 1.17.7 + +### Patch Changes + +- 73f905f: Added optional `tabIndex` property for UISplitter component. + ## 1.17.6 ### Patch Changes diff --git a/packages/ui-components/package.json b/packages/ui-components/package.json index 95c5a6779d..8d4b21c634 100644 --- a/packages/ui-components/package.json +++ b/packages/ui-components/package.json @@ -1,6 +1,6 @@ { "name": "@sap-ux/ui-components", - "version": "1.17.6", + "version": "1.17.9", "license": "Apache-2.0", "description": "SAP UI Components Library", "repository": { diff --git a/packages/ui-components/src/components/UIContextualMenu/UIContextualMenu.tsx b/packages/ui-components/src/components/UIContextualMenu/UIContextualMenu.tsx index 146fbdef33..db4d59d8e1 100644 --- a/packages/ui-components/src/components/UIContextualMenu/UIContextualMenu.tsx +++ b/packages/ui-components/src/components/UIContextualMenu/UIContextualMenu.tsx @@ -68,11 +68,11 @@ export function getUIcontextualMenuStyles(): Partial { * @returns - consumable styles property for ContextualMenuItem */ export function getUIContextualMenuItemStyles( - props: UIIContextualMenuProps, + props?: UIIContextualMenuProps, currentItemHasSubmenu?: boolean, itemsHaveSubMenu?: boolean ): Partial { - const { iconToLeft } = props; + const { iconToLeft } = props ?? {}; const padding: { label?: number; root?: string; rootRight?: string } = {}; if (iconToLeft && itemsHaveSubMenu) { padding.label = currentItemHasSubmenu ? 10 : 19; @@ -119,18 +119,18 @@ export function getUIContextualMenuItemStyles( /** * ContextualMenu sub-component styles prop generator. * - * @param props Contextual menu properties. + * @param styles External styles of contextual menu. * @param maxWidth Maximal width of callout * @returns consumable styles property for Callout */ export function getUIcontextualMenuCalloutStyles( - props: IContextualMenuProps, + styles?: IStyleFunctionOrObject, maxWidth?: number ): Partial { return { root: { maxWidth: maxWidth, - ...extractRawStyles(props.styles, 'root') + ...(styles ? extractRawStyles(styles, 'root') : undefined) } }; } @@ -257,7 +257,7 @@ export const UIContextualMenu: React.FC = (props) => { className={getClassNames(props)} items={injectContextualMenuItemsStyle(props)} calloutProps={{ - styles: getUIcontextualMenuCalloutStyles(props, props.maxWidth), + styles: getUIcontextualMenuCalloutStyles(props.styles, props.maxWidth), ...props.calloutProps, className: getCalloutClassName(props) }} diff --git a/packages/ui-components/src/components/UISection/UISections.tsx b/packages/ui-components/src/components/UISection/UISections.tsx index 3eab89bdb4..a88c39dff2 100644 --- a/packages/ui-components/src/components/UISection/UISections.tsx +++ b/packages/ui-components/src/components/UISection/UISections.tsx @@ -18,6 +18,12 @@ export interface UISectionsProps { minSectionSize?: number | Array; animation?: boolean | boolean[]; splitterType?: UISplitterType; + /** + * Tabindex of splitter element. + * + * @default 0 + */ + splitterTabIndex?: -1 | 0; onClose?: () => void; splitterTitle?: string; splitterLayoutType?: UISplitterLayoutType; @@ -632,6 +638,7 @@ export class UISections extends React.Component 0; @@ -655,6 +662,7 @@ export class UISections extends React.Component