From a0bde6e7c7aea54df94107503562901adeb38192 Mon Sep 17 00:00:00 2001 From: Ross Stenersen Date: Thu, 17 Aug 2023 09:49:33 -0500 Subject: [PATCH] feat: add support for Q&A Schema App input --- .changeset/funny-chicken-marry.md | 5 + .changeset/green-fireants-try.md | 5 + CONTRIBUTING.md | 1 + .../__tests__/commands/schema/create.test.ts | 1 + .../__tests__/commands/schema/update.test.ts | 1 + packages/cli/src/commands/apps/create.ts | 2 +- packages/cli/src/commands/apps/update.ts | 5 +- packages/cli/src/commands/schema/create.ts | 12 +- packages/cli/src/commands/schema/update.ts | 15 +- packages/cli/src/lib/aws-utils.ts | 6 + packages/cli/src/lib/commands/schema-util.ts | 131 ++++++++++++++++ packages/lib/src/input.ts | 9 +- packages/lib/src/item-input/array.ts | 2 +- .../lib/src/item-input/command-helpers.ts | 18 ++- packages/lib/src/item-input/defs.ts | 23 +++ packages/lib/src/item-input/misc.ts | 126 ++++++++++++++- packages/lib/src/item-input/object.ts | 145 ++++++++++++------ packages/lib/src/user-query.ts | 23 ++- packages/lib/src/validate-util.ts | 7 + 19 files changed, 472 insertions(+), 65 deletions(-) create mode 100644 .changeset/funny-chicken-marry.md create mode 100644 .changeset/green-fireants-try.md diff --git a/.changeset/funny-chicken-marry.md b/.changeset/funny-chicken-marry.md new file mode 100644 index 00000000..affda317 --- /dev/null +++ b/.changeset/funny-chicken-marry.md @@ -0,0 +1,5 @@ +--- +"@smartthings/cli-lib": minor +--- + +Miscellaneous updates to item-input module. diff --git a/.changeset/green-fireants-try.md b/.changeset/green-fireants-try.md new file mode 100644 index 00000000..73b7fda9 --- /dev/null +++ b/.changeset/green-fireants-try.md @@ -0,0 +1,5 @@ +--- +"@smartthings/cli": minor +--- + +Added support for Q&A input and updating of Schema Apps. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 02340ea6..b6c0929b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -127,6 +127,7 @@ We're always looking for more opinions on discussions in the issue tracker. It's - For ambitious tasks, you should try to get your work in front of the community for feedback as soon as possible. Open a pull request as soon as you have done the minimum needed to demonstrate your idea. At this early stage, don't worry about making things perfect, or 100% complete. Describe what you still need to do and submit a [draft pull request](https://github.blog/2019-02-14-introducing-draft-pull-requests/). This lets reviewers know not to nit-pick small details or point out improvements you already know you need to make. - Don't include unrelated changes - New features should be accompanied with tests and documentation +- Pull requests should include only a single commit. You can use `git rebase -i main` to combine multiple commits into a single one if necessary. - Commit messages - Use a clear and descriptive title for the pull request and commits - Commit messages must be formatted properly using [conventional commit format](https://www.conventionalcommits.org/en/v1.0.0/). Our CI will check this and fail any PRs that are formatted incorrectly. diff --git a/packages/cli/src/__tests__/commands/schema/create.test.ts b/packages/cli/src/__tests__/commands/schema/create.test.ts index accb5741..b5c610dc 100644 --- a/packages/cli/src/__tests__/commands/schema/create.test.ts +++ b/packages/cli/src/__tests__/commands/schema/create.test.ts @@ -22,6 +22,7 @@ describe('SchemaAppCreateCommand', () => { tableFieldDefinitions: ['endpointAppId', 'stClientId', 'stClientSecret'], }), expect.any(Function), + expect.anything(), ) }) diff --git a/packages/cli/src/__tests__/commands/schema/update.test.ts b/packages/cli/src/__tests__/commands/schema/update.test.ts index 5561f190..d3c268ce 100644 --- a/packages/cli/src/__tests__/commands/schema/update.test.ts +++ b/packages/cli/src/__tests__/commands/schema/update.test.ts @@ -47,6 +47,7 @@ describe('SchemaUpdateCommand', () => { expect(inputItemMock).toBeCalledWith( expect.any(SchemaUpdateCommand), + expect.anything(), ) }) diff --git a/packages/cli/src/commands/apps/create.ts b/packages/cli/src/commands/apps/create.ts index f0689087..03ed4a00 100644 --- a/packages/cli/src/commands/apps/create.ts +++ b/packages/cli/src/commands/apps/create.ts @@ -122,7 +122,7 @@ export default class AppCreateCommand extends APICommand { @@ -48,6 +53,7 @@ export default class SchemaAppCreateCommand extends APICommand getSchemaAppCreateFromUser(this)), + ) } } diff --git a/packages/cli/src/commands/schema/update.ts b/packages/cli/src/commands/schema/update.ts index 9724882f..ead03548 100644 --- a/packages/cli/src/commands/schema/update.ts +++ b/packages/cli/src/commands/schema/update.ts @@ -2,9 +2,10 @@ import { Flags, Errors } from '@oclif/core' import { SchemaApp, SchemaAppRequest } from '@smartthings/core-sdk' -import { APICommand, inputItem, selectFromList, lambdaAuthFlags, SelectFromListConfig } from '@smartthings/cli-lib' +import { APICommand, inputItem, selectFromList, lambdaAuthFlags, SelectFromListConfig, userInputProcessor, inputAndOutputItem } from '@smartthings/cli-lib' import { addSchemaPermission } from '../../lib/aws-utils' +import { getSchemaAppUpdateFromUser } from '../../lib/commands/schema-util' export default class SchemaUpdateCommand extends APICommand { @@ -14,6 +15,11 @@ export default class SchemaUpdateCommand extends APICommand this.client.schema.list(), }) - const [request] = await inputItem(this) + const getInputFromUser = async (): Promise => { + const original = await this.client.schema.get(id) + return getSchemaAppUpdateFromUser(this, original, this.flags['dry-run']) + } + + const [request] = await inputItem(this, userInputProcessor(getInputFromUser)) if (this.flags.authorize) { if (request.hostingType === 'lambda') { if (request.lambdaArn) { diff --git a/packages/cli/src/lib/aws-utils.ts b/packages/cli/src/lib/aws-utils.ts index 94e3087d..74d0c183 100644 --- a/packages/cli/src/lib/aws-utils.ts +++ b/packages/cli/src/lib/aws-utils.ts @@ -34,3 +34,9 @@ export async function addPermission(arn: string, principal = '906037444270', sta export function addSchemaPermission(arn: string, principal = '148790070172', statementId = 'smartthings'): Promise { return addPermission(arn, principal, statementId) } + +/** + * Help text for use in `InputDefinition` instances. + */ +export const awsHelpText = 'More information on AWS Lambdas can be found at:\n' + + ' https://docs.aws.amazon.com/lambda/latest/dg/welcome.html' diff --git a/packages/cli/src/lib/commands/schema-util.ts b/packages/cli/src/lib/commands/schema-util.ts index e181ba1d..89618359 100644 --- a/packages/cli/src/lib/commands/schema-util.ts +++ b/packages/cli/src/lib/commands/schema-util.ts @@ -1 +1,132 @@ +import { SchemaApp, SchemaAppRequest, SmartThingsURLProvider, ViperAppLinks } from '@smartthings/core-sdk' + +import { + InputDefinition, + SmartThingsCommandInterface, + booleanDef, + clipToMaximum, + createFromUserInput, + emailValidate, + httpsURLValidate, + listSelectionDef, + maxItemValueLength, + objectDef, + optionalDef, + optionalStringDef, + staticDef, + stringDef, + undefinedDef, + updateFromUserInput, +} from '@smartthings/cli-lib' +import { awsHelpText } from '../aws-utils' + + export const SCHEMA_AWS_PRINCIPAL = '148790070172' + +const arnDef = (name: string, inChina: boolean, options?: { forChina?: boolean }): InputDefinition => { + if (inChina && !options?.forChina || !inChina && options?.forChina) { + return undefinedDef + } + + const helpText = awsHelpText + + // In China there is only one ARN field so we can make it required. Globally there are three + // and at least one of the three is required, but individually all are optional. + // (See `validateFinal` function below for the validation requiring at least one.) + return optionalDef(inChina ? stringDef(name, { helpText }) : optionalStringDef(name, { helpText }), + (context?: unknown[]) => (context?.[0] as Pick)?.hostingType === 'lambda') +} + +const webHookUrlDef = (inChina: boolean): InputDefinition => { + if (inChina) { + return undefinedDef + } + + return optionalDef(stringDef('Webhook URL'), + (context?: unknown[]) => (context?.[0] as Pick)?.hostingType === 'webhook') +} + +// Create a type with some extra temporary fields. +type InputData = SchemaAppRequest & { includeAppLinks: boolean } + +const validateFinal = (schemaAppRequest: InputData): true | string => { + if ( schemaAppRequest.hostingType === 'lambda' + && !schemaAppRequest.lambdaArn + && !schemaAppRequest.lambdaArnEU + && !schemaAppRequest.lambdaArnAP + && !schemaAppRequest.lambdaArnCN) { + return 'At least one lambda ARN is required.' + } + return true +} + +const appLinksDefSummarize = (value?: ViperAppLinks): string => + clipToMaximum(`android: ${value?.android}, ios: ${value?.ios}`, maxItemValueLength) +const appLinksDef = objectDef('App-to-app Links', { + android: stringDef('Android Link'), + ios: stringDef('iOS Link'), + isLinkingEnabled: staticDef(true), +}, { summarizeForEdit: appLinksDefSummarize }) + +const buildInputDefinition = (command: SmartThingsCommandInterface): InputDefinition => { + // TODO: should do more type checking on this, perhaps using zod or + const baseURL = (command.profile.clientIdProvider as SmartThingsURLProvider | undefined)?.baseURL + const inChina = typeof baseURL === 'string' && baseURL.endsWith('cn') + + const hostingTypeDef = inChina + ? staticDef('lambda') + : listSelectionDef('Hosting Type', ['lambda', 'webhook'], { default: 'webhook' }) + + // TODO: add help text + // TODO: test ARNs + return objectDef('Schema App', { + partnerName: stringDef('Partner Name'), + userEmail: stringDef('User email', { validate: emailValidate }), + appName: optionalStringDef('App Name', { + default: (context?: unknown[]) => (context?.[0] as Pick)?.partnerName ?? '', + }), + oAuthAuthorizationUrl: stringDef('OAuth Authorization URL', { validate: httpsURLValidate }), + icon: optionalStringDef('Icon URL', { validate: httpsURLValidate }), + icon2x: optionalStringDef('2x Icon URL', { validate: httpsURLValidate }), + icon3x: optionalStringDef('3x Icon URL', { validate: httpsURLValidate }), + oAuthClientId: stringDef('Partner OAuth Client Id'), + oAuthClientSecret: stringDef('Partner OAuth Client Secret'), + oAuthTokenUrl: stringDef('Partner OAuth Refresh Token URL', { validate: httpsURLValidate }), + oAuthScope: stringDef('Partner OAuth Scope'), + schemaType: staticDef('st-schema'), + hostingType: hostingTypeDef, + lambdaArn: arnDef('Lambda ARN for US region', inChina), + lambdaArnEU: arnDef('Lambda ARN for EU region', inChina), + lambdaArnCN: arnDef('Lambda ARN for CN region', inChina, { forChina: true }), + lambdaArnAP: arnDef('Lambda ARN for AP region', inChina), + webhookUrl: webHookUrlDef(inChina), + includeAppLinks: booleanDef('Enable app-to-app linking?', { default: false }), + viperAppLinks: optionalDef(appLinksDef, + (context?: unknown[]) => (context?.[0] as Pick)?.includeAppLinks), + }, { validateFinal }) +} + +const stripTempInputFields = (inputData: InputData): SchemaAppRequest => { + // Strip out extra temporary data to make the `InputData` into a `SchemaAppRequest`. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { includeAppLinks, ...result } = inputData + + return result +} + +export const getSchemaAppUpdateFromUser = async (command: SmartThingsCommandInterface, original: SchemaApp, dryRun: boolean): Promise => { + const inputDef = buildInputDefinition(command) + + const inputData = await updateFromUserInput(command, inputDef, + { ...original, includeAppLinks: !!original.viperAppLinks }, { dryRun }) + + return stripTempInputFields(inputData) +} + +export const getSchemaAppCreateFromUser = async (command: SmartThingsCommandInterface): Promise => { + const inputDef = buildInputDefinition(command) + + const inputData = await createFromUserInput(command, inputDef, { dryRun: command.flags['dry-run'] }) + + return stripTempInputFields(inputData) +} diff --git a/packages/lib/src/input.ts b/packages/lib/src/input.ts index c11cdaf2..94772b5a 100644 --- a/packages/lib/src/input.ts +++ b/packages/lib/src/input.ts @@ -121,8 +121,13 @@ export type UserInputCommand = { * always be the last one in the list since input processors are checked in order and this can * always provide data. */ -export function userInputProcessor(command: UserInputCommand): InputProcessor { - return inputProcessor(() => true, () => command.getInputFromUser()) +export function userInputProcessor(command: UserInputCommand): InputProcessor +export function userInputProcessor(readFn: () => Promise): InputProcessor +export function userInputProcessor(commandOrReadFn: UserInputCommand | (() => Promise)): InputProcessor { + if (typeof commandOrReadFn === 'function') { + return inputProcessor(() => true, commandOrReadFn) + } + return inputProcessor(() => true, () => commandOrReadFn.getInputFromUser()) } export class CombinedInputProcessor implements InputProcessor { diff --git a/packages/lib/src/item-input/array.ts b/packages/lib/src/item-input/array.ts index b4858d3b..d6ef48e1 100644 --- a/packages/lib/src/item-input/array.ts +++ b/packages/lib/src/item-input/array.ts @@ -206,7 +206,7 @@ export type CheckboxDefItem = T extends string ? string | ComplexCheckboxDefI export function checkboxDef(name: string, items: CheckboxDefItem[], options?: CheckboxDefOptions): InputDefinition { const editValues = async (values: T[]): Promise => { - // We can't add help to the inquirer `checkbox` so, at least for now, we'll just display + // We can't add help to the inquirer `checkbox` so, at least for now, we'll display // the help before we display the checkbox. if (options?.helpText) { console.log(`\n${options.helpText}\n`) diff --git a/packages/lib/src/item-input/command-helpers.ts b/packages/lib/src/item-input/command-helpers.ts index 76b42b6a..2f7aef11 100644 --- a/packages/lib/src/item-input/command-helpers.ts +++ b/packages/lib/src/item-input/command-helpers.ts @@ -11,6 +11,7 @@ import { previewJSONAction, previewYAMLAction, } from './defs' +import { red } from 'chalk' export type UpdateFromUserInputOptions = { @@ -33,6 +34,7 @@ export const updateFromUserInput = async (command: SmartThings // TODO: this should probably be moved to someplace more common const indent = command.flags.indent ?? command.cliConfig.profile.indent ?? (formatter === yamlFormatter ? 2 : 4) const output = formatter(indent)(retVal) + // TODO: use `askForBoolean` const editAgain = (await inquirer.prompt({ type: 'confirm', name: 'editAgain', @@ -49,11 +51,23 @@ export const updateFromUserInput = async (command: SmartThings // eslint-disable-next-line no-constant-condition while (true) { + const validationResult = inputDefinition.validateFinal ? inputDefinition.validateFinal(retVal) : true + if (validationResult !== true) { + console.log(red(validationResult)) + const answer = await inputDefinition.updateFromUserInput(retVal) + if (answer !== cancelAction) { + retVal = answer + } + continue + } const choices: ChoiceCollection = [ editOption(inputDefinition.name), { name: 'Preview JSON.', value: previewJSONAction }, { name: 'Preview YAML.', value: previewYAMLAction }, - { name: `Finish and ${options.dryRun ? 'output' : (options.finishVerb ?? 'update')} ${inputDefinition.name}.`, value: finishAction }, + { + name: `Finish and ${options.dryRun ? 'output' : (options.finishVerb ?? 'update')} ${inputDefinition.name}.`, + value: finishAction, + }, { name: `Cancel creating ${inputDefinition.name}.`, value: cancelAction }, ] @@ -62,7 +76,7 @@ export const updateFromUserInput = async (command: SmartThings name: 'action', message: 'Choose an action.', choices, - default: finishAction, + default: validationResult === true ? finishAction : editAction, })).action if (action === editAction) { diff --git a/packages/lib/src/item-input/defs.ts b/packages/lib/src/item-input/defs.ts index 377fa577..892018e3 100644 --- a/packages/lib/src/item-input/defs.ts +++ b/packages/lib/src/item-input/defs.ts @@ -47,12 +47,34 @@ export type InputDefinition = { */ updateFromUserInput(original: T, context?: unknown[]): Promise + // TODO: implement and document + // TODO: better name + // TODO: maybe add dependencies between fields somehow for objectDef? + // "predicate" option for fields in objectDef? + /** + * If provided, this method will be called on subsequent items in an object definition + * when a change is made to one. + * + * Use cases: + * 1. computed values need to be recomputed when things they depend upon have changed + * 2. when selection made for one field leads to a different set of later fields requiring input + */ + updateIfNeeded?: (original: U, updatedPropertyName: string | number | symbol, context?: unknown[]) => Promise + /** * Specific item types can include data here for reference outside the definition builder. * Currently this is used by object-type item definitions so parent definitions can access * child definition properties for rolled up properties. */ itemTypeData?: { type: 'object' } + + /** + * Optional final validation. Most validation should be done on each field as it's entered + * or updated but sometimes in a more complex object, a final validation needs to be used. + * + * Return true if the object is valid or a string error message if not. + */ + validateFinal?: (item: U, context?: unknown[]) => true | string } /** @@ -62,6 +84,7 @@ export type InputDefinition = { */ export type InputDefinitionValidateFunction = (input: string, context?: unknown[]) => true | string | Promise +export type InputDefinitionDefaultValueOrFn = T | ((context?: unknown[]) => T) export const addAction = Symbol('add') export type AddAction = typeof addAction diff --git a/packages/lib/src/item-input/misc.ts b/packages/lib/src/item-input/misc.ts index 65459247..2c505a3c 100644 --- a/packages/lib/src/item-input/misc.ts +++ b/packages/lib/src/item-input/misc.ts @@ -1,5 +1,15 @@ -import { askForString, askForOptionalString, AskForStringOptions, ValidateFunction } from '../user-query' -import { InputDefinition, InputDefinitionValidateFunction, Uneditable, uneditable } from './defs' +import inquirer, { ChoiceCollection } from 'inquirer' +import { askForString, askForOptionalString, AskForStringOptions, ValidateFunction, AskForBooleanOptions, askForBoolean, DefaultValueOrFn } from '../user-query' +import { + CancelAction, + InputDefinition, + InputDefinitionDefaultValueOrFn, + InputDefinitionValidateFunction, + Uneditable, + cancelOption, + uneditable, +} from './defs' +import { stringFromUnknown } from '../util' export const validateWithContextFn = (validate?: InputDefinitionValidateFunction, context?: unknown[]): ValidateFunction | undefined => @@ -7,13 +17,20 @@ export const validateWithContextFn = (validate?: InputDefinitionValidateFunction ? (input: string): true | string | Promise => validate(input, context) : undefined -export type StringDefOptions = Omit & { +export const defaultWithContextFn = (def?: InputDefinitionDefaultValueOrFn, context?: unknown[]): DefaultValueOrFn | undefined => + typeof def === 'function' ? () => def(context) : def + +export type StringDefOptions = Omit & { + default?: InputDefinitionDefaultValueOrFn validate?: InputDefinitionValidateFunction } export const optionalStringDef = (name: string, options?: StringDefOptions): InputDefinition => { const buildFromUserInput = async (context?: unknown[]): Promise => - askForOptionalString(`${name} (optional)`, - { ...options, validate: validateWithContextFn(options?.validate, context) }) + askForOptionalString(`${name} (optional)`, { + ...options, + default: defaultWithContextFn(options?.default, context), + validate: validateWithContextFn(options?.validate, context), + }) const summarizeForEdit = (original: string): string => original const updateFromUserInput = (original: string, context?: unknown[]): Promise => askForOptionalString(`${name} (optional)`, @@ -34,6 +51,15 @@ export const stringDef = (name: string, options?: StringDefOptions): InputDefini return { name, buildFromUserInput, summarizeForEdit, updateFromUserInput } } +export type BooleanDefOptions = AskForBooleanOptions +export const booleanDef = (name: string, options?: BooleanDefOptions): InputDefinition => { + const buildFromUserInput = async (): Promise => askForBoolean(name, options) + const summarizeForEdit = (value: boolean): string => value ? 'Yes' : 'No' + const updateFromUserInput = async (original: boolean): Promise => askForBoolean(name, { default: original }) + + return { name, buildFromUserInput, summarizeForEdit, updateFromUserInput } +} + /** * Use a static definition if you only need a hard-coded value for a given field. The user will * never be asked or notified and the value specified will be used. @@ -45,10 +71,100 @@ export const staticDef = (value: T): InputDefinition => { return { name: 'unused', buildFromUserInput, summarizeForEdit, updateFromUserInput } } +export const undefinedDef = staticDef(undefined) + export const computedDef = (compute: (context?: unknown[]) => T): InputDefinition => { const buildFromUserInput = async (context?: unknown[]): Promise => compute(context) const summarizeForEdit = (): Uneditable => uneditable // TODO: consider how we might calling this if anything in the context prior to it is edited const updateFromUserInput = async (_original: T, context?: unknown[]): Promise => compute(context) + // TODO: implement updateIfNeeded return { name: 'unused', buildFromUserInput, summarizeForEdit, updateFromUserInput } } + +export type ListSelectionDefOptions = { + /** + * A summary of the object to display to the user in a list. + * + * The default is `stringFromUnknown`. + */ + summarizeForEdit?: (item: T, context?: unknown[]) => string + + default?: T + + helpText?: string +} + +/** + * Create an `InputDefinition` for selecting items from a list. + */ +export const listSelectionDef = (name: string, validItems: T[], options?: ListSelectionDefOptions): InputDefinition => { + const summarizeForEdit = options?.summarizeForEdit ?? stringFromUnknown + + const updateFromUserInput = async (original?: T): Promise => { + const choices: ChoiceCollection = validItems.map((validItem: T) => ({ + name: summarizeForEdit(validItem), + value: validItem, + })) + choices.push(cancelOption) + + const chosen: T | CancelAction = (await inquirer.prompt({ + type: 'list', + name: 'chosen', + message: `Select ${name}:`, // TODO: check + choices, + default: original, + })).chosen + + return chosen + } + + const buildFromUserInput = async (): Promise => updateFromUserInput(options?.default) + + return { name, buildFromUserInput, summarizeForEdit, updateFromUserInput } +} + +export type OptionalDefPredicateFn = (context?: unknown[]) => boolean +/** + * Given an `InputDefinition`, `inputDef`, for type `T` and a predicate, checkIsActive, create a new + * `InputDefinition` for type `T | undefined` that always resolves to `undefined` without consulting + * the user if the predicate returns false. If the predicate returns true, the provided + * `InputDefinition` is used to retrieve the value from the user. + * + * NOTES: + * - We don't save any previously entered value for if the predicate starts returning `false` + * after previously returning `true`. This could be implemented at a future date if it we decide + * it would be useful. + */ +export const optionalDef = (inputDef: InputDefinition, checkIsActive: OptionalDefPredicateFn): InputDefinition => { + // In certain situations we need to know if the def has changed. + let isActive = false + const decideAndSave = (context?: unknown[]): boolean => { + isActive = checkIsActive(context) + return isActive + } + const buildFromUserInput = async (context?: unknown[]): Promise => + decideAndSave(context) ? inputDef.buildFromUserInput(context) : undefined + const summarizeForEdit = (value: T | undefined, context?: unknown[]): string | Uneditable => + isActive ? inputDef.summarizeForEdit(value as T, context) : uneditable + const updateFromUserInput = async (original: T | undefined, context?: unknown[]): Promise => + isActive + ? (original ? inputDef.updateFromUserInput(original as T, context) : inputDef.buildFromUserInput(context)) + : undefined + + const updateIfNeeded = async (original: T | undefined, updatedPropertyName: string | number | symbol, context?: unknown[]): Promise => { + const previouslyActive = isActive + const currentlyActive = decideAndSave(context) + if (previouslyActive && currentlyActive) { + return inputDef.updateIfNeeded + ? inputDef.updateIfNeeded(original as T, updatedPropertyName, context) + : original + } + if (currentlyActive && !previouslyActive) { + return inputDef.buildFromUserInput(context) + } + return undefined + } + + return { name: inputDef.name, buildFromUserInput, summarizeForEdit, updateFromUserInput, updateIfNeeded } +} diff --git a/packages/lib/src/item-input/object.ts b/packages/lib/src/item-input/object.ts index f7433c62..148b5c7c 100644 --- a/packages/lib/src/item-input/object.ts +++ b/packages/lib/src/item-input/object.ts @@ -33,6 +33,14 @@ export type ObjectDefOptions = { rollup?: boolean helpText?: string + + /** + * Optional final validation. Most validation should be done on each field as it's entered + * or updated but sometimes in a more complex object, a final validation needs to be used. + * + * Return true if the object is valid or a string error message if not. + */ + validateFinal?: (item: T, context?: unknown[]) => true | string } const defaultSummarizeForEditFn = (name: string) => (): string => { throw Error(`missing implementation of summarizeForEdit for objectDef ${name}`) } @@ -44,6 +52,44 @@ export type ObjectItemTypeData = { const maxPropertiesForDefaultRollup = 3 +const buildPropertyChoices = (inputDefsByProperty: InputDefsByProperty, updated: T, contextForChildren: unknown[]): ChoiceCollection => { + const choices = [] as ChoiceCollection + for (const propertyName in inputDefsByProperty) { + const propertyInputDefinition = inputDefsByProperty[propertyName] + if (!propertyInputDefinition) { + continue + } + const propertyValue = updated[propertyName] + if (propertyInputDefinition.itemTypeData?.type === 'object' && + (propertyInputDefinition.itemTypeData as ObjectItemTypeData).rolledUp) { + // nested property that is rolled up + const itemTypeData = propertyInputDefinition.itemTypeData as ObjectItemTypeData + for (const nestedPropertyName in itemTypeData.inputDefsByProperty) { + const nestedPropertyInputDefinition = + (itemTypeData.inputDefsByProperty as { [key: string]: InputDefinition })[nestedPropertyName] + const nestedItem = (propertyValue as { [key: string]: unknown })[nestedPropertyName] + const summary = nestedPropertyInputDefinition.summarizeForEdit(nestedItem, contextForChildren) + if (summary !== uneditable) { + choices.push({ + name: `Edit ${nestedPropertyInputDefinition.name}: ${summary}`, + value: `${propertyName}.${nestedPropertyName}`, + }) + } + } + } else { + // top-level property + const summary = propertyInputDefinition.summarizeForEdit(propertyValue, contextForChildren) + if (summary !== uneditable) { + choices.push({ + name: `Edit ${propertyInputDefinition.name}: ${summary}`, + value: propertyName, + }) + } + } + } + return choices +} + // TODO: deal with optional objects // rolled up: make all items optional or don't allow rollup for optional classes? // TODO: refactor to reduce nesting complexity @@ -57,6 +103,9 @@ const maxPropertiesForDefaultRollup = 3 * * If this definition is for a nested object whose properties are not rolled up into the questions * of its parent, you must include `summarizeForEdit` in `options`. + * + * NOTES: + * Calling `updateIfNeeded` on rolled up properties is not yet implemented. */ export function objectDef(name: string, inputDefsByProperty: InputDefsByProperty, options?: ObjectDefOptions): InputDefinition { @@ -93,40 +142,7 @@ export function objectDef(name: string, inputDefsByProperty: I // eslint-disable-next-line no-constant-condition while (true) { const contextForChildren = [{ ...updated }, ...context] - const choices = [] as ChoiceCollection - for (const propertyName in inputDefsByProperty) { - const propertyInputDefinition = inputDefsByProperty[propertyName] - if (!propertyInputDefinition) { - continue - } - const propertyValue = updated[propertyName] - if (propertyInputDefinition.itemTypeData?.type === 'object' && - (propertyInputDefinition.itemTypeData as ObjectItemTypeData).rolledUp) { - // nested property that is rolled up - const itemTypeData = propertyInputDefinition.itemTypeData as ObjectItemTypeData - for (const nestedPropertyName in itemTypeData.inputDefsByProperty) { - const nestedPropertyInputDefinition = - (itemTypeData.inputDefsByProperty as { [key: string]: InputDefinition })[nestedPropertyName] - const nestedItem = (propertyValue as { [key: string]: unknown })[nestedPropertyName] - const summary = nestedPropertyInputDefinition.summarizeForEdit(nestedItem, contextForChildren) - if (summary !== uneditable) { - choices.push({ - name: `Edit ${nestedPropertyInputDefinition.name}: ${summary}`, - value: `${propertyName}.${nestedPropertyName}`, - }) - } - } - } else { - // top-level property - const summary = propertyInputDefinition.summarizeForEdit(propertyValue, contextForChildren) - if (summary !== uneditable) { - choices.push({ - name: `Edit ${propertyInputDefinition.name}: ${summary}`, - value: propertyName, - }) - } - } - } + const choices = buildPropertyChoices(inputDefsByProperty, updated, contextForChildren) choices.push(new Separator()) if (options?.helpText) { choices.push(helpOption) @@ -152,30 +168,71 @@ export function objectDef(name: string, inputDefsByProperty: I } else { const props = action.split('.') - const propertyName = props[0] as keyof T - const propertyInputDefinition = inputDefsByProperty[propertyName] + const updatedPropertyName = props[0] as keyof T + const propertyInputDefinition = inputDefsByProperty[updatedPropertyName] if (props.length === 1) { // top-level property - const updatedProperty = await propertyInputDefinition.updateFromUserInput(updated[propertyName], contextForChildren) - if (updatedProperty !== cancelAction) { - updated[propertyName] = updatedProperty + const updatedPropertyValue = await propertyInputDefinition + .updateFromUserInput(updated[updatedPropertyName], contextForChildren) + if (updatedPropertyValue !== cancelAction && updated[updatedPropertyName] !== updatedPropertyValue) { + updated[updatedPropertyName] = updatedPropertyValue + let afterUpdatedProperty = false + for (const propertyName in inputDefsByProperty) { + if (afterUpdatedProperty) { + const laterPropertyInputDefinition = inputDefsByProperty[propertyName] + const updateIfNeeded = laterPropertyInputDefinition.updateIfNeeded + if (updateIfNeeded) { + const laterPropertyValue = await updateIfNeeded(updated[propertyName], updatedPropertyName, [{ ...updated }, ...context]) + if (laterPropertyValue !== cancelAction) { + updated[propertyName] = laterPropertyValue + } + } + } else if (propertyName === updatedPropertyName) { + afterUpdatedProperty = true + } + const propertyInputDefinition = inputDefsByProperty[propertyName] + if (!propertyInputDefinition) { + continue + } + } } } else { // nested property that is rolled up const nestedPropertyName = props[1] const itemTypeData = propertyInputDefinition.itemTypeData as ObjectItemTypeData - const objectValue = updated[propertyName] as { [key: string]: unknown } + const objectValue = updated[updatedPropertyName] as { [key: string]: unknown } const nestedPropertyInputDefinition = (itemTypeData.inputDefsByProperty as { [key: string]: InputDefinition })[nestedPropertyName] - const updatedProperty = await nestedPropertyInputDefinition.updateFromUserInput( + const updatedPropertyValue = await nestedPropertyInputDefinition.updateFromUserInput( objectValue[nestedPropertyName], contextForChildren, ) - if (updatedProperty !== cancelAction) { - objectValue[nestedPropertyName] = updatedProperty + if (updatedPropertyValue !== cancelAction && objectValue[nestedPropertyName] !== updatedPropertyValue) { + objectValue[nestedPropertyName] = updatedPropertyValue + let afterUpdatedProperty = false + for (const propertyName in inputDefsByProperty) { + if (afterUpdatedProperty) { + const laterPropertyInputDefinition = inputDefsByProperty[propertyName] + const updateIfNeeded = laterPropertyInputDefinition.updateIfNeeded + if (updateIfNeeded) { + const laterPropertyValue = await updateIfNeeded(updated[propertyName], updatedPropertyName, [{ ...updated }, ...context]) + if (laterPropertyValue !== cancelAction) { + updated[propertyName] = laterPropertyValue + } + } + } else if (propertyName === updatedPropertyName) { + // TODO: also do rolled up properties in `propertyName` after nested property + // (Once this is implemented, remove note from documentation for this function.) + afterUpdatedProperty = true + } + const propertyInputDefinition = inputDefsByProperty[propertyName] + if (!propertyInputDefinition) { + continue + } + } } } } @@ -186,5 +243,5 @@ export function objectDef(name: string, inputDefsByProperty: I ?? Object.values(inputDefsByProperty).filter(value => !!value).length <= maxPropertiesForDefaultRollup const itemTypeData: ObjectItemTypeData = { type: 'object', inputDefsByProperty, rolledUp } - return { name, buildFromUserInput, summarizeForEdit, updateFromUserInput, itemTypeData } + return { name, buildFromUserInput, summarizeForEdit, updateFromUserInput, itemTypeData, validateFinal: options?.validateFinal } } diff --git a/packages/lib/src/user-query.ts b/packages/lib/src/user-query.ts index 31d86eb3..d2dcaf13 100644 --- a/packages/lib/src/user-query.ts +++ b/packages/lib/src/user-query.ts @@ -16,8 +16,9 @@ export const numberTransformer: TransformerFunction = (input, _, { isFinal }) => export const allowEmptyFn = (validate: ValidateFunction): ValidateFunction => (input: string): true | string | Promise => input === '' || validate(input) +export type DefaultValueOrFn = T | (() => T) export type AskForStringOptions = { - default?: string + default?: DefaultValueOrFn validate?: ValidateFunction helpText?: string } @@ -37,7 +38,7 @@ const promptForString = async (message: string, options: AskForStringOptions): P name: 'value', message: options.helpText ? `${message} (? for help)` : message, validate: buildValidateFunction(), - default: options.default, + default: typeof options.default === 'function' ? options.default() : options.default, })).value as string | undefined let entered = await prompt() @@ -129,3 +130,21 @@ export const askForNumber = async (message: string, min?: number, max?: number): return value === '' ? undefined : Number(value) } + +export type AskForBooleanOptions = { + /** + * Specify a default value for when the user hits enter. The default default is true. + */ + default?: boolean +} + +export const askForBoolean = async (message: string, options?: AskForBooleanOptions): Promise => { + const answer = (await inquirer.prompt({ + type: 'confirm', + name: 'answer', + message, + default: options?.default ?? true, + })).answer as boolean + + return answer +} diff --git a/packages/lib/src/validate-util.ts b/packages/lib/src/validate-util.ts index 025525c8..c14f3713 100644 --- a/packages/lib/src/validate-util.ts +++ b/packages/lib/src/validate-util.ts @@ -90,6 +90,13 @@ const urlValidateFn = (options?: URLValidateFnOptions): ValidateFunction => { } } +// Email regex found on Stack Overflow: +// https://stackoverflow.com/questions/201323/how-can-i-validate-an-email-address-using-a-regular-expression +// eslint-disable-next-line no-control-regex +const validEmailRegex = /(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])/ +export const emailValidate = (input: string): true | string => + input.match(validEmailRegex) ? true : 'must be a valid email address' + export const urlValidate = urlValidateFn() export const httpsURLValidate = urlValidateFn({ httpsRequired: true }) export const localhostOrHTTPSValidate = urlValidateFn({ httpsRequired: true, allowLocalhostHTTP: true })