From 5239eda426115693398740fc900debf7d86fcd05 Mon Sep 17 00:00:00 2001 From: PierreDemailly <39910767+PierreDemailly@users.noreply.github.com> Date: Sun, 12 Jan 2025 01:53:07 +0100 Subject: [PATCH] feat: add skip option (#128) --- README.md | 30 ++++++++++++------- eslint.config.mjs | 10 ++++++- package.json | 4 +-- src/prompts/abstract.ts | 6 +++- src/prompts/confirm.ts | 10 +++++-- src/prompts/multiselect.ts | 49 +++++++++++++++++++++----------- src/prompts/question.ts | 8 +++++- src/prompts/select.ts | 9 +++++- test/confirm-prompt.test.ts | 18 ++++++++++++ test/helpers/mock-process.ts | 4 +-- test/multi-select-prompt.test.ts | 25 ++++++++++++++++ test/question-prompt.test.ts | 17 +++++++++++ test/select-prompt.test.ts | 11 +++++++ 13 files changed, 165 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 1d5d1fe..d5a5fdd 100644 --- a/README.md +++ b/README.md @@ -53,12 +53,16 @@ question(message: string, options?: PromptOptions): Promise Simple prompt, similar to `rl.question()` with an improved UI. +Use `options.defaultValue` to set a default value. + Use `options.secure` if you need to hide both input and answer. Use `options.signal` to set an `AbortSignal` (throws a [AbortError](#aborterror)). Use `options.validators` to handle user input. +Use `options.skip` to skip prompt. It will return `options.defaultValue` if given, `""` otherwise. + **Example** ```js @@ -95,35 +99,39 @@ select(message: string, options: SelectOptions): Promise Scrollable select depending `maxVisible` (default `8`). -Use `ignoreValues` to skip result render & clear lines after a selected one. +Use `options.ignoreValues` to skip result render & clear lines after a selected one. -Use `validators` to handle user input. +Use `options.validators` to handle user input. -Use `autocomplete` to allow filtered choices. This can be useful for a large list of choices. +Use `options.autocomplete` to allow filtered choices. This can be useful for a large list of choices. -Use `caseSensitive` to make autocomplete filters case sensitive. Default `false` +Use `options.caseSensitive` to make autocomplete filters case sensitive. Default `false` Use `options.signal` to set an `AbortSignal` (throws a [AbortError](#aborterror)). +Use `options.skip` to skip prompt. It will return the first choice. + ### `multiselect()` ```ts multiselect(message: string, options: MultiselectOptions): Promise<[string]> ``` -Scrollable multiselect depending `maxVisible` (default `8`).
-Use `preSelectedChoices` to pre-select choices. +Scrollable multiselect depending `options.maxVisible` (default `8`).
+Use `options.preSelectedChoices` to pre-select choices. -Use `validators` to handle user input. +Use `options.validators` to handle user input. -Use `showHint: false` to disable hint (this option is truthy by default). +Use `options.showHint: false` to disable hint (this option is truthy by default). -Use `autocomplete` to allow filtered choices. This can be useful for a large list of choices. +Use `options.autocomplete` to allow filtered choices. This can be useful for a large list of choices. -Use `caseSensitive` to make autocomplete filters case sensitive. Default `false`. +Use `options.caseSensitive` to make autocomplete filters case sensitive. Default `false`. Use `options.signal` to set an `AbortSignal` (throws a [AbortError](#aborterror)). +Use `options.skip` to skip prompt. It will return `options.preSelectedChoices` if given, `[]` otherwise. + ### `confirm()` ```ts @@ -137,6 +145,8 @@ Boolean prompt, default to `options.initial` (`false`). Use `options.signal` to set an `AbortSignal` (throws a [AbortError](#aborterror)). +Use `options.skip` to skip prompt. It will return `options.initial` (`false` by default) + ### `PromptAgent` The `PromptAgent` class allows to programmatically set the next answers for any prompt function, this can be useful for testing. diff --git a/eslint.config.mjs b/eslint.config.mjs index 8a1e9d0..62e4fa4 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,3 +1,11 @@ import { typescriptConfig } from "@openally/config.eslint"; -export default typescriptConfig(); +export default typescriptConfig([{ + files: ["demo.js"], + rules: { + // Since we use TS linter, it mark `console` as undefined + "no-undef": "off" + } +}, { + ignores: [".temp/**"], +}]); diff --git a/package.json b/package.json index 91a6887..f6d369d 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "prepublishOnly": "npm run build", "test": "glob -c \"tsx --no-warnings=ExperimentalWarning --loader=esmock --test\" \"./test/**/*.test.ts\"", "coverage": "c8 -r html npm run test", - "lint": "eslint .", + "lint": "eslint src test", "lint:fix": "eslint . --fix" }, "main": "./dist/index.js", @@ -31,7 +31,7 @@ "license": "ISC", "type": "module", "devDependencies": { - "@openally/config.eslint": "^1.1.0", + "@openally/config.eslint": "^1.3.0", "@openally/config.typescript": "^1.0.3", "@types/node": "^22.10.5", "c8": "^10.1.3", diff --git a/src/prompts/abstract.ts b/src/prompts/abstract.ts index 84f85e9..d1a5f9b 100644 --- a/src/prompts/abstract.ts +++ b/src/prompts/abstract.ts @@ -21,6 +21,7 @@ export interface AbstractPromptOptions { stdin?: Stdin; stdout?: Stdout; message: string; + skip?: boolean; signal?: AbortSignal; } @@ -29,6 +30,7 @@ export class AbstractPrompt extends EventEmitter { stdout: Stdout; message: string; signal?: AbortSignal; + skip: boolean; history: string[]; agent: PromptAgent; mute: boolean; @@ -46,7 +48,8 @@ export class AbstractPrompt extends EventEmitter { stdin: input = process.stdin, stdout: output = process.stdout, message, - signal + signal, + skip = false } = options; if (typeof message !== "string") { @@ -65,6 +68,7 @@ export class AbstractPrompt extends EventEmitter { this.stdout = output; this.message = message; this.signal = signal; + this.skip = skip; this.history = []; this.agent = PromptAgent.agent(); this.mute = false; diff --git a/src/prompts/confirm.ts b/src/prompts/confirm.ts index 015af36..0d9fff8 100644 --- a/src/prompts/confirm.ts +++ b/src/prompts/confirm.ts @@ -73,7 +73,7 @@ export class ConfirmPrompt extends AbstractPrompt { }); } - #onKeypress(resolve: (value: unknown) => void, value: any, key: Key) { + #onKeypress(resolve: (value: unknown) => void, _value: any, key: Key) { this.stdout.moveCursor( -this.stdout.columns, -Math.floor(wcwidth(stripAnsi(this.#getQuestionQuery())) / this.stdout.columns) @@ -126,7 +126,13 @@ export class ConfirmPrompt extends AbstractPrompt { this.write(`${this.selectedValue ? SYMBOLS.Tick : SYMBOLS.Cross} ${styleText("bold", this.message)}${EOL}`); } - confirm(): Promise { + async confirm(): Promise { + if (this.skip) { + this.destroy(); + + return this.initial; + } + // eslint-disable-next-line no-async-promise-executor return new Promise(async(resolve, reject) => { const answer = this.agent.nextAnswers.shift(); diff --git a/src/prompts/multiselect.ts b/src/prompts/multiselect.ts index dcacf36..365800c 100644 --- a/src/prompts/multiselect.ts +++ b/src/prompts/multiselect.ts @@ -89,7 +89,7 @@ export class MultiselectPrompt extends AbstractPrompt { constructor(options: MultiselectOptions) { const { choices, - preSelectedChoices, + preSelectedChoices = [], validators = [], showHint = true, ...baseOptions @@ -120,10 +120,6 @@ export class MultiselectPrompt extends AbstractPrompt { } } - if (!preSelectedChoices) { - return; - } - for (const choice of preSelectedChoices) { const choiceIndex = this.filteredChoices.findIndex((item) => { if (typeof item === "string") { @@ -206,6 +202,29 @@ export class MultiselectPrompt extends AbstractPrompt { this.write(`${prefix}${choices ? ` ${formattedChoice}` : ""}${EOL}`); } + #selectedChoices() { + return [...this.selectedIndexes].reduce( + (acc, index) => { + const choice = this.filteredChoices[index]; + + if (typeof choice === "string") { + acc.values.push(choice); + acc.labels.push(choice); + } + else { + acc.values.push(choice.value); + acc.labels.push(choice.label); + } + + return acc; + }, + { + values: [] as string[], + labels: [] as string[] + } + ); + } + #onProcessExit() { this.stdin.off("keypress", this.#boundKeyPressEvent); this.stdout.moveCursor(-this.stdout.columns, 0); @@ -238,16 +257,7 @@ export class MultiselectPrompt extends AbstractPrompt { render(); } else if (key.name === "return") { - const labels = [...this.selectedIndexes].map((index) => { - const choice = this.filteredChoices[index]; - - return typeof choice === "string" ? choice : choice.label; - }); - const values = [...this.selectedIndexes].map((index) => { - const choice = this.filteredChoices[index]; - - return typeof choice === "string" ? choice : choice.value; - }); + const { values, labels } = this.#selectedChoices(); for (const validator of this.#validators) { const validationResult = validator.validate(values); @@ -287,7 +297,14 @@ export class MultiselectPrompt extends AbstractPrompt { } } - multiselect(): Promise { + async multiselect(): Promise { + if (this.skip) { + this.destroy(); + const { values } = this.#selectedChoices(); + + return values; + } + return new Promise((resolve, reject) => { const answer = this.agent.nextAnswers.shift(); if (answer !== undefined) { diff --git a/src/prompts/question.ts b/src/prompts/question.ts index fe22978..0e3305c 100644 --- a/src/prompts/question.ts +++ b/src/prompts/question.ts @@ -100,7 +100,13 @@ export class QuestionPrompt extends AbstractPrompt { this.#writeAnswer(); } - question(): Promise { + async question(): Promise { + if (this.skip) { + this.destroy(); + + return this.defaultValue ?? ""; + } + // eslint-disable-next-line no-async-promise-executor return new Promise(async(resolve, reject) => { this.answer = this.agent.nextAnswers.shift(); diff --git a/src/prompts/select.ts b/src/prompts/select.ts index 12716d8..b6ee0f6 100644 --- a/src/prompts/select.ts +++ b/src/prompts/select.ts @@ -238,7 +238,14 @@ export class SelectPrompt extends AbstractPrompt { } } - select(): Promise { + async select(): Promise { + if (this.skip) { + this.destroy(); + const answer = this.options.choices[0]; + + return typeof answer === "string" ? answer : answer.value; + } + return new Promise((resolve, reject) => { const answer = this.agent.nextAnswers.shift(); if (answer !== undefined) { diff --git a/test/confirm-prompt.test.ts b/test/confirm-prompt.test.ts index d239e1d..3bb59db 100644 --- a/test/confirm-prompt.test.ts +++ b/test/confirm-prompt.test.ts @@ -179,4 +179,22 @@ describe("ConfirmPrompt", () => { "✖ Foo" ]); }); + + it("should return initial value (true) when skipping prompt", async() => { + const input = await confirm("Foo", { + skip: true, + initial: true + }); + + assert.strictEqual(input, true); + }); + + it("should return initial value (false) when skipping prompt", async() => { + const input = await confirm("Foo", { + skip: true, + initial: false + }); + + assert.strictEqual(input, false); + }); }); diff --git a/test/helpers/mock-process.ts b/test/helpers/mock-process.ts index c595e94..b30b825 100644 --- a/test/helpers/mock-process.ts +++ b/test/helpers/mock-process.ts @@ -8,7 +8,7 @@ import { AbstractPromptOptions } from "../../src/prompts/abstract.js"; export function mockProcess(inputs: string[] = [], writeCb: (value: string) => void = () => void 0) { const stdout = { write: (msg: string | Buffer) => { - if (msg instanceof Buffer) { + if (typeof msg === "object") { return; } @@ -22,7 +22,7 @@ export function mockProcess(inputs: string[] = [], writeCb: (value: string) => v clearLine: () => true }; const stdin = { - on: (event, cb) => { + on: (_event, cb) => { for (const input of inputs) { cb(null, input); } diff --git a/test/multi-select-prompt.test.ts b/test/multi-select-prompt.test.ts index 5597a98..324e6cc 100644 --- a/test/multi-select-prompt.test.ts +++ b/test/multi-select-prompt.test.ts @@ -919,4 +919,29 @@ describe("MultiselectPrompt", () => { ]); assert.deepStrictEqual(input, ["foo"]); }); + + it("should return pre-selected choices when skipping prompt", async() => { + const message = "Choose between foo & bar"; + const options = { + choices: ["foo", "bar"], + preSelectedChoices: ["bar"], + skip: true + }; + + const input = await multiselect(message, options); + + assert.deepEqual(input, ["bar"]); + }); + + it("should return '[]' when skipping prompt and no pre-selected choices", async() => { + const message = "Choose between foo & bar"; + const options = { + choices: ["foo", "bar"], + skip: true + }; + + const input = await multiselect(message, options); + + assert.deepEqual(input, []); + }); }); diff --git a/test/question-prompt.test.ts b/test/question-prompt.test.ts index 2fd4f23..691c8fd 100644 --- a/test/question-prompt.test.ts +++ b/test/question-prompt.test.ts @@ -160,4 +160,21 @@ describe("QuestionPrompt", () => { "✔ What's your name? › CONFIDENTIAL" ]); }); + + it("should return '' when skipping prompt", async() => { + const input = await question("What's your name?", { + skip: true + }); + + assert.equal(input, ""); + }); + + it("should return default value when skipping prompt", async() => { + const input = await question("What's your name?", { + defaultValue: "John Doe", + skip: true + }); + + assert.equal(input, "John Doe"); + }); }); diff --git a/test/select-prompt.test.ts b/test/select-prompt.test.ts index b0a2f28..9a03465 100644 --- a/test/select-prompt.test.ts +++ b/test/select-prompt.test.ts @@ -705,4 +705,15 @@ describe("SelectPrompt", () => { ]); assert.equal(input, "foo"); }); + + it("should return first choice when skipping prompt", async() => { + const message = "Choose between foo, bar or baz"; + const options = { + choices: ["foo", "bar", "baz"], + skip: true + }; + const input = await select(message, options); + + assert.equal(input, "foo"); + }); });