diff --git a/.changeset/silly-bears-share.md b/.changeset/silly-bears-share.md new file mode 100644 index 00000000..cff8529c --- /dev/null +++ b/.changeset/silly-bears-share.md @@ -0,0 +1,6 @@ +--- +"@clack/prompts": minor +"@clack/core": minor +--- + +Add async validation support to prompts, and validation state rendering to text prompts. diff --git a/packages/core/src/prompts/prompt.ts b/packages/core/src/prompts/prompt.ts index 2291544b..66a6348b 100644 --- a/packages/core/src/prompts/prompt.ts +++ b/packages/core/src/prompts/prompt.ts @@ -209,7 +209,7 @@ export default class Prompt { this._setUserInput(''); } - private onKeypress(char: string | undefined, key: Key) { + private async onKeypress(char: string | undefined, key: Key) { if (this._track && key.name !== 'return') { if (key.name && this._isActionKey(char, key)) { this.rl?.write(null, { ctrl: true, name: 'h' }); @@ -238,7 +238,21 @@ export default class Prompt { if (key?.name === 'return' && this._shouldSubmit(char, key)) { if (this.opts.validate) { - const problem = runValidation(this.opts.validate, this.value); + const problemResult = runValidation(this.opts.validate, this.value); + let problem: string | Error | undefined; + // Only if it is not a string or an Error, we assume + // it is a Promise and await it. + if ( + problemResult !== undefined && + typeof problemResult !== 'string' && + !(problemResult instanceof Error) + ) { + this.state = 'validating'; + this.render(); + problem = await problemResult; + } else { + problem = problemResult; + } if (problem) { this.error = problem instanceof Error ? problem.message : problem; this.state = 'error'; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index a4a56405..14c34567 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -4,7 +4,7 @@ import type { Action } from './utils/settings.js'; /** * The state of the prompt */ -export type ClackState = 'initial' | 'active' | 'cancel' | 'submit' | 'error'; +export type ClackState = 'initial' | 'active' | 'cancel' | 'submit' | 'error' | 'validating'; /** * Typed event emitter for clack @@ -15,6 +15,7 @@ export interface ClackEvents { cancel: (value?: any) => void; submit: (value?: any) => void; error: (value?: any) => void; + validating: (value?: any) => void; cursor: (key?: Action) => void; key: (key: string | undefined, info: Key) => void; value: (value?: TValue) => void; diff --git a/packages/core/src/utils/validation.ts b/packages/core/src/utils/validation.ts index 0e04d5f3..aec88307 100644 --- a/packages/core/src/utils/validation.ts +++ b/packages/core/src/utils/validation.ts @@ -1,5 +1,7 @@ import type { StandardSchemaV1 } from './standard-schema.js'; +type MaybePromise = T | Promise; + /** * A function or [Standard Schema](https://github.com/standard-schema/standard-schema) * that validates user input. If a custom function is given, you should return a @@ -33,7 +35,7 @@ import type { StandardSchemaV1 } from './standard-schema.js'; * ``` */ export type Validate = - | ((value: TValue | undefined) => string | Error | undefined) + | ((value: TValue | undefined) => MaybePromise) | StandardSchemaV1; /** @@ -45,15 +47,13 @@ export type Validate = export function runValidation( validate: Validate, value: TValue | undefined -): string | Error | undefined { +): MaybePromise { if ('~standard' in validate) { const result = validate['~standard'].validate(value); // https://standardschema.dev/schema#how-to-only-allow-synchronous-validation // TODO: https://github.com/bombshell-dev/clack/issues/92 if (result instanceof Promise) { - throw new TypeError( - 'Schema validation must be synchronous. Update `validate()` and remove any asynchronous logic.' - ); + return result.then((res) => res.issues?.at(0)?.message); } return result.issues?.at(0)?.message; } diff --git a/packages/core/test/prompts/prompt.test.ts b/packages/core/test/prompts/prompt.test.ts index 89c1e37f..5a29d936 100644 --- a/packages/core/test/prompts/prompt.test.ts +++ b/packages/core/test/prompts/prompt.test.ts @@ -6,6 +6,8 @@ import { isCancel } from '../../src/utils/index.js'; import { MockReadable } from '../mock-readable.js'; import { MockWritable } from '../mock-writable.js'; +const waitForTick = () => new Promise((resolve) => setTimeout(resolve, 0)); + describe('Prompt', () => { let input: MockReadable; let output: MockWritable; @@ -347,4 +349,46 @@ describe('Prompt', () => { expect(instance.error).to.equal('must be "valid" (was "invalid")'); }); }); + + test('validates value with async validation', async () => { + const instance = new Prompt({ + input, + output, + render: () => 'foo', + validate: async (value) => { + await waitForTick(); + return value === 'valid' ? undefined : 'Invalid value'; + }, + }); + instance.prompt(); + + instance.value = 'invalid'; + input.emit('keypress', '', { name: 'return' }); + + expect(instance.state).to.equal('validating'); + await waitForTick(); // Wait for the validation to complete + expect(instance.state).to.equal('error'); + expect(instance.error).to.equal('Invalid value'); + }); + + test('accepts valid value with async validation', async () => { + const instance = new Prompt({ + input, + output, + render: () => 'foo', + validate: async (value) => { + await waitForTick(); + return value === 'valid' ? undefined : 'Invalid value'; + }, + }); + instance.prompt(); + + instance.value = 'valid'; + input.emit('keypress', '', { name: 'return' }); + + expect(instance.state).to.equal('validating'); + await waitForTick(); // Wait for the validation to complete + expect(instance.state).to.equal('submit'); + expect(instance.error).to.equal(''); + }); }); diff --git a/packages/prompts/src/common.ts b/packages/prompts/src/common.ts index e4a0c379..c9d896f9 100644 --- a/packages/prompts/src/common.ts +++ b/packages/prompts/src/common.ts @@ -50,6 +50,8 @@ export const symbol = (state: State) => { return styleText('yellow', S_STEP_ERROR); case 'submit': return styleText('green', S_STEP_SUBMIT); + case 'validating': + return styleText('dim', S_STEP_ACTIVE); } }; diff --git a/packages/prompts/src/text.ts b/packages/prompts/src/text.ts index ff72b1d5..796fea38 100644 --- a/packages/prompts/src/text.ts +++ b/packages/prompts/src/text.ts @@ -75,6 +75,13 @@ export const text = (opts: TextOptions) => { const value = this.value ?? ''; switch (this.state) { + case 'validating': { + const validatePrefix = hasGuide ? `${styleText('cyan', S_BAR)} ` : ''; + const validatePrefixEnd = hasGuide ? styleText('cyan', S_BAR_END) : ''; + const userInputText = styleText('dim', userInput); + const validateText = styleText('dim', 'Validating...'); + return `${title}${validatePrefix}${userInputText}\n${validatePrefixEnd} ${validateText}\n`; + } case 'error': { const errorText = this.error ? ` ${styleText('yellow', this.error)}` : ''; const errorPrefix = hasGuide ? `${styleText('yellow', S_BAR)} ` : ''; diff --git a/packages/prompts/test/__snapshots__/text.test.ts.snap b/packages/prompts/test/__snapshots__/text.test.ts.snap index 43c38c78..bed7f5a3 100644 --- a/packages/prompts/test/__snapshots__/text.test.ts.snap +++ b/packages/prompts/test/__snapshots__/text.test.ts.snap @@ -52,6 +52,58 @@ exports[`text (isCI = false) > defaultValue sets the value but does not render 1 ] `; +exports[`text (isCI = false) > displays a validating message with async validation 1`] = ` +[ + "", + "│ +◆ foo +│ _ +└ +", + "", + "", + "", + "│ x█", + "", + "", + "", + "", + "◆ foo +│ x█ +└ Validating... +", + "", + "", + "", + "▲ foo +│ x█ +└ should be xy +", + "", + "", + "", + "◆ foo +│ xy█ +└ +", + "", + "", + "", + "◆ foo +│ xy█ +└ Validating... +", + "", + "", + "", + "◇ foo +│ xy", + " +", + "", +] +`; + exports[`text (isCI = false) > empty string when no value and no default 1`] = ` [ "", @@ -349,6 +401,58 @@ exports[`text (isCI = true) > defaultValue sets the value but does not render 1` ] `; +exports[`text (isCI = true) > displays a validating message with async validation 1`] = ` +[ + "", + "│ +◆ foo +│ _ +└ +", + "", + "", + "", + "│ x█", + "", + "", + "", + "", + "◆ foo +│ x█ +└ Validating... +", + "", + "", + "", + "▲ foo +│ x█ +└ should be xy +", + "", + "", + "", + "◆ foo +│ xy█ +└ +", + "", + "", + "", + "◆ foo +│ xy█ +└ Validating... +", + "", + "", + "", + "◇ foo +│ xy", + " +", + "", +] +`; + exports[`text (isCI = true) > empty string when no value and no default 1`] = ` [ "", diff --git a/packages/prompts/test/text.test.ts b/packages/prompts/test/text.test.ts index 62de9067..0e534255 100644 --- a/packages/prompts/test/text.test.ts +++ b/packages/prompts/test/text.test.ts @@ -238,4 +238,27 @@ describe.each(['true', 'false'])('text (isCI = %s)', (isCI) => { expect(output.buffer).toMatchSnapshot(); }); + + test('displays a validating message with async validation', async () => { + const result = prompts.text({ + message: 'foo', + validate: async (val) => { + await new Promise((resolve) => setTimeout(resolve, 10)); + return val !== 'xy' ? 'should be xy' : undefined; + }, + input, + output, + }); + + input.emit('keypress', 'x', { name: 'x' }); + input.emit('keypress', '', { name: 'return' }); + await new Promise((resolve) => setTimeout(resolve, 20)); + input.emit('keypress', 'y', { name: 'y' }); + input.emit('keypress', '', { name: 'return' }); + + const value = await result; + + expect(value).toBe('xy'); + expect(output.buffer).toMatchSnapshot(); + }); });