Skip to content

Commit

Permalink
Feat (Core): Prompt functions are now cancelable. Fix #816
Browse files Browse the repository at this point in the history
  • Loading branch information
SBoudrias committed Jun 3, 2023
1 parent 5eaf72e commit e91d960
Show file tree
Hide file tree
Showing 5 changed files with 105 additions and 43 deletions.
4 changes: 3 additions & 1 deletion packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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<string, { message: string }>()`)
Expand Down
42 changes: 26 additions & 16 deletions packages/core/core.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand All @@ -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 });
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
62 changes: 37 additions & 25 deletions packages/core/src/index.mts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -133,7 +133,7 @@ export function createPrompt<Value, Config extends AsyncPromptConfig>(
done: (value: Value) => void
) => string | [string, string | undefined]
) {
const prompt: Prompt<Value, Config> = async (config, context) => {
const prompt: Prompt<Value, Config> = (config, context) => {
// Set our state before starting the prompt.
resetHookState();

Expand All @@ -151,11 +151,18 @@ export function createPrompt<Value, Config extends AsyncPromptConfig>(
}) 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<Value>((resolve, reject) => {
const onExit = () => {
try {
let len = hooksCleanup.length;
while (len--) {
cleanupHook(len);
}
} catch (err) {
reject(err);
}

if (context?.clearPromptOnDone) {
screen.clean();
} else {
Expand All @@ -166,6 +173,12 @@ export function createPrompt<Value, Config extends AsyncPromptConfig>(
process.removeListener('SIGINT', onForceExit);
};

cancel = () => {
onExit();

reject(new Error('Prompt was canceled'));
};

let shouldHandleExit = true;
const onForceExit = () => {
if (shouldHandleExit) {
Expand All @@ -181,39 +194,38 @@ export function createPrompt<Value, Config extends AsyncPromptConfig>(
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
resolve(value);
});
};

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;
Expand Down
31 changes: 31 additions & 0 deletions packages/prompts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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!
Expand Down
9 changes: 8 additions & 1 deletion packages/type/src/index.mts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
export class CancelablePromise<T> extends Promise<T> {
public cancel: () => void = () => {};
}

export type Context = {
input?: NodeJS.ReadableStream;
output?: NodeJS.WritableStream;
clearPromptOnDone?: boolean;
};

export type Prompt<Value, Config> = (config: Config, context?: Context) => Promise<Value>;
export type Prompt<Value, Config> = (
config: Config,
context?: Context
) => CancelablePromise<Value>;

0 comments on commit e91d960

Please sign in to comment.