diff --git a/packages/cf-deploy-config-inquirer/.eslintignore b/packages/cf-deploy-config-inquirer/.eslintignore new file mode 100644 index 0000000000..9be6e1b137 --- /dev/null +++ b/packages/cf-deploy-config-inquirer/.eslintignore @@ -0,0 +1,3 @@ +/test/test-output/ +dist +templates \ No newline at end of file diff --git a/packages/cf-deploy-config-inquirer/.eslintrc.js b/packages/cf-deploy-config-inquirer/.eslintrc.js new file mode 100644 index 0000000000..b717f83ae9 --- /dev/null +++ b/packages/cf-deploy-config-inquirer/.eslintrc.js @@ -0,0 +1,7 @@ +module.exports = { + extends: ['../../.eslintrc'], + parserOptions: { + project: './tsconfig.eslint.json', + tsconfigRootDir: __dirname + } +}; diff --git a/packages/cf-deploy-config-inquirer/CHANGELOG.md b/packages/cf-deploy-config-inquirer/CHANGELOG.md new file mode 100644 index 0000000000..f6c196b03c --- /dev/null +++ b/packages/cf-deploy-config-inquirer/CHANGELOG.md @@ -0,0 +1,7 @@ +# @sap-ux/cf-deploy-config-inquirer + +## 0.0.1 + +### Patch Changes + +- 2fa3fda: add new module @sap-ux/cf-deploy-config-inquirer to get cf prompt options diff --git a/packages/cf-deploy-config-inquirer/LICENSE b/packages/cf-deploy-config-inquirer/LICENSE new file mode 100644 index 0000000000..261eeb9e9f --- /dev/null +++ b/packages/cf-deploy-config-inquirer/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/cf-deploy-config-inquirer/README.md b/packages/cf-deploy-config-inquirer/README.md new file mode 100644 index 0000000000..be895d744c --- /dev/null +++ b/packages/cf-deploy-config-inquirer/README.md @@ -0,0 +1,64 @@ +# @sap-ux/cf-deploy-config-inquirer + +Prompts module that can provide prompts for Cloud Foundry deployment config writer. + +## Installation +Npm +`npm install --save @sap-ux/cf-deploy-config-inquirer` + +Yarn +`yarn add @sap-ux/cf-deploy-config-inquirer` + +Pnpm +`pnpm add @sap-ux/cf-deploy-config-inquirer` + + +## Explainer + +Prompts may be retrieved using `getPrompts` and then executed in another prompting module that supports `inquirer` type prompts. + +`getPrompts` is provided to allow consumers to access cloud foundry prompts. There may be cases where these can be transformed to support other prompting frameworks. Most prompt configuration is possible via `CfDeployConfigPromptOptions` and calling `prompt`. This is the recommended approach. + +Configurability of prompts is entirely controlled using the `CfDeployConfigPromptOptions` parameter. + +See [Inquirer.js](https://www.npmjs.com/package/inquirer) for valid default properties. + +### Cloud Foundry Deploy Config Inquirer usage example: + +```TypeScript +import type { InquirerAdapter } from '@sap-ux/inquirer-common'; +import type { CfDeployConfigAnswers, CfDeployConfigPromptOptions } from '@sap-ux/cf-deploy-config-inquirer'; +import { prompt as cfDeployConfigPrompt, promptNames } from '@sap-ux/cf-deploy-config-inquirer'; + +const promptOptions = { + [promptNames.destinationName]: { + defaultValue: 'defaultDestination', + hint: false + }, + [promptNames.addManagedAppRouter]: true, + [promptNames.overwrite]: true +}; + +/** + * Pass an Inquirer prompt function https://www.npmjs.com/package/inquirer#methods + */ +const inqAdaptor = { + prompt: this.prompt.bind(this) // the inquirer prompting function, here we use the generators reference +}; + +const cfDeployConfigAnswers: CfDeployConfigAnswers = await cfDeployConfigPrompt( + inqAdaptor as InquirerAdapter, + promptOpts +); +``` + +## License + +Read [License](./LICENSE). + +## Keywords +SAP UI5 Application +Inquirer +Prompting +Generator +Deployment diff --git a/packages/cf-deploy-config-inquirer/jest.config.js b/packages/cf-deploy-config-inquirer/jest.config.js new file mode 100644 index 0000000000..2f0a4db758 --- /dev/null +++ b/packages/cf-deploy-config-inquirer/jest.config.js @@ -0,0 +1,6 @@ +const config = require('../../jest.base'); +config.snapshotFormat = { + escapeString: false, + printBasicPrototype: false +}; +module.exports = config; diff --git a/packages/cf-deploy-config-inquirer/package.json b/packages/cf-deploy-config-inquirer/package.json new file mode 100644 index 0000000000..81332958fb --- /dev/null +++ b/packages/cf-deploy-config-inquirer/package.json @@ -0,0 +1,48 @@ +{ + "name": "@sap-ux/cf-deploy-config-inquirer", + "description": "Prompts module that can provide prompts for cf deployment config writer", + "version": "0.0.1", + "repository": { + "type": "git", + "url": "https://github.com/SAP/open-ux-tools.git", + "directory": "packages/cf-deploy-config-inquirer" + }, + "bugs": { + "url": "https://github.com/SAP/open-ux-tools/issues?q=is%3Aopen+is%3Aissue+label%3Abug+label%3Acf-deploy-config-inquirer" + }, + "license": "Apache-2.0", + "main": "dist/index.js", + "scripts": { + "build": "tsc --build", + "clean": "rimraf --glob dist test/test-output coverage *.tsbuildinfo", + "watch": "tsc --watch", + "lint": "eslint . --ext .ts", + "lint:fix": "eslint . --ext .ts --fix", + "test": "jest --ci --forceExit --detectOpenHandles --colors --passWithNoTests", + "test-u": "jest --ci --forceExit --detectOpenHandles --colors -u", + "link": "pnpm link --global", + "unlink": "pnpm unlink --global" + }, + "files": [ + "LICENSE", + "dist", + "!dist/*.map", + "!dist/**/*.map" + ], + "dependencies": { + "@sap-ux/inquirer-common": "workspace:*", + "@sap-ux/btp-utils": "workspace:*", + "@sap-ux/logger": "workspace:*", + "i18next": "23.5.1", + "inquirer-autocomplete-prompt": "2.0.1" + }, + "devDependencies": { + "@sap-devx/yeoman-ui-types": "1.14.4", + "@types/inquirer-autocomplete-prompt": "2.0.1", + "@types/inquirer": "8.2.6", + "inquirer": "8.2.6" + }, + "engines": { + "node": ">=18.x" + } +} diff --git a/packages/cf-deploy-config-inquirer/src/i18n.ts b/packages/cf-deploy-config-inquirer/src/i18n.ts new file mode 100644 index 0000000000..2f3180dfd4 --- /dev/null +++ b/packages/cf-deploy-config-inquirer/src/i18n.ts @@ -0,0 +1,41 @@ +import type { TOptions } from 'i18next'; +import i18next from 'i18next'; +import translations from './translations/cf-deploy-config-inquirer.i18n.json'; + +const cfInquirerNamespace = 'cf-deploy-config-inquirer'; +export const defaultProjectNumber = 1; +/** + * Initialize i18next with the translations for this module. + */ +export async function initI18nCfDeployConfigInquirer(): Promise { + await i18next.init( + { + lng: 'en', + fallbackLng: 'en', + interpolation: { + defaultVariables: { + defaultProjectNumber + } + } + }, + () => i18next.addResourceBundle('en', cfInquirerNamespace, translations) + ); +} + +/** + * Helper function facading the call to i18next. Unless a namespace option is provided the local namespace will be used. + * + * @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 { + if (!options?.ns) { + options = Object.assign(options ?? {}, { ns: cfInquirerNamespace }); + } + return i18next.t(key, options); +} + +initI18nCfDeployConfigInquirer().catch(() => { + // Needed for lint +}); diff --git a/packages/cf-deploy-config-inquirer/src/index.ts b/packages/cf-deploy-config-inquirer/src/index.ts new file mode 100644 index 0000000000..bbb0326b95 --- /dev/null +++ b/packages/cf-deploy-config-inquirer/src/index.ts @@ -0,0 +1,57 @@ +import { getQuestions } from './prompts'; +import type { + CfDeployConfigPromptOptions, + CfDeployConfigQuestions, + CfSystemChoice, + CfDeployConfigAnswers +} from './types'; +import { promptNames } from './types'; +import { initI18nCfDeployConfigInquirer } from './i18n'; +import type { InquirerAdapter } from '@sap-ux/inquirer-common'; +import autocomplete from 'inquirer-autocomplete-prompt'; +import type { Logger } from '@sap-ux/logger'; +import LoggerHelper from './logger-helper'; + +/** + * Retrieves Cloud Foundry deployment configuration prompts. + * + * This function returns a list of cf deployment questions based on the provided application root and prompt options. + * + * @param {CfDeployConfigPromptOptions} promptOptions - The configuration options for prompting during cf target deployment. + * @param logger - The logger instance to use for logging. + * @returns {Promise} A promise that resolves to an array of questions for cf target prompting. + */ +async function getPrompts( + promptOptions: CfDeployConfigPromptOptions, + logger?: Logger +): Promise { + if (logger) { + LoggerHelper.logger = logger; + } + await initI18nCfDeployConfigInquirer(); + return getQuestions(promptOptions, LoggerHelper.logger); +} + +/** + * Prompt for cf inquirer inputs. + * + * @param adapter - optionally provide references to a calling inquirer instance, this supports integration to Yeoman generators, for example + * @param promptOptions - options that can control some of the prompt behavior. See {@link CfDeployConfigPromptOptions} for details + * @param logger - logger instance to use for logging + * @returns the prompt answers + */ +async function prompt( + adapter: InquirerAdapter, + promptOptions: CfDeployConfigPromptOptions, + logger?: Logger +): Promise { + const cfPrompts = await getPrompts(promptOptions, logger); + if (adapter?.promptModule && promptOptions[promptNames.destinationName]?.useAutocomplete) { + const pm = adapter.promptModule; + pm.registerPrompt('autocomplete', autocomplete); + } + const answers = await adapter.prompt(cfPrompts); + return answers; +} + +export { getPrompts, CfDeployConfigPromptOptions, CfSystemChoice, promptNames, prompt }; diff --git a/packages/cf-deploy-config-inquirer/src/logger-helper.ts b/packages/cf-deploy-config-inquirer/src/logger-helper.ts new file mode 100644 index 0000000000..7bb3d8882b --- /dev/null +++ b/packages/cf-deploy-config-inquirer/src/logger-helper.ts @@ -0,0 +1,26 @@ +import { ToolsLogger, type Logger } from '@sap-ux/logger'; + +/** + * Static logger prevents passing of logger references through all functions, as this is a cross-cutting concern. + */ +export default class LoggerHelper { + private static _logger: Logger = new ToolsLogger({ logPrefix: '@sap-ux/cf-deploy-config-inquirer' }); + + /** + * Get the logger. + * + * @returns the logger + */ + public static get logger(): Logger { + return LoggerHelper._logger; + } + + /** + * Set the logger. + * + * @param value the logger to set + */ + public static set logger(value: Logger) { + LoggerHelper._logger = value; + } +} diff --git a/packages/cf-deploy-config-inquirer/src/prompts/index.ts b/packages/cf-deploy-config-inquirer/src/prompts/index.ts new file mode 100644 index 0000000000..4ae4948f98 --- /dev/null +++ b/packages/cf-deploy-config-inquirer/src/prompts/index.ts @@ -0,0 +1 @@ +export * from './prompts'; diff --git a/packages/cf-deploy-config-inquirer/src/prompts/prompt-helpers.ts b/packages/cf-deploy-config-inquirer/src/prompts/prompt-helpers.ts new file mode 100644 index 0000000000..558ffd4cc0 --- /dev/null +++ b/packages/cf-deploy-config-inquirer/src/prompts/prompt-helpers.ts @@ -0,0 +1,55 @@ +import type { CfSystemChoice } from '../types'; +import { + isAppStudio, + listDestinations, + getDisplayName, + isAbapEnvironmentOnBtp, + type Destinations +} from '@sap-ux/btp-utils'; +import LoggerHelper from '../logger-helper'; +import { t } from '../i18n'; + +/** + * Generates a sorted list of Cloud Foundry system destination choices from provided destinations. + * + * @param {Destinations} [destinations] - Object containing destination details retrieved from BTP. + * @returns {CfSystemChoice[]} - Array of destination choices formatted for selection prompts. + */ +function createDestinationChoices(destinations: Destinations = {}): CfSystemChoice[] { + return Object.values(destinations) + .filter( + (destination): destination is Destinations[keyof Destinations] => + destination && typeof destination.Name === 'string' && typeof destination.Host === 'string' + ) + .sort((a, b) => a.Name.localeCompare(b.Name, undefined, { numeric: true, caseFirst: 'lower' })) + .map((destination) => ({ + name: `${getDisplayName(destination) ?? 'Unknown'} - ${destination.Host}`, + value: destination.Name, + scp: isAbapEnvironmentOnBtp(destination) || false, + url: destination.Host + })); +} + +/** + * Retrieves an array of Cloud Foundry system choices. + * + * @param {Destinations} [destinations] - Optional destinations object retrieved from BTP. + * @returns {Promise} - List of system choices formatted for selection prompts. + */ +export async function getCfSystemChoices(destinations?: Destinations): Promise { + return destinations ? createDestinationChoices(destinations) : []; +} + +/** + * Retrieves and caches the list of available BTP destinations if running in BAS. + * + * @returns {Promise} - A promise resolving to a list of destinations or undefined if not in BAS. + */ +export async function fetchBTPDestinations(): Promise { + if (isAppStudio()) { + const destinations = await listDestinations(); + return destinations; + } + LoggerHelper.logger.warn(t('warning.btpDestinationListWarning')); + return undefined; +} diff --git a/packages/cf-deploy-config-inquirer/src/prompts/prompts.ts b/packages/cf-deploy-config-inquirer/src/prompts/prompts.ts new file mode 100644 index 0000000000..0a3bda17d6 --- /dev/null +++ b/packages/cf-deploy-config-inquirer/src/prompts/prompts.ts @@ -0,0 +1,146 @@ +import { type ConfirmQuestion, type InputQuestion, searchChoices } from '@sap-ux/inquirer-common'; +import { t } from '../i18n'; +import type { + CfDeployConfigPromptOptions, + CfDeployConfigQuestions, + CfDeployConfigAnswers, + DestinationNamePromptOptions, + CfSystemChoice +} from '../types'; +import { promptNames } from '../types'; +import * as validators from './validators'; +import { isAppStudio } from '@sap-ux/btp-utils'; +import { getCfSystemChoices, fetchBTPDestinations } from './prompt-helpers'; +import type { Logger } from '@sap-ux/logger'; + +/** + * Retrieves the prompt configuration for selecting a Cloud Foundry destination name. + * + * This function generates a prompt that allows users to specify a destination name. The prompt can be rendered as a list or + * an input field depending on the provided options. If the environment supports + * autocomplete, it can provide suggestions based on existing destinations. + * + * @param {DestinationNamePromptOptions} destinationOptions - The options for configuring + * the destination name prompt. + * @param {string} destinationOptions.destination - The Cloud Foundry destination name + * to be used. + * @param {string} destinationOptions.defaultValue - The default destination value for CF. + * @param {boolean} [destinationOptions.addDestinationHintMessage] - A flag to indicate + * whether to show a hint for the destination name. + * @param {CfSystemChoice[]} [destinationOptions.additionalChoiceList] - Additional choices + * available for the destination. If additional choices are provided and the environment is VsCode, the prompt + * type will render as a list instead of an input field. + * @param {boolean} [destinationOptions.useAutocomplete] - A flag to indicate whether + * to use an autocomplete feature for the destination name input. + * @param {boolean} [destinationOptions.addBTPDestinationList] - A flag to indicate whether to include BTP destination choices. + * @returns {Promise} A promise that resolves to the configuration + * of the prompt, which includes the question and any related options for rendering + * the prompt in a user interface. + */ +async function getDestinationNamePrompt( + destinationOptions: DestinationNamePromptOptions +): Promise { + const { + hint = false, + additionalChoiceList = [], + defaultValue, + useAutocomplete = false, + addBTPDestinationList = true + } = destinationOptions; + + const isBAS = isAppStudio(); + const destinations = addBTPDestinationList ? await fetchBTPDestinations() : {}; + const destinationList: CfSystemChoice[] = [...additionalChoiceList, ...(await getCfSystemChoices(destinations))]; + // If BAS is used or additional choices are provided, the prompt should be a list + // If VsCode is used and additional choices are not provided, the prompt should be an input field + // If VsCode is used and additional choices are provided, the prompt should be a list + const basePromptType = isBAS || additionalChoiceList.length ? 'list' : 'input'; + // If autocomplete is enabled and there are destination choices, the prompt should be an autocomplete + const promptType = useAutocomplete && destinationList.length ? 'autocomplete' : basePromptType; + return { + guiOptions: { + mandatory: !isBAS, + breadcrumb: t('prompts.destinationNameMessage') + }, + type: promptType, + default: () => defaultValue, + name: promptNames.destinationName, + message: () => (hint ? t('prompts.directBindingDestinationHint') : t('prompts.destinationNameMessage')), + validate: (destination: string): string | boolean => { + return validators.validateDestinationQuestion(destination, !destination && isBAS); + }, + source: (prevAnswers: CfDeployConfigAnswers, input: string) => searchChoices(input, destinationList), + choices: () => destinationList + } as InputQuestion; +} + +/** + * Creates a prompt for managing application router during cf deployment. + * + * + * This function returns a confirmation question that asks whether to add a managed application router + * to the cf deployment. The prompt only appears if no mta file is found. + * + * @returns {ConfirmQuestion} Returns a confirmation question object for configuring the application router. + */ +function getAddManagedRouterPrompt(): CfDeployConfigQuestions { + return { + type: 'confirm', + name: promptNames.addManagedAppRouter, + guiOptions: { + breadcrumb: t('prompts.addApplicationRouterBreadcrumbMessage') + }, + message: () => t('prompts.generateManagedApplicationToRouterMessage'), + default: () => true + } as ConfirmQuestion; +} + +/** + * + * @returns A confirmation question object which overwrites destination. + */ +function getOverwritePrompt(): CfDeployConfigQuestions { + return { + type: 'confirm', + name: promptNames.overwrite, + guiOptions: { + hint: t('prompts.overwriteHintMessage') + }, + default: () => { + return true; + }, + message: () => t('prompts.overwriteMessage') + } as ConfirmQuestion; +} + +/** + * Retrieves a list of deployment questions based on the application root and prompt options. + * + * @param {CfDeployConfigPromptOptions} promptOptions - The configuration options for prompting during cf target deployment. + * @param {Logger} [log] - The logger instance to use for logging. + * @returns {CfDeployConfigQuestions[]} Returns an array of questions related to cf deployment configuration. + */ +export async function getQuestions( + promptOptions: CfDeployConfigPromptOptions, + log?: Logger +): Promise { + const destinationOptions = promptOptions[promptNames.destinationName] as DestinationNamePromptOptions; + const addOverwriteQuestion = promptOptions[promptNames.overwrite] ?? false; + const addManagedAppRouter = promptOptions[promptNames.addManagedAppRouter] ?? false; + + const questions: CfDeployConfigQuestions[] = []; + // Collect questions into an array + questions.push(await getDestinationNamePrompt(destinationOptions)); + + if (addManagedAppRouter) { + log?.info(t('info.addManagedAppRouter')); + questions.push(getAddManagedRouterPrompt()); + } + + if (addOverwriteQuestion) { + log?.info(t('info.overwriteDestination')); + questions.push(getOverwritePrompt()); + } + + return questions; +} diff --git a/packages/cf-deploy-config-inquirer/src/prompts/validators.ts b/packages/cf-deploy-config-inquirer/src/prompts/validators.ts new file mode 100644 index 0000000000..b755909bad --- /dev/null +++ b/packages/cf-deploy-config-inquirer/src/prompts/validators.ts @@ -0,0 +1,53 @@ +import { t } from '../i18n'; +import type { CfSystemChoice } from '../types'; + +/** + * + * @param input The input string to check for emptiness. + * @returns returns true if the input string is not empty, otherwise false. + */ +function isNotEmpty(input: string): boolean { + return !!input?.trim(); +} + +/** + * Validates the input string for the following: + * - It must not be empty after trimming whitespace. + * - It must contain only alphanumeric characters, underscores, or dashes. + * - It must not exceed 200 characters. + * + * @param {string} input - The input string to validate. + * @returns {boolean|string} `true` if the input is valid, otherwise an error message. + */ +function validateInput(input: string): boolean | string { + if (!isNotEmpty(input)) { + return t('errors.emptyDestinationNameError'); + } + const result = /^[a-z0-9_-]+$/i.test(input); + if (!result) { + return t('errors.destinationNameError'); + } + if (input.length > 200) { + return t('errors.destinationNameLengthError'); + } + return true; +} + +/** + * Validates the destination name or input string. If `allowEmptyChoice` is true, + * the validation will pass immediately. Otherwise, the input will be validated + * against rules (non-empty, valid characters, length check). + * + * @param {string} input - The destination name or input string to validate. + * @param {boolean} allowEmptyChoice - Whether to allow an empty input as a valid choice. + * @returns {boolean|string} `true` if the input is valid or empty choices are allowed, otherwise an error message. + */ +export function validateDestinationQuestion( + input: string | CfSystemChoice, + allowEmptyChoice: boolean = false +): boolean | string { + if (allowEmptyChoice) { + return true; + } + return typeof input === 'string' ? validateInput(input) : true; +} diff --git a/packages/cf-deploy-config-inquirer/src/translations/cf-deploy-config-inquirer.i18n.json b/packages/cf-deploy-config-inquirer/src/translations/cf-deploy-config-inquirer.i18n.json new file mode 100644 index 0000000000..b62b1753d1 --- /dev/null +++ b/packages/cf-deploy-config-inquirer/src/translations/cf-deploy-config-inquirer.i18n.json @@ -0,0 +1,22 @@ +{ + "prompts": { + "destinationNameMessage": "Destination name", + "addApplicationRouterBreadcrumbMessage": "Add to Router", + "generateManagedApplicationToRouterMessage": "Add application to managed application router?", + "directBindingDestinationHint": "Destination name - The app router is configured to use direct service binding", + "overwriteMessage": "Editing the deployment configuration will overwrite existing configuration, are you sure you want to continue?", + "overwriteHintMessage": "Deployment config will abort if you choose no. Click Finish to abort." + }, + "errors": { + "emptyDestinationNameError": "You must provide a destination name in order to continue.", + "destinationNameError": "The destination name must only contain letters, digits, dashes and underscores.", + "destinationNameLengthError": "Destination name cannot contain more than 200 characters" + }, + "warning": { + "btpDestinationListWarning": "BTP destinations are only retrieved on BAS" + }, + "info": { + "addManagedAppRouter": "Add managed application router is enabled", + "overwriteDestination": "Overwriting destination is enabled" + } +} diff --git a/packages/cf-deploy-config-inquirer/src/types.ts b/packages/cf-deploy-config-inquirer/src/types.ts new file mode 100644 index 0000000000..0c0d181bcf --- /dev/null +++ b/packages/cf-deploy-config-inquirer/src/types.ts @@ -0,0 +1,90 @@ +import type { YUIQuestion } from '@sap-ux/inquirer-common'; +import type { AutocompleteQuestionOptions } from 'inquirer-autocomplete-prompt'; + +/** + * Enum defining prompt names for Cloud Foundry (CF) deployment configuration. + */ +export enum promptNames { + /** The prompt to specify the destination name for CF deployment. */ + destinationName = 'destinationName', + /** The prompt to specify if a managed app router should be added to the deployment. */ + addManagedAppRouter = 'addManagedAppRouter', + /** The prompt for confirming destination overwrite. */ + overwrite = 'overwriteDestinationName' +} + +/** + * Configuration options for the 'destinationName' prompt used in deployment settings. + */ +export type DestinationNamePromptOptions = { + /** Default value to suggest for the destination name. */ + defaultValue: string; + /** Flag to indicate if a hint message should be shown to indicate the app router is configured.*/ + hint?: boolean; + /** + * List of additional destination choices available for the prompt. + * - In BAS environments, this list will be appended to BTP destination options. + * - If `additionalChoiceList` is provided and the environment is VS Code, + * the prompt will render as a list, allowing users to select from the provided choices instead of input. + */ + additionalChoiceList?: CfSystemChoice[]; + /** + * Indicates BTP destination list choices should be available for the prompt. + * If `addBTPDestinationList` is set to true, the prompt will include BTP destination choices else it will not. + * By default, this is set to true. + */ + addBTPDestinationList?: boolean; + /** + * Flag to indicate if the destination prompt should use auto completion + */ + useAutocomplete?: boolean; +}; + +/** + * Defines options for boolean-type prompts in CF deployment configuration. + */ +type booleanValuePromptOptions = Record & + Record; + +/** + * Defines options for string-type prompts in CF deployment configuration. + */ +type stringValuePromptOptions = Record; + +/** + * Configuration options for CF deployment prompts. + */ +export type CfDeployConfigPromptOptions = Partial; + +/** + * Represents a question in the CF deployment configuration. + * Extends `YUIQuestion` with optional autocomplete functionality. + */ +export type CfDeployConfigQuestions = YUIQuestion & + Partial>; + +/** + * User responses for CF deployment configuration. + */ +export interface CfDeployConfigAnswers { + /** The selected Cloud Foundry destination. */ + destinationName?: string; + /** Indicates whether the user opted to include a managed application router. */ + addManagedRouter?: boolean; + /* Indicates whether the user opted to overwrite the destination. */ + overwrite?: boolean; +} + +/** + * Interface for selectable system choices within prompts. + */ +export interface CfSystemChoice { + /** Display name of the system choice. */ + name: string; + /** Value associated with the system choice. */ + value: string; + /** Flag indicating if the system choice is an scp destination. */ + scp: boolean; + /** URL associated with the system choice. */ + url: string; +} diff --git a/packages/cf-deploy-config-inquirer/test/index.test.ts b/packages/cf-deploy-config-inquirer/test/index.test.ts new file mode 100644 index 0000000000..833841c2e3 --- /dev/null +++ b/packages/cf-deploy-config-inquirer/test/index.test.ts @@ -0,0 +1,49 @@ +import { getPrompts, promptNames, type CfDeployConfigPromptOptions, prompt } from '../src'; +import * as cfPrompts from '../src/prompts/prompts'; +import type { CfDeployConfigAnswers } from '../src/types'; +import type { Logger } from '@sap-ux/logger'; +import { createPromptModule } from 'inquirer'; +import type { InquirerAdapter } from '@sap-ux/inquirer-common'; +import AutocompletePrompt from 'inquirer-autocomplete-prompt'; + +describe('index', () => { + const mockLog = { + info: jest.fn(), + warn: jest.fn() + } as unknown as Logger; + const promptOptions: CfDeployConfigPromptOptions = { + [promptNames.destinationName]: { + defaultValue: 'defaultDestination', + hint: false, + useAutocomplete: true + }, + [promptNames.addManagedAppRouter]: true, + [promptNames.overwrite]: true + }; + + it('should return prompts from getPrompts', async () => { + const getQuestionsSpy = jest.spyOn(cfPrompts, 'getQuestions'); + const prompts = await getPrompts(promptOptions, mockLog); + expect(prompts.length).toBe(3); + expect(getQuestionsSpy).toHaveBeenCalledWith(promptOptions, mockLog); + }); + + it('should prompt with inquirer adapter', async () => { + const answers: CfDeployConfigAnswers = { + destinationName: 'testDestination', + addManagedRouter: true, + overwrite: true + }; + + const mockPromptsModule = createPromptModule(); + const adapterRegisterPromptSpy = jest.spyOn(mockPromptsModule, 'registerPrompt'); + const mockInquirerAdapter: InquirerAdapter = { + prompt: jest.fn().mockResolvedValue(answers), + promptModule: mockPromptsModule + }; + + expect(await prompt(mockInquirerAdapter, promptOptions)).toMatchObject(answers); + // Ensure autocomplete plugin is registered + expect(adapterRegisterPromptSpy).toHaveBeenCalledWith('autocomplete', AutocompletePrompt); + }); +}); diff --git a/packages/cf-deploy-config-inquirer/test/prompt-helpers.test.ts b/packages/cf-deploy-config-inquirer/test/prompt-helpers.test.ts new file mode 100644 index 0000000000..35d77bd3c5 --- /dev/null +++ b/packages/cf-deploy-config-inquirer/test/prompt-helpers.test.ts @@ -0,0 +1,115 @@ +import { + isAppStudio, + listDestinations, + getDisplayName, + isAbapEnvironmentOnBtp, + type Destinations +} from '@sap-ux/btp-utils'; +import { getCfSystemChoices, fetchBTPDestinations } from '../src/prompts/prompt-helpers'; +import type { CfSystemChoice } from '../src/types'; +import LoggerHelper from '../src/logger-helper'; +import { t } from '../src/i18n'; + +jest.mock('@sap-ux/btp-utils', () => ({ + isAppStudio: jest.fn(), + listDestinations: jest.fn(), + getDisplayName: jest.fn(), + isAbapEnvironmentOnBtp: jest.fn() +})); + +jest.mock('../src/logger-helper', () => ({ + logger: { + warn: jest.fn() + } +})); + +describe('Utility Functions', () => { + const mockDestinations: Destinations = { + dest1: { + Name: 'Dest1', + Type: 'HTTP', + Authentication: 'NoAuthentication', + ProxyType: 'Internet', + Description: 'Test Destination', + Host: 'host' + }, + dest2: { + Name: '', + Type: 'HTTP', + Authentication: 'NoAuthentication', + ProxyType: 'Internet', + Description: 'Test Destination ', + Host: 'host' + } + }; + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getCfSystemChoices', () => { + it('should return destination choices when destinations are provided', async () => { + const choices: CfSystemChoice[] = [ + { name: 'Dest1 - host', value: '', scp: false, url: 'host' }, + { name: 'Unknown - host', value: 'Dest1', scp: false, url: 'host' } + ]; + (getDisplayName as jest.Mock).mockReturnValueOnce('Dest1'); + (isAbapEnvironmentOnBtp as jest.Mock).mockReturnValueOnce(false); + + const result = await getCfSystemChoices(mockDestinations); + + expect(result).toEqual(choices); + }); + + it('should return an empty array if no destinations are provided', async () => { + const result = await getCfSystemChoices(); + expect(result).toEqual([]); + }); + + it('should return sorted and formatted destination choices', async () => { + const destinations: Destinations = { + ...mockDestinations, + dest2: { + Name: 'Dest2', + Host: 'host2', + Type: 'HTTP', + Authentication: 'NoAuthentication', + ProxyType: 'Internet', + Description: 'Test Destination 2' + } + }; + (getDisplayName as jest.Mock).mockImplementation((dest) => dest.Name); + (isAbapEnvironmentOnBtp as jest.Mock).mockReturnValue(false); + + const result = await getCfSystemChoices(destinations); + + expect(result).toEqual([ + { name: 'Dest1 - host', value: 'Dest1', scp: false, url: 'host' }, + { name: 'Dest2 - host2', value: 'Dest2', scp: false, url: 'host2' } + ]); + }); + }); + + describe('fetchBTPDestinations', () => { + it('should return destinations if running in App Studio', async () => { + (isAppStudio as jest.Mock).mockReturnValue(true); + (listDestinations as jest.Mock).mockResolvedValue(mockDestinations); + + const result = await fetchBTPDestinations(); + + expect(result).toEqual(mockDestinations); + expect(isAppStudio).toHaveBeenCalled(); + expect(listDestinations).toHaveBeenCalled(); + }); + + it('should return undefined if not running in App Studio', async () => { + (isAppStudio as jest.Mock).mockReturnValue(false); + + const result = await fetchBTPDestinations(); + + expect(result).toBeUndefined(); + expect(isAppStudio).toHaveBeenCalled(); + expect(listDestinations).not.toHaveBeenCalled(); + expect(LoggerHelper.logger.warn).toHaveBeenCalledWith(t('warning.btpDestinationListWarning')); + }); + }); +}); diff --git a/packages/cf-deploy-config-inquirer/test/prompts.test.ts b/packages/cf-deploy-config-inquirer/test/prompts.test.ts new file mode 100644 index 0000000000..0bc4c13da1 --- /dev/null +++ b/packages/cf-deploy-config-inquirer/test/prompts.test.ts @@ -0,0 +1,253 @@ +import { getQuestions } from '../src/prompts/prompts'; +import { isAppStudio } from '@sap-ux/btp-utils'; +import { t } from '../src/i18n'; +import type { + CfDeployConfigPromptOptions, + CfSystemChoice, + CfDeployConfigQuestions, + DestinationNamePromptOptions +} from '../src/types'; +import { promptNames } from '../src/types'; +import { fetchBTPDestinations } from '../src/prompts/prompt-helpers'; +import { type ListQuestion } from '@sap-ux/inquirer-common'; +import type { Logger } from '@sap-ux/logger'; + +jest.mock('@sap-ux/btp-utils', () => ({ + ...jest.requireActual('@sap-ux/btp-utils'), + isAppStudio: jest.fn() +})); +const mockIsAppStudio = isAppStudio as jest.Mock; + +jest.mock('../src/prompts/prompt-helpers', () => ({ + ...jest.requireActual('../src/prompts/prompt-helpers'), + fetchBTPDestinations: jest.fn() +})); +const mockFetchBTPDestinations = fetchBTPDestinations as jest.Mock; +const mockLog = { + info: jest.fn(), + warn: jest.fn() +} as unknown as Logger; + +describe('Prompt Generation Tests', () => { + let promptOptions: CfDeployConfigPromptOptions; + const destinationPrompts: DestinationNamePromptOptions = { + defaultValue: 'defaultDestination', + hint: false + }; + const additionalChoiceList: CfSystemChoice[] = [ + { + name: 'testChoice', + value: 'testValue', + scp: false, + url: 'testUrl' + }, + { + name: 'testChoice1', + value: 'testValue1', + scp: false, + url: 'testUrl' + } + ]; + + beforeEach(() => { + jest.clearAllMocks(); + promptOptions = { + [promptNames.destinationName]: destinationPrompts + }; + }); + + describe('getDestinationNamePrompt', () => { + it('returns list-based prompt when environment is BAS', async () => { + mockIsAppStudio.mockReturnValueOnce(true); + + const questions: CfDeployConfigQuestions[] = await getQuestions(promptOptions); + const destinationNamePrompt = questions.find((question) => question.name === promptNames.destinationName); + expect(destinationNamePrompt?.type).toBe('list'); + expect(destinationNamePrompt?.default()).toBe('defaultDestination'); + }); + + it('returns list-based prompt for cap project when environment is BAS', async () => { + mockIsAppStudio.mockReturnValueOnce(true); + mockFetchBTPDestinations.mockResolvedValueOnce({ + btpTestDest: { + Name: 'btpTestDest', + Host: 'btpTestDest', + Type: 'HTTP', + Authentication: 'BasicAuthentication', + ProxyType: 'OnPremise', + Description: 'btpTestDest' + } + }); + // cap destination is provided as an additional choice + promptOptions = { + [promptNames.destinationName]: { + ...destinationPrompts, + additionalChoiceList + } + }; + + const questions: CfDeployConfigQuestions[] = await getQuestions(promptOptions); + const destinationNamePrompt = questions.find((question) => question.name === promptNames.destinationName); + expect(destinationNamePrompt?.type).toBe('list'); + expect(destinationNamePrompt?.default()).toBe('defaultDestination'); + // ensure additional choice is added to the BTP destination list + expect(((destinationNamePrompt as ListQuestion)?.choices as Function)()).toStrictEqual([ + ...additionalChoiceList, + { name: 'btpTestDest - btpTestDest', value: 'btpTestDest', scp: false, url: 'btpTestDest' } + ]); + }); + + it('enables autocomplete when enabled and additionalChoiceList is provided', async () => { + mockIsAppStudio.mockReturnValueOnce(false); + promptOptions = { + [promptNames.destinationName]: { + ...destinationPrompts, + addBTPDestinationList: false, + useAutocomplete: true, + additionalChoiceList + } + }; + + const questions: CfDeployConfigQuestions[] = await getQuestions(promptOptions); + const destinationNamePrompt = questions.find((question) => question.name === promptNames.destinationName); + expect(destinationNamePrompt?.type).toBe('autocomplete'); + }); + + it('returns input-based prompt when environment is vscode', async () => { + mockIsAppStudio.mockReturnValueOnce(false); + + const questions: CfDeployConfigQuestions[] = await getQuestions(promptOptions); + const destinationNamePrompt = questions.find((question) => question.name === promptNames.destinationName); + expect(destinationNamePrompt?.type).toBe('input'); + expect((destinationNamePrompt?.message as Function)()).toBe(t('prompts.destinationNameMessage')); + expect(((destinationNamePrompt as ListQuestion)?.choices as Function)()).toStrictEqual([]); + }); + + it('returns list-based prompt when environment is vscode and additionalChoiceList is provided', async () => { + mockIsAppStudio.mockReturnValueOnce(false); + promptOptions = { + [promptNames.destinationName]: { + ...destinationPrompts, + additionalChoiceList + } + }; + const questions: CfDeployConfigQuestions[] = await getQuestions(promptOptions); + const destinationNamePrompt = questions.find((question) => question.name === promptNames.destinationName); + expect(destinationNamePrompt?.type).toBe('list'); + expect((destinationNamePrompt?.message as Function)()).toBe(t('prompts.destinationNameMessage')); + expect(((destinationNamePrompt as ListQuestion)?.choices as Function)()).toStrictEqual( + additionalChoiceList + ); + }); + + it('validates destination correctly and shows hint when directBindingDestinationHint is enabled', async () => { + promptOptions = { + [promptNames.destinationName]: { + ...destinationPrompts, + hint: true + } + }; + const questions: CfDeployConfigQuestions[] = await getQuestions(promptOptions); + const destinationNamePrompt = questions.find((question) => question.name === promptNames.destinationName); + expect((destinationNamePrompt?.validate as Function)()).toBe(true); + expect((destinationNamePrompt?.message as Function)()).toBe(t('prompts.directBindingDestinationHint')); + }); + + it('Shows default hint when directBindingDestinationHint is not provided', async () => { + promptOptions = { + [promptNames.destinationName]: { + ...destinationPrompts, + hint: undefined + } + }; + const questions: CfDeployConfigQuestions[] = await getQuestions(promptOptions); + const destinationNamePrompt = questions.find((question) => question.name === promptNames.destinationName); + expect((destinationNamePrompt?.message as Function)()).toBe(t('prompts.destinationNameMessage')); + }); + + test('Destination name when autocomplete is specified', async () => { + // Option `useAutocomplete` specified + promptOptions = { + [promptNames.destinationName]: { + ...destinationPrompts, + useAutocomplete: true, + additionalChoiceList, + defaultValue: 'testChoice' + } + }; + const questions: CfDeployConfigQuestions[] = await getQuestions(promptOptions); + const destinationNamePrompt = questions.find( + (question: CfDeployConfigQuestions) => question.name === promptNames.destinationName + ); + expect(destinationNamePrompt?.type).toEqual('autocomplete'); + expect(((destinationNamePrompt as ListQuestion)?.choices as Function)()).toEqual(additionalChoiceList); + expect((destinationNamePrompt?.source as Function)()).toEqual(additionalChoiceList); + // Default should be used + expect((destinationNamePrompt?.default as Function)()).toEqual(additionalChoiceList[0].name); + }); + }); + + describe('getAddManagedRouterPrompt', () => { + beforeEach(() => { + promptOptions = { + ...promptOptions, + [promptNames.addManagedAppRouter]: true + }; + }); + + it('Displays managed router prompt when enabled', async () => { + const questions: CfDeployConfigQuestions[] = await getQuestions(promptOptions, mockLog); + const managedAppRouterPrompt = questions.find( + (question) => question.name === promptNames.addManagedAppRouter + ); + expect(managedAppRouterPrompt?.type).toBe('confirm'); + expect(managedAppRouterPrompt?.guiOptions?.breadcrumb).toBe( + t('prompts.addApplicationRouterBreadcrumbMessage') + ); + expect((managedAppRouterPrompt?.message as Function)()).toBe( + t('prompts.generateManagedApplicationToRouterMessage') + ); + expect((managedAppRouterPrompt?.default as Function)()).toBe(true); + expect(mockLog.info).toHaveBeenCalledWith(t('info.addManagedAppRouter')); + }); + + it('Displays managed router prompt when disabled', async () => { + promptOptions[promptNames.addManagedAppRouter] = false; + + const questions: CfDeployConfigQuestions[] = await getQuestions(promptOptions, mockLog); + const managedAppRouterPrompt = questions.find( + (question) => question.name === promptNames.addManagedAppRouter + ); + expect(managedAppRouterPrompt).toBeUndefined(); + expect(mockLog.info).not.toHaveBeenCalled(); + }); + }); + + describe('getOverwritePrompt', () => { + beforeEach(() => { + promptOptions = { + ...promptOptions, + [promptNames.overwrite]: true + }; + }); + + it('Displays get overwrite prompt when enabled', async () => { + const questions: CfDeployConfigQuestions[] = await getQuestions(promptOptions, mockLog); + const overwritePrompt = questions.find((question) => question.name === promptNames.overwrite); + expect(overwritePrompt?.type).toBe('confirm'); + expect((overwritePrompt?.default as Function)()).toBe(true); + expect((overwritePrompt?.message as Function)()).toBe(t('prompts.overwriteMessage')); + expect(mockLog.info).toHaveBeenCalledWith(t('info.overwriteDestination')); + }); + + it('Displays get overwrite prompt when disabled', async () => { + if (promptOptions[promptNames.overwrite]) { + promptOptions[promptNames.overwrite] = false; + } + const questions: CfDeployConfigQuestions[] = await getQuestions(promptOptions, mockLog); + const overwritePrompt = questions.find((question) => question.name === promptNames.overwrite); + expect(overwritePrompt?.type).toBeUndefined(); + expect(mockLog.info).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/cf-deploy-config-inquirer/test/validators.test.ts b/packages/cf-deploy-config-inquirer/test/validators.test.ts new file mode 100644 index 0000000000..fc9ccccc7c --- /dev/null +++ b/packages/cf-deploy-config-inquirer/test/validators.test.ts @@ -0,0 +1,36 @@ +import { t } from '../src/i18n'; +import { validateDestinationQuestion } from '../src/prompts/validators'; + +describe('validateDestinationQuestion', () => { + beforeEach(async () => { + jest.clearAllMocks(); + }); + + const cfServiceInput: [any, any][] = [ + [' ', t('errors.emptyDestinationNameError')], + ['', t('errors.emptyDestinationNameError')], + ['ABC ', t('errors.destinationNameError')], + ['ABC&', t('errors.destinationNameError')], + ['ABC$', t('errors.destinationNameError')], + ['ABC abc', t('errors.destinationNameError')], + ['a'.repeat(201), t('errors.destinationNameLengthError')], + ['ABCabc', true], + ['123ABCabc', true], + ['123ABCabc123', true], + ['_ABCabc123', true], + ['-ABCabc123', true], + ['ABC', true], + ['ABC-abc', true], + ['ABC_abc', true], + [{}, true] + ]; + + test.each(cfServiceInput)('Validate destination field %p', (input, toEqual) => { + const output = validateDestinationQuestion(input, false); + expect(output).toEqual(toEqual); + }); + + it('returns true if allowEmptyChoice is true', () => { + expect(validateDestinationQuestion('', true)).toBe(true); + }); +}); diff --git a/packages/cf-deploy-config-inquirer/tsconfig.eslint.json b/packages/cf-deploy-config-inquirer/tsconfig.eslint.json new file mode 100644 index 0000000000..d5f1aa3474 --- /dev/null +++ b/packages/cf-deploy-config-inquirer/tsconfig.eslint.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "include": ["src", "test", ".eslintrc.js"] +} diff --git a/packages/cf-deploy-config-inquirer/tsconfig.json b/packages/cf-deploy-config-inquirer/tsconfig.json new file mode 100644 index 0000000000..24c19c23fa --- /dev/null +++ b/packages/cf-deploy-config-inquirer/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../tsconfig.json", + "include": [ + "src", + "src/**/*.json" + ], + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "references": [ + { + "path": "../btp-utils" + }, + { + "path": "../inquirer-common" + }, + { + "path": "../logger" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 21f22bbee7..1d95f24601 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -886,6 +886,37 @@ importers: specifier: 5.6.2 version: 5.6.2 + packages/cf-deploy-config-inquirer: + dependencies: + '@sap-ux/btp-utils': + specifier: workspace:* + version: link:../btp-utils + '@sap-ux/inquirer-common': + specifier: workspace:* + version: link:../inquirer-common + '@sap-ux/logger': + specifier: workspace:* + version: link:../logger + i18next: + specifier: 23.5.1 + version: 23.5.1 + inquirer-autocomplete-prompt: + specifier: 2.0.1 + version: 2.0.1(inquirer@8.2.6) + devDependencies: + '@sap-devx/yeoman-ui-types': + specifier: 1.14.4 + version: 1.14.4 + '@types/inquirer': + specifier: 8.2.6 + version: 8.2.6 + '@types/inquirer-autocomplete-prompt': + specifier: 2.0.1 + version: 2.0.1 + inquirer: + specifier: 8.2.6 + version: 8.2.6 + packages/control-property-editor: devDependencies: '@esbuild-plugins/node-modules-polyfill': @@ -6573,7 +6604,7 @@ packages: dependencies: '@formatjs/ts-transformer': 2.13.0(ts-jest@29.1.2) '@types/json-stable-stringify': 1.0.36 - '@types/lodash': 4.17.7 + '@types/lodash': 4.14.202 '@types/loud-rejection': 2.0.0 '@types/node': 14.18.63 '@vue/compiler-core': 3.4.37 @@ -9354,10 +9385,6 @@ packages: resolution: {integrity: sha512-OvlIYQK9tNneDlS0VN54LLd5uiPCBOp7gS5Z0f1mjoJYBrtStzgmJBxONW3U6OZqdtNzZPmn9BS/7WI7BFFcFQ==} dev: true - /@types/lodash@4.17.7: - resolution: {integrity: sha512-8wTvZawATi/lsmNu10/j2hk1KEP0IvjubqPE3cu1Xz7xfXXt5oCq3SNUz4fMIP4XGF9Ky+Ue2tBA3hcS7LSBlA==} - dev: true - /@types/loud-rejection@2.0.0: resolution: {integrity: sha512-oTHISsIybJGoh3b3Ay/10csbAd2k0su7G7DGrE1QWciC+IdydPm0WMw1+Gr9YMYjPiJ5poB3g5Ev73IlLoavLw==} dependencies: @@ -21018,6 +21045,7 @@ packages: /semver@7.6.3: resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==} engines: {node: '>=10'} + hasBin: true dev: true /send@0.19.0: diff --git a/sonar-project.properties b/sonar-project.properties index 32c26ddb65..b251f52ceb 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -55,6 +55,7 @@ sonar.javascript.lcov.reportPaths=packages/abap-deploy-config-inquirer/coverage/ packages/odata-annotation-core-types/coverage/lcov.info, \ packages/odata-entity-model/coverage/lcov.info, \ packages/cds-odata-annotation-converter/coverage/lcov.info, \ + packages/cf-deploy-config-inquirer/coverage/lcov.info, \ packages/ui5-application-writer/coverage/lcov.info, \ packages/ui5-config/coverage/lcov.info, \ packages/ui5-proxy-middleware/coverage/lcov.info, \ @@ -122,6 +123,7 @@ sonar.testExecutionReportPaths=packages/abap-deploy-config-inquirer/coverage/son packages/odata-annotation-core-types/coverage/sonar-report.xml, \ packages/odata-entity-model/coverage/sonar-report.xml, \ packages/cds-odata-annotation-converter/coverage/sonar-report.xml, \ + packages/cf-deploy-config-inquirer/coverage/sonar-report.xml, \ packages/ui5-application-writer/coverage/sonar-report.xml, \ packages/ui5-config/coverage/sonar-report.xml, \ packages/ui5-proxy-middleware/coverage/sonar-report.xml, \ diff --git a/tsconfig.json b/tsconfig.json index 1f99248f49..d6be5dde09 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -62,6 +62,9 @@ { "path": "packages/cds-odata-annotation-converter" }, + { + "path": "packages/cf-deploy-config-inquirer" + }, { "path": "packages/control-property-editor-common" },