From b90cf5ca2ad6118e43a0bacf0428efb858afa906 Mon Sep 17 00:00:00 2001 From: hcl-z <1401859664@qq.com> Date: Thu, 20 Jun 2024 22:32:00 +0800 Subject: [PATCH] feat: Adding a new warn state to the validate method --- examples/basic/index.ts | 8 +- packages/core/src/index.ts | 2 +- packages/core/src/prompts/prompt.ts | 37 ++++--- packages/prompts/README.md | 9 +- packages/prompts/src/index.ts | 149 ++++++++++++++-------------- 5 files changed, 114 insertions(+), 91 deletions(-) diff --git a/examples/basic/index.ts b/examples/basic/index.ts index 62cdaf78..3c45386e 100644 --- a/examples/basic/index.ts +++ b/examples/basic/index.ts @@ -16,16 +16,16 @@ async function main() { message: 'Where should we create your project?', placeholder: './sparkling-solid', validate: (value) => { - if (!value) return 'Please enter a path.'; - if (value[0] !== '.') return 'Please enter a relative path.'; + if (!value) return { status: 'error', message: 'Please enter a path.' }; + if (value[0] !== '.') return { status: 'warn', message: 'warn: Relative path may not work' }; }, }), password: () => p.password({ message: 'Provide a password', validate: (value) => { - if (!value) return 'Please enter a password.'; - if (value.length < 5) return 'Password should have at least 5 characters.'; + if (!value) return { status: 'error', message: 'Please enter a password.' }; + if (value.length < 5) return { status: 'warn', message: 'warn: Password security is too low' }; }, }), type: ({ results }) => diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index db26c399..4c40c9c2 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, ValidateType } 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 be21f65f..098228ca 100644 --- a/packages/core/src/prompts/prompt.ts +++ b/packages/core/src/prompts/prompt.ts @@ -38,17 +38,18 @@ const aliases = new Map([ ]); const keys = new Set(['up', 'down', 'left', 'right', 'space', 'enter']); +export type ValidateType = ((value: any) => ({ status: 'error' | 'warn', message: string } | void)); export interface PromptOptions { render(this: Omit): string | void; placeholder?: string; initialValue?: any; - validate?: ((value: any) => string | void) | undefined; + validate?: ValidateType; input?: Readable; output?: Writable; debug?: boolean; } -export type State = 'initial' | 'active' | 'cancel' | 'submit' | 'error'; +export type State = 'initial' | 'active' | 'cancel' | 'submit' | 'error' | 'warn'; export default class Prompt { protected input: Readable; @@ -62,6 +63,7 @@ export default class Prompt { public state: State = 'initial'; public value: any; public error: string = ''; + public warn: string = '' constructor( { render, input = stdin, output = stdout, ...opts }: PromptOptions, @@ -154,9 +156,10 @@ export default class Prompt { } private onKeypress(char: string, key?: Key) { - if (this.state === 'error') { + if ((this.state === 'error' || this.state === 'warn') && key?.name !== 'return') { this.state = 'active'; } + if (key?.name && !this._track && aliases.has(key.name)) { this.emit('cursor', aliases.get(key.name)); } @@ -176,18 +179,28 @@ export default class Prompt { this.emit('key', char.toLowerCase()); } + if (key?.name === 'return') { - if (this.opts.validate) { - const problem = this.opts.validate(this.value); - if (problem) { - this.error = problem; - this.state = 'error'; - this.rl.write(this.value); + if (this.state === 'warn') { + this.state = 'submit' + } else { + if (this.opts.validate) { + const problem = this.opts.validate(this.value); + if (problem?.status === 'error') { + this.error = problem.message; + this.state = 'error'; + this.rl.write(this.value); + } else if (problem?.status === 'warn') { + this.warn = problem.message; + this.state = 'warn'; + this.rl.write(this.value); + } else { + this.state = 'submit'; + } + } else { + this.state = 'submit'; } } - if (this.state !== 'error') { - this.state = 'submit'; - } } if (char === '\x03') { this.state = 'cancel'; diff --git a/packages/prompts/README.md b/packages/prompts/README.md index 9e8b5bf8..e1742492 100644 --- a/packages/prompts/README.md +++ b/packages/prompts/README.md @@ -56,8 +56,13 @@ const meaning = await text({ placeholder: 'Not sure', initialValue: '42', validate(value) { - if (value.length === 0) return `Value is required!`; - }, + if (value.length === 0) { + return { + status:'error', + message:'Value is required!' + }; + } + } }); ``` diff --git a/packages/prompts/src/index.ts b/packages/prompts/src/index.ts index b6071bb5..d5d5a318 100644 --- a/packages/prompts/src/index.ts +++ b/packages/prompts/src/index.ts @@ -8,7 +8,8 @@ import { SelectKeyPrompt, SelectPrompt, State, - TextPrompt + TextPrompt, + type ValidateType } from '@clack/core'; import isUnicodeSupported from 'is-unicode-supported'; import color from 'picocolors'; @@ -21,12 +22,14 @@ const s = (c: string, fallback: string) => (unicode ? c : fallback); const S_STEP_ACTIVE = s('◆', '*'); const S_STEP_CANCEL = s('■', 'x'); const S_STEP_ERROR = s('▲', 'x'); +const S_STEP_WARN = s('➤', '!'); const S_STEP_SUBMIT = s('◇', 'o'); const S_BAR_START = s('┌', 'T'); const S_BAR = s('│', '|'); const S_BAR_END = s('└', '—'); + const S_RADIO_ACTIVE = s('●', '>'); const S_RADIO_INACTIVE = s('○', ' '); const S_CHECKBOX_ACTIVE = s('◻', '[•]'); @@ -52,7 +55,9 @@ const symbol = (state: State) => { case 'cancel': return color.red(S_STEP_CANCEL); case 'error': - return color.yellow(S_STEP_ERROR); + return color.red(S_STEP_ERROR); + case 'warn': + return color.yellow(S_STEP_WARN); case 'submit': return color.green(S_STEP_SUBMIT); } @@ -100,7 +105,7 @@ export interface TextOptions { placeholder?: string; defaultValue?: string; initialValue?: string; - validate?: (value: string) => string | void; + validate?: ValidateType; } export const text = (opts: TextOptions) => { return new TextPrompt({ @@ -117,9 +122,13 @@ export const text = (opts: TextOptions) => { switch (this.state) { case 'error': + return `${title.trim()}\n${color.red(S_BAR)} ${value}\n${color.red( + S_BAR_END + )} ${color.red(this.error)}\n`; + case 'warn': return `${title.trim()}\n${color.yellow(S_BAR)} ${value}\n${color.yellow( S_BAR_END - )} ${color.yellow(this.error)}\n`; + )} ${color.yellow(this.warn)}\n`; case 'submit': return `${title}${color.gray(S_BAR)} ${color.dim(this.value || opts.placeholder)}`; case 'cancel': @@ -136,7 +145,7 @@ export const text = (opts: TextOptions) => { export interface PasswordOptions { message: string; mask?: string; - validate?: (value: string) => string | void; + validate?: ValidateType; } export const password = (opts: PasswordOptions) => { return new PasswordPrompt({ @@ -149,15 +158,18 @@ export const password = (opts: PasswordOptions) => { switch (this.state) { case 'error': + return `${title.trim()}\n${color.red(S_BAR)} ${masked}\n${color.red( + S_BAR_END + )} ${color.red(this.error)}\n`; + case 'warn': return `${title.trim()}\n${color.yellow(S_BAR)} ${masked}\n${color.yellow( S_BAR_END - )} ${color.yellow(this.error)}\n`; + )} ${color.yellow(this.warn)}\n`; case 'submit': return `${title}${color.gray(S_BAR)} ${color.dim(masked)}`; case 'cancel': - return `${title}${color.gray(S_BAR)} ${color.strikethrough(color.dim(masked ?? ''))}${ - masked ? '\n' + color.gray(S_BAR) : '' - }`; + return `${title}${color.gray(S_BAR)} ${color.strikethrough(color.dim(masked ?? ''))}${masked ? '\n' + color.gray(S_BAR) : '' + }`; default: return `${title}${color.cyan(S_BAR)} ${value}\n${color.cyan(S_BAR_END)}\n`; } @@ -190,15 +202,13 @@ export const confirm = (opts: ConfirmOptions) => { color.dim(value) )}\n${color.gray(S_BAR)}`; default: { - return `${title}${color.cyan(S_BAR)} ${ - this.value - ? `${color.green(S_RADIO_ACTIVE)} ${active}` - : `${color.dim(S_RADIO_INACTIVE)} ${color.dim(active)}` - } ${color.dim('/')} ${ - !this.value + return `${title}${color.cyan(S_BAR)} ${this.value + ? `${color.green(S_RADIO_ACTIVE)} ${active}` + : `${color.dim(S_RADIO_INACTIVE)} ${color.dim(active)}` + } ${color.dim('/')} ${!this.value ? `${color.green(S_RADIO_ACTIVE)} ${inactive}` : `${color.dim(S_RADIO_INACTIVE)} ${color.dim(inactive)}` - }\n${color.cyan(S_BAR_END)}\n`; + }\n${color.cyan(S_BAR_END)}\n`; } } }, @@ -225,9 +235,8 @@ export const select = (opts: SelectOptions) => { case 'selected': return `${color.dim(label)}`; case 'active': - return `${color.green(S_RADIO_ACTIVE)} ${label} ${ - option.hint ? color.dim(`(${option.hint})`) : '' - }`; + return `${color.green(S_RADIO_ACTIVE)} ${label} ${option.hint ? color.dim(`(${option.hint})`) : '' + }`; case 'cancelled': return `${color.strikethrough(color.dim(label))}`; default: @@ -240,7 +249,6 @@ export const select = (opts: SelectOptions) => { initialValue: opts.initialValue, render() { const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`; - switch (this.state) { case 'submit': return `${title}${color.gray(S_BAR)} ${opt(this.options[this.cursor], 'selected')}`; @@ -273,13 +281,11 @@ export const selectKey = (opts: SelectOptions) => { } else if (state === 'cancelled') { return `${color.strikethrough(color.dim(label))}`; } else if (state === 'active') { - return `${color.bgCyan(color.gray(` ${option.value} `))} ${label} ${ - option.hint ? color.dim(`(${option.hint})`) : '' - }`; + return `${color.bgCyan(color.gray(` ${option.value} `))} ${label} ${option.hint ? color.dim(`(${option.hint})`) : '' + }`; } - return `${color.gray(color.bgWhite(color.inverse(` ${option.value} `)))} ${label} ${ - option.hint ? color.dim(`(${option.hint})`) : '' - }`; + return `${color.gray(color.bgWhite(color.inverse(` ${option.value} `)))} ${label} ${option.hint ? color.dim(`(${option.hint})`) : '' + }`; }; return new SelectKeyPrompt({ @@ -323,17 +329,15 @@ export const multiselect = (opts: MultiSelectOptions) => { ) => { const label = option.label ?? String(option.value); if (state === 'active') { - return `${color.cyan(S_CHECKBOX_ACTIVE)} ${label} ${ - option.hint ? color.dim(`(${option.hint})`) : '' - }`; + return `${color.cyan(S_CHECKBOX_ACTIVE)} ${label} ${option.hint ? color.dim(`(${option.hint})`) : '' + }`; } else if (state === 'selected') { return `${color.green(S_CHECKBOX_SELECTED)} ${color.dim(label)}`; } else if (state === 'cancelled') { return `${color.strikethrough(color.dim(label))}`; } else if (state === 'active-selected') { - return `${color.green(S_CHECKBOX_SELECTED)} ${label} ${ - option.hint ? color.dim(`(${option.hint})`) : '' - }`; + return `${color.green(S_CHECKBOX_SELECTED)} ${label} ${option.hint ? color.dim(`(${option.hint})`) : '' + }`; } else if (state === 'submitted') { return `${color.dim(label)}`; } @@ -347,13 +351,16 @@ export const multiselect = (opts: MultiSelectOptions) => { cursorAt: opts.cursorAt, validate(selected: Value[]) { if (this.required && selected.length === 0) - return `Please select at least one option.\n${color.reset( - color.dim( - `Press ${color.gray(color.bgWhite(color.inverse(' space ')))} to select, ${color.gray( - color.bgWhite(color.inverse(' enter ')) - )} to submit` - ) - )}`; + return { + status: 'error', + message: `Please select at least one option.\n${color.reset( + color.dim( + `Press ${color.gray(color.bgWhite(color.inverse(' space ')))} to select, ${color.gray( + color.bgWhite(color.inverse(' enter ')) + )} to submit` + ) + )}` + }; }, render() { let title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`; @@ -371,39 +378,37 @@ export const multiselect = (opts: MultiSelectOptions) => { switch (this.state) { case 'submit': { - return `${title}${color.gray(S_BAR)} ${ - this.options - .filter(({ value }) => this.value.includes(value)) - .map((option) => opt(option, 'submitted')) - .join(color.dim(', ')) || color.dim('none') - }`; + return `${title}${color.gray(S_BAR)} ${this.options + .filter(({ value }) => this.value.includes(value)) + .map((option) => opt(option, 'submitted')) + .join(color.dim(', ')) || color.dim('none') + }`; } case 'cancel': { const label = this.options .filter(({ value }) => this.value.includes(value)) .map((option) => opt(option, 'cancelled')) .join(color.dim(', ')); - return `${title}${color.gray(S_BAR)} ${ - label.trim() ? `${label}\n${color.gray(S_BAR)}` : '' - }`; + return `${title}${color.gray(S_BAR)} ${label.trim() ? `${label}\n${color.gray(S_BAR)}` : '' + }`; } case 'error': { const footer = this.error .split('\n') .map((ln, i) => - i === 0 ? `${color.yellow(S_BAR_END)} ${color.yellow(ln)}` : ` ${ln}` + i === 0 ? `${color.red(S_BAR_END)} ${color.red(ln)}` : ` ${ln}` ) .join('\n'); return ( title + - color.yellow(S_BAR) + + color.red(S_BAR) + ' ' + limitOptions({ options: this.options, cursor: this.cursor, maxItems: opts.maxItems, style: styleOption, - }).join(`\n${color.yellow(S_BAR)} `) + + }).join(`\n${color.red(S_BAR)} `) + '\n' + footer + '\n' @@ -450,9 +455,8 @@ export const groupMultiselect = (opts: GroupMultiSelectOptions) => const prefix = isItem ? `${isLast ? S_BAR_END : S_BAR} ` : ''; if (state === 'active') { - return `${color.dim(prefix)}${color.cyan(S_CHECKBOX_ACTIVE)} ${label} ${ - option.hint ? color.dim(`(${option.hint})`) : '' - }`; + return `${color.dim(prefix)}${color.cyan(S_CHECKBOX_ACTIVE)} ${label} ${option.hint ? color.dim(`(${option.hint})`) : '' + }`; } else if (state === 'group-active') { return `${prefix}${color.cyan(S_CHECKBOX_ACTIVE)} ${color.dim(label)}`; } else if (state === 'group-active-selected') { @@ -462,9 +466,8 @@ export const groupMultiselect = (opts: GroupMultiSelectOptions) => } else if (state === 'cancelled') { return `${color.strikethrough(color.dim(label))}`; } else if (state === 'active-selected') { - return `${color.dim(prefix)}${color.green(S_CHECKBOX_SELECTED)} ${label} ${ - option.hint ? color.dim(`(${option.hint})`) : '' - }`; + return `${color.dim(prefix)}${color.green(S_CHECKBOX_SELECTED)} ${label} ${option.hint ? color.dim(`(${option.hint})`) : '' + }`; } else if (state === 'submitted') { return `${color.dim(label)}`; } @@ -478,13 +481,16 @@ export const groupMultiselect = (opts: GroupMultiSelectOptions) => cursorAt: opts.cursorAt, validate(selected: Value[]) { if (this.required && selected.length === 0) - return `Please select at least one option.\n${color.reset( - color.dim( - `Press ${color.gray(color.bgWhite(color.inverse(' space ')))} to select, ${color.gray( - color.bgWhite(color.inverse(' enter ')) - )} to submit` - ) - )}`; + return { + status: 'error', + message: `Please select at least one option.\n${color.reset( + color.dim( + `Press ${color.gray(color.bgWhite(color.inverse(' space ')))} to select, ${color.gray( + color.bgWhite(color.inverse(' enter ')) + )} to submit` + ) + )}` + }; }, render() { let title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`; @@ -501,18 +507,17 @@ export const groupMultiselect = (opts: GroupMultiSelectOptions) => .filter(({ value }) => this.value.includes(value)) .map((option) => opt(option, 'cancelled')) .join(color.dim(', ')); - return `${title}${color.gray(S_BAR)} ${ - label.trim() ? `${label}\n${color.gray(S_BAR)}` : '' - }`; + return `${title}${color.gray(S_BAR)} ${label.trim() ? `${label}\n${color.gray(S_BAR)}` : '' + }`; } case 'error': { const footer = this.error .split('\n') .map((ln, i) => - i === 0 ? `${color.yellow(S_BAR_END)} ${color.yellow(ln)}` : ` ${ln}` + i === 0 ? `${color.red(S_BAR_END)} ${color.red(ln)}` : ` ${ln}` ) .join('\n'); - return `${title}${color.yellow(S_BAR)} ${this.options + return `${title}${color.red(S_BAR)} ${this.options .map((option, i, options) => { const selected = this.value.includes(option.value) || @@ -533,7 +538,7 @@ export const groupMultiselect = (opts: GroupMultiSelectOptions) => } return opt(option, active ? 'active' : 'inactive', options); }) - .join(`\n${color.yellow(S_BAR)} `)}\n${footer}\n`; + .join(`\n${color.red(S_BAR)} `)}\n${footer}\n`; } default: { return `${title}${color.cyan(S_BAR)} ${this.options @@ -699,8 +704,8 @@ export const spinner = () => { code === 0 ? color.green(S_STEP_SUBMIT) : code === 1 - ? color.red(S_STEP_CANCEL) - : color.red(S_STEP_ERROR); + ? color.red(S_STEP_CANCEL) + : color.red(S_STEP_ERROR); process.stdout.write(cursor.move(-999, 0)); process.stdout.write(erase.down(1)); process.stdout.write(`${step} ${_message}\n`);