Skip to content

Commit ae636dd

Browse files
authored
feat: add async validation (#583)
1 parent 37fca4e commit ae636dd

9 files changed

Lines changed: 240 additions & 10 deletions

File tree

‎.changeset/silly-bears-share.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@clack/prompts": minor
3+
"@clack/core": minor
4+
---
5+
6+
Add async validation support to prompts, and validation state rendering to text prompts.

‎packages/core/src/prompts/prompt.ts‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,11 @@ export default class Prompt<TValue> {
224224
this._setUserInput('');
225225
}
226226

227-
private onKeypress(char: string | undefined, key: Key) {
227+
private async onKeypress(char: string | undefined, key: Key) {
228+
if (this.state === 'validating') {
229+
return;
230+
}
231+
228232
if (this._track && key.name !== 'return') {
229233
if (key.name && this._isActionKey(char, key)) {
230234
this.rl?.write(null, { ctrl: true, name: 'h' });
@@ -253,7 +257,15 @@ export default class Prompt<TValue> {
253257

254258
if (key?.name === 'return' && this._shouldSubmit(char, key)) {
255259
if (this.opts.validate) {
256-
const problem = runValidation(this.opts.validate, this.value);
260+
const problemResult = runValidation(this.opts.validate, this.value);
261+
let problem: string | Error | undefined;
262+
if (problemResult instanceof Promise) {
263+
this.state = 'validating';
264+
this.render();
265+
problem = await problemResult;
266+
} else {
267+
problem = problemResult;
268+
}
257269
if (problem) {
258270
this.error = problem instanceof Error ? problem.message : problem;
259271
this.state = 'error';

‎packages/core/src/types.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { Action } from './utils/settings.js';
44
/**
55
* The state of the prompt
66
*/
7-
export type ClackState = 'initial' | 'active' | 'cancel' | 'submit' | 'error';
7+
export type ClackState = 'initial' | 'active' | 'cancel' | 'submit' | 'error' | 'validating';
88

99
/**
1010
* Typed event emitter for clack
@@ -15,6 +15,7 @@ export interface ClackEvents<TValue> {
1515
cancel: (value?: any) => void;
1616
submit: (value?: any) => void;
1717
error: (value?: any) => void;
18+
validating: (value?: any) => void;
1819
cursor: (key?: Action) => void;
1920
key: (key: string | undefined, info: Key) => void;
2021
value: (value?: TValue) => void;

‎packages/core/src/utils/validation.ts‎

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import type { StandardSchemaV1 } from './standard-schema.js';
22

3+
type MaybePromise<T> = T | Promise<T>;
4+
35
/**
46
* A function or [Standard Schema](https://github.com/standard-schema/standard-schema)
57
* 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';
3335
* ```
3436
*/
3537
export type Validate<TValue> =
36-
| ((value: TValue | undefined) => string | Error | undefined)
38+
| ((value: TValue | undefined) => MaybePromise<string | Error | undefined>)
3739
| StandardSchemaV1<TValue | undefined, unknown>;
3840

3941
/**
@@ -45,15 +47,11 @@ export type Validate<TValue> =
4547
export function runValidation<TValue>(
4648
validate: Validate<TValue>,
4749
value: TValue | undefined
48-
): string | Error | undefined {
50+
): MaybePromise<string | Error | undefined> {
4951
if ('~standard' in validate) {
5052
const result = validate['~standard'].validate(value);
51-
// https://standardschema.dev/schema#how-to-only-allow-synchronous-validation
52-
// TODO: https://github.com/bombshell-dev/clack/issues/92
5353
if (result instanceof Promise) {
54-
throw new TypeError(
55-
'Schema validation must be synchronous. Update `validate()` and remove any asynchronous logic.'
56-
);
54+
return result.then((res) => res.issues?.at(0)?.message);
5755
}
5856
return result.issues?.at(0)?.message;
5957
}

‎packages/core/test/prompts/prompt.test.ts‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { isCancel, settings } from '../../src/utils/index.js';
66
import { MockReadable } from '../mock-readable.js';
77
import { MockWritable } from '../mock-writable.js';
88

9+
const waitForTick = () => new Promise((resolve) => setTimeout(resolve, 0));
10+
911
describe('Prompt', () => {
1012
let input: MockReadable;
1113
let output: MockWritable;
@@ -396,4 +398,79 @@ describe('Prompt', () => {
396398
expect(instance.error).to.equal('must be "valid" (was "invalid")');
397399
});
398400
});
401+
402+
test('validates value with async validation', async () => {
403+
const instance = new Prompt<string>({
404+
input,
405+
output,
406+
render: () => 'foo',
407+
validate: async (value) => {
408+
await waitForTick();
409+
return value === 'valid' ? undefined : 'Invalid value';
410+
},
411+
});
412+
instance.prompt();
413+
414+
instance.value = 'invalid';
415+
input.emit('keypress', '', { name: 'return' });
416+
417+
expect(instance.state).to.equal('validating');
418+
await waitForTick(); // Wait for the validation to complete
419+
expect(instance.state).to.equal('error');
420+
expect(instance.error).to.equal('Invalid value');
421+
});
422+
423+
test('accepts valid value with async validation', async () => {
424+
const instance = new Prompt<string>({
425+
input,
426+
output,
427+
render: () => 'foo',
428+
validate: async (value) => {
429+
await waitForTick();
430+
return value === 'valid' ? undefined : 'Invalid value';
431+
},
432+
});
433+
instance.prompt();
434+
435+
instance.value = 'valid';
436+
input.emit('keypress', '', { name: 'return' });
437+
438+
expect(instance.state).to.equal('validating');
439+
await waitForTick(); // Wait for the validation to complete
440+
expect(instance.state).to.equal('submit');
441+
expect(instance.error).to.equal('');
442+
});
443+
444+
test('ignores keypresses while async validation is pending', async () => {
445+
const eventSpy = vi.fn();
446+
let resolveValidation: (problem: string | undefined) => void;
447+
const validation = new Promise<string | undefined>((resolve) => {
448+
resolveValidation = resolve;
449+
});
450+
const instance = new Prompt<string>({
451+
input,
452+
output,
453+
render: () => 'foo',
454+
validate: () => validation,
455+
});
456+
const resultPromise = instance.prompt();
457+
458+
instance.value = 'valid';
459+
input.emit('keypress', '', { name: 'return' });
460+
461+
expect(instance.state).to.equal('validating');
462+
463+
instance.on('key', eventSpy);
464+
input.emit('keypress', 'z', { name: 'z' });
465+
input.emit('keypress', '\x03', { name: 'c' });
466+
467+
expect(eventSpy).not.toHaveBeenCalled();
468+
expect(instance.state).to.equal('validating');
469+
470+
resolveValidation!(undefined);
471+
await resultPromise;
472+
473+
expect(instance.state).to.equal('submit');
474+
expect(instance.error).to.equal('');
475+
});
399476
});

‎packages/prompts/src/common.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ export const symbol = (state: State) => {
5050
return styleText('yellow', S_STEP_ERROR);
5151
case 'submit':
5252
return styleText('green', S_STEP_SUBMIT);
53+
case 'validating':
54+
return styleText('dim', S_STEP_ACTIVE);
5355
}
5456
};
5557

‎packages/prompts/src/text.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,13 @@ export const text = (opts: TextOptions) => {
7777
const value = this.value ?? '';
7878

7979
switch (this.state) {
80+
case 'validating': {
81+
const validatePrefix = hasGuide ? `${styleText('cyan', S_BAR)} ` : '';
82+
const validatePrefixEnd = hasGuide ? styleText('cyan', S_BAR_END) : '';
83+
const userInputText = styleText('dim', userInput);
84+
const validateText = styleText('dim', 'Validating...');
85+
return `${title}${validatePrefix}${userInputText}\n${validatePrefixEnd} ${validateText}\n`;
86+
}
8087
case 'error': {
8188
const errorText = this.error ? ` ${styleText('yellow', this.error)}` : '';
8289
const errorPrefix = hasGuide ? `${styleText('yellow', S_BAR)} ` : '';

‎packages/prompts/test/__snapshots__/text.test.ts.snap‎

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,58 @@ exports[`text (isCI = false) > defaultValue sets the value but does not render 1
5252
]
5353
`;
5454
55+
exports[`text (isCI = false) > displays a validating message with async validation 1`] = `
56+
[
57+
"<cursor.hide>",
58+
"│
59+
◆ foo
60+
│ _
61+
└
62+
",
63+
"<cursor.backward count=999><cursor.up count=4>",
64+
"<cursor.down count=2>",
65+
"<erase.line><cursor.left count=1>",
66+
"│ x█",
67+
"<cursor.down count=2>",
68+
"<cursor.backward count=999><cursor.up count=4>",
69+
"<cursor.down count=1>",
70+
"<erase.down>",
71+
"◆ foo
72+
│ x█
73+
└ Validating...
74+
",
75+
"<cursor.backward count=999><cursor.up count=4>",
76+
"<cursor.down count=1>",
77+
"<erase.down>",
78+
"▲ foo
79+
│ x█
80+
└ should be xy
81+
",
82+
"<cursor.backward count=999><cursor.up count=4>",
83+
"<cursor.down count=1>",
84+
"<erase.down>",
85+
"◆ foo
86+
│ xy█
87+
└
88+
",
89+
"<cursor.backward count=999><cursor.up count=4>",
90+
"<cursor.down count=1>",
91+
"<erase.down>",
92+
"◆ foo
93+
│ xy█
94+
└ Validating...
95+
",
96+
"<cursor.backward count=999><cursor.up count=4>",
97+
"<cursor.down count=1>",
98+
"<erase.down>",
99+
"◇ foo
100+
│ xy",
101+
"
102+
",
103+
"<cursor.show>",
104+
]
105+
`;
106+
55107
exports[`text (isCI = false) > empty string when no value and no default 1`] = `
56108
[
57109
"<cursor.hide>",
@@ -349,6 +401,58 @@ exports[`text (isCI = true) > defaultValue sets the value but does not render 1`
349401
]
350402
`;
351403
404+
exports[`text (isCI = true) > displays a validating message with async validation 1`] = `
405+
[
406+
"<cursor.hide>",
407+
"│
408+
◆ foo
409+
│ _
410+
└
411+
",
412+
"<cursor.backward count=999><cursor.up count=4>",
413+
"<cursor.down count=2>",
414+
"<erase.line><cursor.left count=1>",
415+
"│ x█",
416+
"<cursor.down count=2>",
417+
"<cursor.backward count=999><cursor.up count=4>",
418+
"<cursor.down count=1>",
419+
"<erase.down>",
420+
"◆ foo
421+
│ x█
422+
└ Validating...
423+
",
424+
"<cursor.backward count=999><cursor.up count=4>",
425+
"<cursor.down count=1>",
426+
"<erase.down>",
427+
"▲ foo
428+
│ x█
429+
└ should be xy
430+
",
431+
"<cursor.backward count=999><cursor.up count=4>",
432+
"<cursor.down count=1>",
433+
"<erase.down>",
434+
"◆ foo
435+
│ xy█
436+
└
437+
",
438+
"<cursor.backward count=999><cursor.up count=4>",
439+
"<cursor.down count=1>",
440+
"<erase.down>",
441+
"◆ foo
442+
│ xy█
443+
└ Validating...
444+
",
445+
"<cursor.backward count=999><cursor.up count=4>",
446+
"<cursor.down count=1>",
447+
"<erase.down>",
448+
"◇ foo
449+
│ xy",
450+
"
451+
",
452+
"<cursor.show>",
453+
]
454+
`;
455+
352456
exports[`text (isCI = true) > empty string when no value and no default 1`] = `
353457
[
354458
"<cursor.hide>",

‎packages/prompts/test/text.test.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,4 +238,27 @@ describe.each(['true', 'false'])('text (isCI = %s)', (isCI) => {
238238

239239
expect(output.buffer).toMatchSnapshot();
240240
});
241+
242+
test('displays a validating message with async validation', async () => {
243+
const result = prompts.text({
244+
message: 'foo',
245+
validate: async (val) => {
246+
await new Promise((resolve) => setTimeout(resolve, 10));
247+
return val !== 'xy' ? 'should be xy' : undefined;
248+
},
249+
input,
250+
output,
251+
});
252+
253+
input.emit('keypress', 'x', { name: 'x' });
254+
input.emit('keypress', '', { name: 'return' });
255+
await new Promise((resolve) => setTimeout(resolve, 20));
256+
input.emit('keypress', 'y', { name: 'y' });
257+
input.emit('keypress', '', { name: 'return' });
258+
259+
const value = await result;
260+
261+
expect(value).toBe('xy');
262+
expect(output.buffer).toMatchSnapshot();
263+
});
241264
});

0 commit comments

Comments
 (0)