From 46163fa1840fc962e05659f9915fb98a673fc6d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oskar=20L=C3=B6fgren?= <516549+ulken@users.noreply.github.com> Date: Thu, 2 Mar 2023 23:52:51 +0100 Subject: [PATCH 1/6] async validate --- packages/core/src/index.ts | 2 +- packages/core/src/prompts/prompt.ts | 10 +++++++--- packages/prompts/src/index.ts | 5 +++-- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index db26c399..a3864caa 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -3,7 +3,7 @@ export { default as GroupMultiSelectPrompt } from './prompts/group-multiselect'; export { default as MultiSelectPrompt } from './prompts/multi-select'; export { default as PasswordPrompt } from './prompts/password'; export { default as Prompt, isCancel } from './prompts/prompt'; -export type { State } from './prompts/prompt'; +export type { State, Validator } from './prompts/prompt'; export { default as SelectPrompt } from './prompts/select'; export { default as SelectKeyPrompt } from './prompts/select-key'; export { default as TextPrompt } from './prompts/text'; diff --git a/packages/core/src/prompts/prompt.ts b/packages/core/src/prompts/prompt.ts index 21ee0077..b26de3ad 100644 --- a/packages/core/src/prompts/prompt.ts +++ b/packages/core/src/prompts/prompt.ts @@ -38,11 +38,15 @@ const aliases = new Map([ ]); const keys = new Set(['up', 'down', 'left', 'right', 'space', 'enter']); +export interface Validator { + (value: Value): string | void | Promise; +} + export interface PromptOptions { render(this: Omit): string | void; placeholder?: string; initialValue?: any; - validate?: ((value: any) => string | void) | undefined; + validate?: Validator; input?: Readable; output?: Writable; debug?: boolean; @@ -153,7 +157,7 @@ export default class Prompt { this.subscribers.clear(); } - private onKeypress(char: string, key?: Key) { + private async onKeypress(char: string, key?: Key) { if (this.state === 'error') { this.state = 'active'; } @@ -172,7 +176,7 @@ export default class Prompt { if (key?.name === 'return') { if (this.opts.validate) { - const problem = this.opts.validate(this.value); + const problem = await this.opts.validate(this.value); if (problem) { this.error = problem; this.state = 'error'; diff --git a/packages/prompts/src/index.ts b/packages/prompts/src/index.ts index d10f34ce..7b6f8c2a 100644 --- a/packages/prompts/src/index.ts +++ b/packages/prompts/src/index.ts @@ -9,6 +9,7 @@ import { SelectPrompt, State, TextPrompt, + Validator, } from '@clack/core'; import isUnicodeSupported from 'is-unicode-supported'; import color from 'picocolors'; @@ -63,7 +64,7 @@ export interface TextOptions { placeholder?: string; defaultValue?: string; initialValue?: string; - validate?: (value: string) => string | void; + validate?: Validator; } export const text = (opts: TextOptions) => { return new TextPrompt({ @@ -99,7 +100,7 @@ export const text = (opts: TextOptions) => { export interface PasswordOptions { message: string; mask?: string; - validate?: (value: string) => string | void; + validate?: Validator; } export const password = (opts: PasswordOptions) => { return new PasswordPrompt({ From abdb997854daaf70648e836f31266dffb23a74e6 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Sun, 5 Mar 2023 14:50:09 -0600 Subject: [PATCH 2/6] adds validate state, triggered after 300ms --- examples/basic/index.ts | 3 ++- packages/core/src/prompts/prompt.ts | 15 +++++++++++++-- packages/prompts/src/index.ts | 5 +++++ 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/examples/basic/index.ts b/examples/basic/index.ts index 8af7a7a3..b032eb27 100644 --- a/examples/basic/index.ts +++ b/examples/basic/index.ts @@ -15,7 +15,8 @@ async function main() { p.text({ message: 'Where should we create your project?', placeholder: './sparkling-solid', - validate: (value) => { + validate: async (value) => { + await setTimeout(299); if (!value) return 'Please enter a path.'; if (value[0] !== '.') return 'Please enter a relative path.'; }, diff --git a/packages/core/src/prompts/prompt.ts b/packages/core/src/prompts/prompt.ts index b26de3ad..81ed68ae 100644 --- a/packages/core/src/prompts/prompt.ts +++ b/packages/core/src/prompts/prompt.ts @@ -4,6 +4,7 @@ import { stdin, stdout } from 'node:process'; import readline from 'node:readline'; import { Readable, Writable } from 'node:stream'; import { WriteStream } from 'node:tty'; +import { setTimeout } from 'node:timers/promises'; import { cursor, erase } from 'sisteransi'; import wrap from 'wrap-ansi'; @@ -52,7 +53,7 @@ export interface PromptOptions { debug?: boolean; } -export type State = 'initial' | 'active' | 'cancel' | 'submit' | 'error'; +export type State = 'initial' | 'active' | 'cancel' | 'validate' | 'submit' | 'error'; export default class Prompt { protected input: Readable; @@ -176,7 +177,17 @@ export default class Prompt { if (key?.name === 'return') { if (this.opts.validate) { - const problem = await this.opts.validate(this.value); + this.state = 'validate'; + let problem = this.opts.validate(this.value); + // Only trigger validation state after 300ms. + // If problem resolves first, render will be cancelled. + await Promise.race([ + problem, + setTimeout(300).then(() => { + this.render(); + }), + ]); + problem = await problem; if (problem) { this.error = problem; this.state = 'error'; diff --git a/packages/prompts/src/index.ts b/packages/prompts/src/index.ts index 7b6f8c2a..1e8cfa5a 100644 --- a/packages/prompts/src/index.ts +++ b/packages/prompts/src/index.ts @@ -20,6 +20,7 @@ export { isCancel } from '@clack/core'; const unicode = isUnicodeSupported(); const s = (c: string, fallback: string) => (unicode ? c : fallback); const S_STEP_ACTIVE = s('◆', '*'); +const S_STEP_VALIDATE = S_STEP_ACTIVE; const S_STEP_CANCEL = s('■', 'x'); const S_STEP_ERROR = s('▲', 'x'); const S_STEP_SUBMIT = s('◇', 'o'); @@ -50,6 +51,8 @@ const symbol = (state: State) => { case 'initial': case 'active': return color.cyan(S_STEP_ACTIVE); + case 'validate': + return color.cyan(S_STEP_VALIDATE); case 'cancel': return color.red(S_STEP_CANCEL); case 'error': @@ -80,6 +83,8 @@ export const text = (opts: TextOptions) => { const value = !this.value ? placeholder : this.valueWithCursor; switch (this.state) { + case 'validate': + return `${title}${color.cyan(S_BAR)} ${value}\n${color.cyan(S_BAR_END)} ${color.dim('Validating...')}\n`; case 'error': return `${title.trim()}\n${color.yellow(S_BAR)} ${value}\n${color.yellow( S_BAR_END From fafbc21fc421f6064cc98f1f905326b7ca49b0f2 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Sun, 5 Mar 2023 14:50:13 -0600 Subject: [PATCH 3/6] add changeset --- .changeset/cool-rats-cheat.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/cool-rats-cheat.md diff --git a/.changeset/cool-rats-cheat.md b/.changeset/cool-rats-cheat.md new file mode 100644 index 00000000..03c89e38 --- /dev/null +++ b/.changeset/cool-rats-cheat.md @@ -0,0 +1,6 @@ +--- +'@clack/prompts': minor +'@clack/core': minor +--- + +Allow `async` validation, add new `validate` state while validation is pending From 45b6f299a00bd339780df0cedcd00bcb7c5e77f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oskar=20L=C3=B6fgren?= <516549+ulken@users.noreply.github.com> Date: Mon, 6 Mar 2023 00:23:51 +0100 Subject: [PATCH 4/6] Revert example --- examples/basic/index.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/examples/basic/index.ts b/examples/basic/index.ts index b032eb27..8af7a7a3 100644 --- a/examples/basic/index.ts +++ b/examples/basic/index.ts @@ -15,8 +15,7 @@ async function main() { p.text({ message: 'Where should we create your project?', placeholder: './sparkling-solid', - validate: async (value) => { - await setTimeout(299); + validate: (value) => { if (!value) return 'Please enter a path.'; if (value[0] !== '.') return 'Please enter a relative path.'; }, From 68abb8144f4bf4190acb86a211cbe1a41835ff53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oskar=20L=C3=B6fgren?= <516549+ulken@users.noreply.github.com> Date: Mon, 6 Mar 2023 00:26:13 +0100 Subject: [PATCH 5/6] Handle `validate` state for password prompt --- packages/prompts/src/index.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/prompts/src/index.ts b/packages/prompts/src/index.ts index 1e8cfa5a..7ac5e5f1 100644 --- a/packages/prompts/src/index.ts +++ b/packages/prompts/src/index.ts @@ -84,7 +84,9 @@ export const text = (opts: TextOptions) => { switch (this.state) { case 'validate': - return `${title}${color.cyan(S_BAR)} ${value}\n${color.cyan(S_BAR_END)} ${color.dim('Validating...')}\n`; + return `${title}${color.cyan(S_BAR)} ${value}\n${color.cyan(S_BAR_END)} ${color.dim( + 'Validating...' + )}\n`; case 'error': return `${title.trim()}\n${color.yellow(S_BAR)} ${value}\n${color.yellow( S_BAR_END @@ -117,6 +119,10 @@ export const password = (opts: PasswordOptions) => { const masked = this.masked; switch (this.state) { + case 'validate': + return `${title}${color.cyan(S_BAR)} ${masked}\n${color.cyan(S_BAR_END)} ${color.dim( + 'Validating...' + )}\n`; case 'error': return `${title.trim()}\n${color.yellow(S_BAR)} ${masked}\n${color.yellow( S_BAR_END From 19a19035f8bcc86acfbab62c2587e66d0365a6da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oskar=20L=C3=B6fgren?= <516549+ulken@users.noreply.github.com> Date: Wed, 8 Mar 2023 00:00:29 +0100 Subject: [PATCH 6/6] cancel timeout if validation finishes --- packages/core/src/prompts/prompt.ts | 41 ++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/packages/core/src/prompts/prompt.ts b/packages/core/src/prompts/prompt.ts index 81ed68ae..10f3ae16 100644 --- a/packages/core/src/prompts/prompt.ts +++ b/packages/core/src/prompts/prompt.ts @@ -4,10 +4,11 @@ import { stdin, stdout } from 'node:process'; import readline from 'node:readline'; import { Readable, Writable } from 'node:stream'; import { WriteStream } from 'node:tty'; -import { setTimeout } from 'node:timers/promises'; import { cursor, erase } from 'sisteransi'; import wrap from 'wrap-ansi'; +const VALIDATION_STATE_DELAY = 400; + function diffLines(a: string, b: string) { if (a === b) return; @@ -22,6 +23,26 @@ function diffLines(a: string, b: string) { return diff; } +function raceTimeout( + promise: Promise, + { onTimeout, delay }: { onTimeout(): void; delay: number } +) { + let timer: NodeJS.Timeout; + + return Promise.race([ + new Promise((resolve) => { + timer = setTimeout(() => { + onTimeout(); + resolve(); + }, delay); + }), + promise.then((value) => { + clearTimeout(timer); + return value; + }), + ]); +} + const cancel = Symbol('clack:cancel'); export function isCancel(value: unknown): value is symbol { return value === cancel; @@ -178,16 +199,16 @@ export default class Prompt { if (key?.name === 'return') { if (this.opts.validate) { this.state = 'validate'; - let problem = this.opts.validate(this.value); - // Only trigger validation state after 300ms. - // If problem resolves first, render will be cancelled. - await Promise.race([ - problem, - setTimeout(300).then(() => { + const validation = Promise.resolve(this.opts.validate(this.value)); + // Delay rendering of validation state. + // If validation resolves first, render will be cancelled. + await raceTimeout(validation, { + onTimeout: () => { this.render(); - }), - ]); - problem = await problem; + }, + delay: VALIDATION_STATE_DELAY, + }); + const problem = await validation; if (problem) { this.error = problem; this.state = 'error';