diff --git a/src/__tests__/lib/user-query.test.ts b/src/__tests__/lib/user-query.test.ts index b58e4652..ba094d8c 100644 --- a/src/__tests__/lib/user-query.test.ts +++ b/src/__tests__/lib/user-query.test.ts @@ -20,6 +20,7 @@ const { booleanInput, displayNoneForEmpty, integerInput, + numberInput, optionalIntegerInput, optionalNumberInput, optionalStringInput, @@ -129,6 +130,22 @@ describe('optionalStringInput', () => { expect(generatedValidate('bad input')).toBe('please enter better input') expect(validateMock).toHaveBeenCalledExactlyOnceWith('bad input') }) + + it('allows empty even with custom validate function', async () => { + const validateMock = jest.fn>().mockReturnValue(true) + inputMock.mockResolvedValueOnce('') + + expect(await optionalStringInput('prompt message', { validate: validateMock })).toBe(undefined) + + expect(inputMock).toHaveBeenCalledWith(expect.objectContaining({ + message: 'prompt message', + validate: expect.any(Function), + })) + + const generatedValidate = (inputMock.mock.calls[0][0] as { validate: ValidateFunction }).validate + expect(generatedValidate('')).toBeTrue() + expect(validateMock).toHaveBeenCalledTimes(0) + }) }) describe('stringInput', () => { @@ -428,6 +445,35 @@ describe('optionalNumberInput', () => { }) }) +describe('numberInput', () => { + it('requires input', async () => { + inputMock.mockResolvedValueOnce('43.9999') + + expect(await numberInput('prompt message')).toBe(43.9999) + + expect(inputMock).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ required: true })) + }) + + it('incorporates supplied validation', async () => { + inputMock.mockResolvedValueOnce('13.31') + + const validateMock = jest.fn>() + + expect(await numberInput('prompt message', { validate: validateMock })).toBe(13.31) + + expect(inputMock).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ + validate: expect.any(Function), + })) + + const generatedValidate = (inputMock.mock.calls[0][0] as { validate: ValidateFunction }).validate + + validateMock.mockReturnValue(true) + + expect(generatedValidate('77')).toBe(true) + expect(validateMock).toHaveBeenCalledExactlyOnceWith(77) + }) +}) + describe('booleanInput', () => { it('defaults to true', async () => { confirmMock.mockResolvedValueOnce(false) diff --git a/src/commands/capabilities/create.ts b/src/commands/capabilities/create.ts index 12776f9d..5c470454 100644 --- a/src/commands/capabilities/create.ts +++ b/src/commands/capabilities/create.ts @@ -1,4 +1,4 @@ -import inquirer from 'inquirer' +import { select } from '@inquirer/prompts' import log4js from 'log4js' import { type ArgumentsCamelCase, type Argv, type CommandModule } from 'yargs' @@ -13,9 +13,14 @@ import { type HttpClientParams, } from '@smartthings/core-sdk' +import { booleanInput, integerInput, numberInput, optionalIntegerInput, optionalNumberInput, optionalStringInput, stringInput } from '../../lib/user-query.js' import { fatalError } from '../../lib/util.js' import { apiDocsURL } from '../../lib/command/api-command.js' -import { apiOrganizationCommand, apiOrganizationCommandBuilder, type APIOrganizationCommandFlags } from '../../lib/command/api-organization-command.js' +import { + apiOrganizationCommand, + apiOrganizationCommandBuilder, + type APIOrganizationCommandFlags, +} from '../../lib/command/api-organization-command.js' import { inputAndOutputItem, inputAndOutputItemBuilder, @@ -23,6 +28,7 @@ import { } from '../../lib/command/input-and-output-item.js' import { userInputProcessor } from '../../lib/command/input-processor.js' import { buildTableOutput } from '../../lib/command/util/capabilities-table.js' +import { numberValidateFn, stringValidateFn } from '../../lib/validate-util.js' export type CommandArgs = @@ -52,22 +58,12 @@ const builder = (yargs: Argv): Argv => const logger = log4js.getLogger('cli') -const enum Type { - INTEGER = 'integer', - NUMBER = 'number', - STRING = 'string', - BOOLEAN = 'boolean', -} - -const attributeAndCommandNamePattern = /^[a-z][a-zA-Z]{0,35}$/ -function commandOrAttributeNameValidator(input: string): boolean | string { - return !!attributeAndCommandNamePattern.exec(input) - || 'Invalid attribute name; only letters are allowed and must start with a lowercase letter, max length 36' -} +type Type = 'integer' | 'number' | 'string' | 'boolean' -function unitOfMeasureValidator(input: string): boolean | string { - return input.length < 25 ? true : 'The unit should be less than 25 characters' -} +const commandOrAttributeNameValidator = stringValidateFn({ + regex: /^[a-z][a-zA-Z]{0,35}$/, + errorMessage: 'Only letters are allowed, must start with a lowercase letter, max length 36', +}) const addCommand = (capability: CapabilityCreate, name: string, command: CapabilityCommand): void => { if (capability.commands === undefined) { @@ -77,9 +73,7 @@ const addCommand = (capability: CapabilityCreate, name: string, command: Capabil } const buildSchemaMatchingAttributeType = (attribute: CapabilityAttribute, type: Type): CapabilityJSONSchema => { - const retVal: CapabilityJSONSchema = { - type, - } + const retVal: CapabilityJSONSchema = { type } if (attribute.schema.properties.value.minimum !== undefined) { retVal.minimum = attribute.schema.properties.value.minimum } @@ -97,11 +91,7 @@ const promptAndAddSetter = async ( attributeName: string, attribute: CapabilityAttribute, type: Type, ): Promise => { - const addSetter = (await inquirer.prompt({ - type: 'confirm', - name: 'addSetter', - message: 'Add a setter command for this attribute?', - })).addSetter + const addSetter = await booleanInput('Add a setter command for this attribute?') logger.debug(`promptAndAddSetter - addSetter = ${addSetter}`) if (addSetter) { @@ -126,8 +116,7 @@ const addBasicCommand = ( attribute: CapabilityAttribute, commandName: string, type: Type, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - value: any, + value: unknown, ): void => { if (!attribute.enumCommands) { attribute.enumCommands = [] @@ -150,69 +139,28 @@ const promptAndAddBasicCommands = async ( attribute: CapabilityAttribute, type: Type, ): Promise => { - let basicCommandName = '' - const baseMessage = 'If you want to add a basic command, enter a ' + - 'command name now (or hit enter for none):' - let message = `${baseMessage}\n(Basic commands are simple commands ` + - 'that set the attribute to a specific value.)' + let basicCommandName: string | undefined = undefined + const baseMessage = 'If you want to add a basic command, enter a command name now (or hit enter for none):' + let message = `${baseMessage}\n(Basic commands are simple commands that set the attribute to a specific value.)` do { - basicCommandName = (await inquirer.prompt({ - type: 'input', - name: 'basicCommandName', - message, - validate: (input) => { - // empty string is allowed here because it ends basic command name input - return !input || commandOrAttributeNameValidator(input) - }, - })).basicCommandName + basicCommandName = await optionalStringInput(message, { validate: commandOrAttributeNameValidator }) message = baseMessage - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let basicCommandValue: any = undefined + let basicCommandValue: unknown = undefined const minimum = attribute.schema.properties.value.minimum const maximum = attribute.schema.properties.value.maximum const maxLength = attribute.schema.properties.value.maxLength if (basicCommandName) { - // TODO: This switch (/if/else/else) should be handled in a - // more generic/object oriented way - if (type === Type.INTEGER || type === Type.NUMBER) { - basicCommandValue = parseInt((await inquirer.prompt({ - type: 'input', - name: 'basicCommandValue', - message: 'Command Value: ', - validate: (input) => { - if (isNaN(input)) { - return 'Please enter a numeric value' - } - if (typeof minimum === 'number' && parseInt(input) < minimum) { - return 'Number must be greater than or equal to minimum.' - } - if (typeof maximum === 'number' && parseInt(input) > maximum) { - return 'Number must be less than or equal to maximum value.' - } - return true - }, - })).basicCommandValue) - } else if (type === Type.STRING) { - basicCommandValue = (await inquirer.prompt({ - type: 'input', - name: 'basicCommandValue', - message: 'Command Value: ', - validate: (input) => { - if (typeof maxLength === 'number' && input.length > maxLength) { - return 'String cannot be greater than maximum length.' - } - return true - }, - })).basicCommandValue - } else if (type === Type.BOOLEAN) { - basicCommandValue = (await inquirer.prompt({ - type: 'list', - name: 'basicCommandValue', - message: 'Command Value: ', - // choices must be strings as per inquirer documentation - choices: ['True', 'False'], - })).basicCommandValue + if (type === 'integer' || type === 'number') { + basicCommandValue = await (type === 'integer' ? integerInput : numberInput)( + 'Command Value:', + { validate: numberValidateFn({ min: minimum, max: maximum }) }, + ) + } else if (type === 'string') { + const validate = typeof maxLength === 'number' ? stringValidateFn({ maxLength }) : undefined + basicCommandValue = await stringInput('Command Value:', { validate }) + } else if (type === 'boolean') { + basicCommandValue = await booleanInput('Command Value:') } else { logger.error('invalid state in promptAndAddBasicCommands') } @@ -223,46 +171,25 @@ const promptAndAddBasicCommands = async ( } const promptForType = async (message: string): Promise => { - return (await inquirer.prompt({ - type: 'list', - name: 'type', - message: `Select an ${message} type:`, - choices: [Type.INTEGER, Type.NUMBER, Type.STRING, Type.BOOLEAN], - })).type -} - -const promptForAttributeName = async (): Promise => { - return (await inquirer.prompt({ - type: 'input', - name: 'attributeName', - message: 'Attribute Name: ', - validate: commandOrAttributeNameValidator, - })).attributeName -} - -const promptForUnitOfMeasure = async (): Promise => { - return (await inquirer.prompt({ - type: 'input', - name: 'unitOfMeasure', - message: 'Unit of measure (default: none): ', - validate: unitOfMeasureValidator, - })).unitOfMeasure + const choices: Type[] = ['integer', 'number', 'string', 'boolean'] + return await select({ message: `Select an ${message} type:`, choices }) } const promptAndAddAttribute = async (capability: CapabilityCreate): Promise => { + const promptForAttributeName = async (): Promise => + await stringInput('Attribute Name:', { validate: commandOrAttributeNameValidator }) + let name = await promptForAttributeName() let userAcknowledgesNoSetter = false while (name.length > 33 && !userAcknowledgesNoSetter) { - const answer = (await inquirer.prompt({ - type: 'list', - name: 'answer', + const answer = await select({ message: `Attribute Name ${name} is too long to make a setter.`, choices: [ { name: 'Enter a shorter name (max 33 characters)', value: 'shorter ' }, { name: 'I won\'t need a setter', value: 'noSetter' }, ], - })).answer + }) if (answer === 'noSetter') { userAcknowledgesNoSetter = true } else { @@ -285,39 +212,25 @@ const promptAndAddAttribute = async (capability: CapabilityCreate): Promise { - return input.length === 0 || !isNaN(input) || 'Please enter a numeric value' - }, - })).minValue - if (minValue) { - attribute.schema.properties.value.minimum = parseInt(minValue) + if (type === 'integer' || type === 'number') { + const minValue = await (type === 'integer' ? optionalIntegerInput : optionalNumberInput)( + 'Minimum value (default: no minimum):', + ) + if (minValue != null) { + attribute.schema.properties.value.minimum = minValue } - const maxValue: string | undefined = (await inquirer.prompt({ - type: 'input', - name: 'maxValue', - message: 'Maximum value (default: no maximum): ', - validate: (input) => { - if (input.length === 0) { - return true - } - if (isNaN(input)) { - return 'Please enter a numeric value' - } - return minValue === undefined - || parseInt(input) > parseInt(minValue) - || 'Maximum value must be greater than minimum value.' - }, - })).maxValue - if (maxValue) { - attribute.schema.properties.value.maximum = parseInt(maxValue) + const maxValue = await (type === 'integer' ? optionalIntegerInput : optionalNumberInput)( + 'Minimum value (default: no maximum):', + { validate: numberValidateFn( { min: minValue } ) }, + ) + if (maxValue != null) { + attribute.schema.properties.value.maximum = maxValue } - const unit = await promptForUnitOfMeasure() + const unit = await optionalStringInput( + 'Unit of measure (default: none):', + { validate: stringValidateFn({ maxLength: 25 }) }, + ) if (unit) { // Note: we don't support multiple units here because we want to move toward using a single unit // of measure in capabilities @@ -327,18 +240,14 @@ const promptAndAddAttribute = async (capability: CapabilityCreate): Promise { - return input.length === 0 || !isNaN(input) || 'Please enter a numeric value' - }, - })).maxLength - if (maxLength) { - attribute.schema.properties.value.maxLength = parseInt(maxLength) + const maxLength = await optionalIntegerInput( + 'Maximum length (default: no max length):', + { validate: numberValidateFn({ min: 1 }) }, + ) + if (maxLength != null) { + attribute.schema.properties.value.maxLength = maxLength } } @@ -354,34 +263,22 @@ const promptAndAddAttribute = async (capability: CapabilityCreate): Promise => { - const name: string = (await inquirer.prompt({ - type: 'input', - name: 'commandName', - message: 'Command Name: ', - validate: commandOrAttributeNameValidator, - })).commandName + const name: string = await stringInput('Command Name:', { validate: commandOrAttributeNameValidator }) const command: CapabilityCommand = { name, arguments: [], } - let argumentName = '' + let argumentName: string | undefined = undefined do { - argumentName = (await inquirer.prompt({ - type: 'input', - name: 'argumentName', - message: 'If you want to add argument, enter a name for it now (enter to finish): ', - })).argumentName + argumentName = + await optionalStringInput('If you want to add an argument, enter a name for it now (enter to finish): ') if (argumentName) { const type = await promptForType('argument') - const optional = (await inquirer.prompt({ - type: 'confirm', - name: 'optionalArgument', - message: 'Is this argument optional?', - })).optionalArgument + const optional = await booleanInput('Is this argument optional?') const argument: CapabilityArgument = { name: argumentName, @@ -397,46 +294,37 @@ const promptAndAddCommand = async (capability: CapabilityCreate): Promise addCommand(capability, name, command) } -// TODO: throughout this Q&A session there seldom options to cancel input -// choices without starting completely over. We need to look at fixing this. -// TODO: also, this process needs more up-front validation -const getInputFromUser = async (): Promise => { - const name = (await inquirer.prompt({ - type: 'input', - name: 'capabilityName', - message: 'Capability Name:', - validate: (input: string) => { - return new RegExp('^[a-zA-Z0-9][a-zA-Z0-9 ]{1,35}$').test(input) || 'Invalid capability name' - }, - })).capabilityName +const getInputFromUser = async (options: { dryRun: boolean }): Promise => { + const name = await stringInput( + 'Capability Name:', + { validate: stringValidateFn({ regex: /^[a-zA-Z0-9][a-zA-Z0-9 ]{1,35}$/ }) }, + ) const capability: CapabilityCreate = { name, } - const enum Action { - ADD_ATTRIBUTE = 'Add an attribute', - ADD_COMMAND = 'Add a command', - FINISH = 'Finish & Create', - } + const startingChoices = [ + { name: 'Add an attribute', value: 'add-attribute' }, + { name: 'Add a command', value: 'add-command' }, + ] + const allChoices = [ + ...startingChoices, + { name: `Finish & ${options.dryRun ? 'Output' : 'Create'}`, value: 'finish' }, + ] let action: string - let choices = [Action.ADD_ATTRIBUTE, Action.ADD_COMMAND] + let choices = startingChoices do { - action = (await inquirer.prompt({ - type: 'list', - name: 'action', - message: 'Select an action...', - choices, - })).action - - if (action === Action.ADD_ATTRIBUTE) { + action = await select({ message: 'Select an action...', choices }) + + if (action === 'add-attribute') { await promptAndAddAttribute(capability) - } else if (action === Action.ADD_COMMAND) { + } else if (action === 'add-command') { await promptAndAddCommand(capability) } - choices = [Action.ADD_ATTRIBUTE, Action.ADD_COMMAND, Action.FINISH] - } while (action !== Action.FINISH) + choices = allChoices + } while (action !== 'finish') return capability } @@ -463,7 +351,7 @@ const handler = async (argv: ArgumentsCamelCase): Promise => await inputAndOutputItem(command, { buildTableOutput: data => buildTableOutput(command.tableGenerator, data) }, - createCapability, userInputProcessor(getInputFromUser)) + createCapability, userInputProcessor(() => getInputFromUser({ dryRun: !!argv.dryRun }))) } const cmd: CommandModule = { command, describe, builder, handler } diff --git a/src/lib/user-query.ts b/src/lib/user-query.ts index 6c913459..ba2a91f6 100644 --- a/src/lib/user-query.ts +++ b/src/lib/user-query.ts @@ -51,7 +51,13 @@ export const optionalStringInput = async ( message: string, options?: OptionalStringInputOptions, ): Promise => { - const entered = await internalStringInput(message, { ...options, required: false }) + const internalOptions = { ...options, required: false } + // ensure empty input is allowed regardless of the validator + const originalValidate = internalOptions.validate + if (originalValidate) { + internalOptions.validate = input => !input || originalValidate(input) + } + const entered = await internalStringInput(message, internalOptions) // inquirer returns an empty string when nothing entered; convert that to undefined return entered ? entered : undefined @@ -151,18 +157,18 @@ export type NumberInputOptions = { helpText?: string } -export const optionalNumberInput = async ( +export const internalNumberInput = async ( message: string, - options?: NumberInputOptions, + options: NumberInputOptions & { required: boolean }, ): Promise => { // Using `inquirer`'s `number` function instead of `input` would be nice but it doesn't allow // us to accept `?` for help text. - const prompt = async (): Promise => await input({ + const prompt = async (): Promise => await input({ ...options, - message: options?.helpText ? `${message} (? for help)` : message, + message: options.helpText ? `${message} (? for help)` : message, transformer: displayNoneForEmpty, validate: input => { - if (options?.helpText && input === '?') { + if (options.helpText && input === '?') { return true } if (input === '') { @@ -172,14 +178,14 @@ export const optionalNumberInput = async ( if (isNaN(asNumber)) { return `"${input}" is not a valid number` } - return options?.validate ? options.validate(asNumber) : true + return options.validate ? options.validate(asNumber) : true }, - default: (typeof options?.default === 'function' ? options.default() : options?.default)?.toString(), - required: false, + default: (typeof options.default === 'function' ? options.default() : options.default)?.toString(), + required: options.required, }) let entered = await prompt() - while (options?.helpText && entered === '?') { + while (options.helpText && entered === '?') { console.log(options.helpText) entered = await prompt() } @@ -187,6 +193,18 @@ export const optionalNumberInput = async ( return entered ? Number(entered) : undefined } +export const optionalNumberInput = async ( + message: string, + options?: NumberInputOptions, +): Promise => + internalNumberInput(message, { ...options, required: false }) + +export const numberInput = async ( + message: string, + options?: NumberInputOptions, +): Promise => + await internalNumberInput(message, { ...options, required: true }) as number + export type BooleanInputOptions = { /** * Specify a default value for when the user hits enter. The default default is true.