From e91d960ace4f14b65280795315ad923c20511d2e Mon Sep 17 00:00:00 2001 From: Simon Boudrias Date: Sat, 3 Jun 2023 16:39:13 -0400 Subject: [PATCH] Feat (Core): Prompt functions are now cancelable. Fix #816 --- packages/core/README.md | 4 ++- packages/core/core.test.mts | 42 +++++++++++++++---------- packages/core/src/index.mts | 62 ++++++++++++++++++++++--------------- packages/prompts/README.md | 31 +++++++++++++++++++ packages/type/src/index.mts | 9 +++++- 5 files changed, 105 insertions(+), 43 deletions(-) diff --git a/packages/core/README.md b/packages/core/README.md index e9dfdaba9..ab03fa2a3 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -75,7 +75,7 @@ See more examples: ### `createPrompt(viewFn)` -The `createPrompt` function returns an asynchronous function that returns a promise resolving to the valid answer a user submit. This prompt function takes the prompt configuration as its first argument (this is defined by each prompt), and the context options as a second argument. +The `createPrompt` function returns an asynchronous function that returns a cancelable promise resolving to the valid answer a user submit. This prompt function takes the prompt configuration as its first argument (this is defined by each prompt), and the context options as a second argument. The prompt configuration is unique to each prompt. The context options are: @@ -85,6 +85,8 @@ The prompt configuration is unique to each prompt. The context options are: | output | `NodeJS.WritableStream` | no | The stdout stream (defaults to `process.stdout`) | | clearPromptOnDone | `boolean` | no | If true, we'll clear the screen after the prompt is answered | +The cancelable promise exposes a `cancel` method that'll exit the prompt and reject the promise. + #### Typescript If using typescript, `createPrompt` takes 2 generic arguments (ex `createPrompt()`) diff --git a/packages/core/core.test.mts b/packages/core/core.test.mts index b3e20cad4..591a1398a 100644 --- a/packages/core/core.test.mts +++ b/packages/core/core.test.mts @@ -17,13 +17,7 @@ import ansiEscapes from 'ansi-escapes'; describe('createPrompt()', () => { it('handle async function message', async () => { - const viewFunction = vi.fn((config, done) => { - useEffect(() => { - done(); - }, []); - - return ''; - }); + const viewFunction = vi.fn(() => ''); const prompt = createPrompt(viewFunction); const promise = Promise.resolve('Async message:'); const renderingDone = render(prompt, { message: () => promise }); @@ -37,17 +31,12 @@ describe('createPrompt()', () => { expect.any(Function) ); - await answer.catch(() => {}); + answer.cancel(); + await expect(answer).rejects.toBeInstanceOf(Error); }); it('handle deferred message', async () => { - const viewFunction = vi.fn((config, done) => { - useEffect(() => { - done(); - }, []); - - return ''; - }); + const viewFunction = vi.fn(() => ''); const prompt = createPrompt(viewFunction); const promise = Promise.resolve('Async message:'); const renderingDone = render(prompt, { message: promise }); @@ -61,7 +50,8 @@ describe('createPrompt()', () => { expect.any(Function) ); - await answer.catch(() => {}); + answer.cancel(); + await expect(answer).rejects.toBeInstanceOf(Error); }); it('onKeypress: allow to implement custom behavior on keypress', async () => { @@ -204,6 +194,26 @@ describe('createPrompt()', () => { await expect(answer).resolves.toEqual('done'); }); + + it('allow cancelling the prompt', async () => { + const Prompt = (config: { message: string }, done: (value: string) => void) => { + useKeypress((key: KeypressEvent) => { + if (isEnterKey(key)) { + done('done'); + } + }); + + return config.message; + }; + + const prompt = createPrompt(Prompt); + const { answer, events } = await render(prompt, { message: 'Question' }); + + answer.cancel(); + events.keypress('enter'); + + await expect(answer).rejects.toMatchInlineSnapshot('[Error: Prompt was canceled]'); + }); }); describe('Error handling', () => { diff --git a/packages/core/src/index.mts b/packages/core/src/index.mts index d1f55359b..97151ab27 100644 --- a/packages/core/src/index.mts +++ b/packages/core/src/index.mts @@ -1,5 +1,5 @@ import readline from 'node:readline'; -import type { Prompt } from '@inquirer/type'; +import { CancelablePromise, type Prompt } from '@inquirer/type'; import MuteStream from 'mute-stream'; import ScreenManager from './lib/screen-manager.mjs'; import { getPromptConfig } from './lib/options.mjs'; @@ -133,7 +133,7 @@ export function createPrompt( done: (value: Value) => void ) => string | [string, string | undefined] ) { - const prompt: Prompt = async (config, context) => { + const prompt: Prompt = (config, context) => { // Set our state before starting the prompt. resetHookState(); @@ -151,11 +151,18 @@ export function createPrompt( }) as InquirerReadline; const screen = new ScreenManager(sessionRl); - // TODO: we should display a loader while we get the default options. - const resolvedConfig = await getPromptConfig(config); - - return new Promise((resolve, reject) => { + let cancel: () => void = () => {}; + const answer = new CancelablePromise((resolve, reject) => { const onExit = () => { + try { + let len = hooksCleanup.length; + while (len--) { + cleanupHook(len); + } + } catch (err) { + reject(err); + } + if (context?.clearPromptOnDone) { screen.clean(); } else { @@ -166,6 +173,12 @@ export function createPrompt( process.removeListener('SIGINT', onForceExit); }; + cancel = () => { + onExit(); + + reject(new Error('Prompt was canceled')); + }; + let shouldHandleExit = true; const onForceExit = () => { if (shouldHandleExit) { @@ -181,15 +194,6 @@ export function createPrompt( const done = (value: Value) => { // Delay execution to let time to the hookCleanup functions to registers. setImmediate(() => { - try { - let len = hooksCleanup.length; - while (len--) { - cleanupHook(len); - } - } catch (err) { - reject(err); - } - onExit(); // Finally we resolve our promise @@ -197,23 +201,31 @@ export function createPrompt( }); }; - const workLoop = () => { + const workLoop = (resolvedConfig: Config & ResolvedPromptConfig) => { index = 0; hooksEffect.length = 0; - handleChange = () => workLoop(); + handleChange = () => workLoop(resolvedConfig); - const nextView = view(resolvedConfig, done); - for (const effect of hooksEffect) { - effect(); - } + try { + const nextView = view(resolvedConfig, done); + for (const effect of hooksEffect) { + effect(); + } - const [content, bottomContent] = - typeof nextView === 'string' ? [nextView] : nextView; - screen.render(content, bottomContent); + const [content, bottomContent] = + typeof nextView === 'string' ? [nextView] : nextView; + screen.render(content, bottomContent); + } catch (err) { + reject(err); + } }; - workLoop(); + // TODO: we should display a loader while we get the default options. + getPromptConfig(config).then(workLoop, reject); }); + + answer.cancel = cancel; + return answer; }; return prompt; diff --git a/packages/prompts/README.md b/packages/prompts/README.md index f7924d138..0085b13f7 100644 --- a/packages/prompts/README.md +++ b/packages/prompts/README.md @@ -151,6 +151,20 @@ const allowEmail = await confirm( ); ``` +## Canceling prompt + +All prompt functions are returning a cancelable promise. This special promise type has a `cancel` method that'll cancel and cleanup the prompt. + +On calling `cancel`, the answer promise will become rejected. + +```js +import { confirm } from '@inquirer/prompts'; + +const answer = confirm(...); // note: for this you cannot use `await` + +answer.cancel(); +``` + # Recipes ## Get answers in an object @@ -183,6 +197,23 @@ if (allowEmail) { } ``` +## Get default value after timeout + +```js +import { input } from '@inquirer/prompts'; + +const answer = input(...); + +const defaultValue = new Promise(resolve => { + setTimeout(() => { + resolve(...); + answer.cancel(); + }, 5000); +}); + +const answer = await Promise.race([defaultValue, answer]) +``` + # Community prompts If you created a cool prompt, [send us a PR adding it](https://github.com/SBoudrias/Inquirer.js/edit/master/README.md) to the list below! diff --git a/packages/type/src/index.mts b/packages/type/src/index.mts index 1d54df345..e8c2a4d49 100644 --- a/packages/type/src/index.mts +++ b/packages/type/src/index.mts @@ -1,7 +1,14 @@ +export class CancelablePromise extends Promise { + public cancel: () => void = () => {}; +} + export type Context = { input?: NodeJS.ReadableStream; output?: NodeJS.WritableStream; clearPromptOnDone?: boolean; }; -export type Prompt = (config: Config, context?: Context) => Promise; +export type Prompt = ( + config: Config, + context?: Context +) => CancelablePromise;