Skip to content

Commit

Permalink
feat: add skip option (#128)
Browse files Browse the repository at this point in the history
  • Loading branch information
PierreDemailly authored Jan 12, 2025
1 parent cb3416f commit 5239eda
Show file tree
Hide file tree
Showing 13 changed files with 165 additions and 36 deletions.
30 changes: 20 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,16 @@ question(message: string, options?: PromptOptions): Promise<string>

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
Expand Down Expand Up @@ -95,35 +99,39 @@ select(message: string, options: SelectOptions): Promise<string>

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`).<br>
Use `preSelectedChoices` to pre-select choices.
Scrollable multiselect depending `options.maxVisible` (default `8`).<br>
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
Expand All @@ -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.
Expand Down
10 changes: 9 additions & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -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/**"],
}]);
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
6 changes: 5 additions & 1 deletion src/prompts/abstract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export interface AbstractPromptOptions {
stdin?: Stdin;
stdout?: Stdout;
message: string;
skip?: boolean;
signal?: AbortSignal;
}

Expand All @@ -29,6 +30,7 @@ export class AbstractPrompt<T> extends EventEmitter {
stdout: Stdout;
message: string;
signal?: AbortSignal;
skip: boolean;
history: string[];
agent: PromptAgent<T>;
mute: boolean;
Expand All @@ -46,7 +48,8 @@ export class AbstractPrompt<T> extends EventEmitter {
stdin: input = process.stdin,
stdout: output = process.stdout,
message,
signal
signal,
skip = false
} = options;

if (typeof message !== "string") {
Expand All @@ -65,6 +68,7 @@ export class AbstractPrompt<T> extends EventEmitter {
this.stdout = output;
this.message = message;
this.signal = signal;
this.skip = skip;
this.history = [];
this.agent = PromptAgent.agent<T>();
this.mute = false;
Expand Down
10 changes: 8 additions & 2 deletions src/prompts/confirm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export class ConfirmPrompt extends AbstractPrompt<boolean> {
});
}

#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)
Expand Down Expand Up @@ -126,7 +126,13 @@ export class ConfirmPrompt extends AbstractPrompt<boolean> {
this.write(`${this.selectedValue ? SYMBOLS.Tick : SYMBOLS.Cross} ${styleText("bold", this.message)}${EOL}`);
}

confirm(): Promise<boolean> {
async confirm(): Promise<boolean> {
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();
Expand Down
49 changes: 33 additions & 16 deletions src/prompts/multiselect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ export class MultiselectPrompt extends AbstractPrompt<string | string[]> {
constructor(options: MultiselectOptions) {
const {
choices,
preSelectedChoices,
preSelectedChoices = [],
validators = [],
showHint = true,
...baseOptions
Expand Down Expand Up @@ -120,10 +120,6 @@ export class MultiselectPrompt extends AbstractPrompt<string | string[]> {
}
}

if (!preSelectedChoices) {
return;
}

for (const choice of preSelectedChoices) {
const choiceIndex = this.filteredChoices.findIndex((item) => {
if (typeof item === "string") {
Expand Down Expand Up @@ -206,6 +202,29 @@ export class MultiselectPrompt extends AbstractPrompt<string | string[]> {
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);
Expand Down Expand Up @@ -238,16 +257,7 @@ export class MultiselectPrompt extends AbstractPrompt<string | string[]> {
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);
Expand Down Expand Up @@ -287,7 +297,14 @@ export class MultiselectPrompt extends AbstractPrompt<string | string[]> {
}
}

multiselect(): Promise<string[]> {
async multiselect(): Promise<string[]> {
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) {
Expand Down
8 changes: 7 additions & 1 deletion src/prompts/question.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,13 @@ export class QuestionPrompt extends AbstractPrompt<string> {
this.#writeAnswer();
}

question(): Promise<string> {
async question(): Promise<string> {
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();
Expand Down
9 changes: 8 additions & 1 deletion src/prompts/select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,14 @@ export class SelectPrompt extends AbstractPrompt<string> {
}
}

select(): Promise<string> {
async select(): Promise<string> {
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) {
Expand Down
18 changes: 18 additions & 0 deletions test/confirm-prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
4 changes: 2 additions & 2 deletions test/helpers/mock-process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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);
}
Expand Down
25 changes: 25 additions & 0 deletions test/multi-select-prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, []);
});
});
17 changes: 17 additions & 0 deletions test/question-prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
11 changes: 11 additions & 0 deletions test/select-prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});

0 comments on commit 5239eda

Please sign in to comment.