Skip to content

Commit

Permalink
feat(multiselect): input validators (#50)
Browse files Browse the repository at this point in the history
  • Loading branch information
PierreDemailly authored Oct 4, 2023
1 parent 383f5db commit b96f2c3
Show file tree
Hide file tree
Showing 9 changed files with 108 additions and 24 deletions.
8 changes: 4 additions & 4 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,11 @@ jobs:
egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs

- name: Checkout repository
uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0

# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@00e563ead9f72a8461b24876bee2d0c2e8bd2ee8 # v2.21.5
uses: github/codeql-action/init@ddccb873888234080b77e9bc2d4764d5ccaaccf9 # v2.21.9
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
Expand All @@ -68,7 +68,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@00e563ead9f72a8461b24876bee2d0c2e8bd2ee8 # v2.21.5
uses: github/codeql-action/autobuild@ddccb873888234080b77e9bc2d4764d5ccaaccf9 # v2.21.9

# ℹ️ Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
Expand All @@ -81,6 +81,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh

- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@00e563ead9f72a8461b24876bee2d0c2e8bd2ee8 # v2.21.5
uses: github/codeql-action/analyze@ddccb873888234080b77e9bc2d4764d5ccaaccf9 # v2.21.9
with:
category: "/language:${{matrix.language}}"
4 changes: 2 additions & 2 deletions .github/workflows/node.js.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@ jobs:
with:
egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs

- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
uses: actions/setup-node@5e21ff4d9bc1a8cf6de233a3057d20ec6b3fb69d # v3.8.1
with:
node-version: ${{ matrix.node-version }}
- name: Install dependencies
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/scorecard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ jobs:
egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs

- name: "Checkout code"
uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
with:
persist-credentials: false

Expand All @@ -65,14 +65,14 @@ jobs:
# Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
# format to the repository Actions tab.
- name: "Upload artifact"
uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v3.1.2
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3
with:
name: SARIF file
path: results.sarif
retention-days: 5

# Upload the results to GitHub's code scanning dashboard.
- name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@00e563ead9f72a8461b24876bee2d0c2e8bd2ee8 # v2.21.5
uses: github/codeql-action/upload-sarif@ddccb873888234080b77e9bc2d4764d5ccaaccf9 # v2.21.9
with:
sarif_file: results.sarif
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,20 @@ Use `ignoreValues` to skip result render & clear lines after a selected one.
multiselect(message: string, options: MultiselectOptions): Promise<[string]>
```

Scrollable multiselect depending `maxVisible` (default `8`).
Scrollable multiselect depending `maxVisible` (default `8`).
Use `preSelectedChoices` to pre-select choices.

Use `validators` to handle user input.

**Example**

```js
const os = await multiselect('Choose OS', {
choices: ["linux", "mac", "windows"]
validators: [required()]
});
```

### `confirm()`

```ts
Expand Down Expand Up @@ -159,6 +170,13 @@ export interface SelectOptions extends SharedOptions {
ignoreValues?: (string | number | boolean)[];
}

export interface MultiselectOptions extends SharedOptions {
choices: (Choice | string)[];
maxVisible?: number;
preSelectedChoices?: (Choice | string)[];
validators?: Validator[];
}

export interface ConfirmOptions extends SharedOptions {
initial?: boolean;
}
Expand Down
2 changes: 1 addition & 1 deletion index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ export interface QuestionOptions extends SharedOptions {
validators?: Validator[];
}


export interface Choice {
value: any;
label: string;
Expand All @@ -34,6 +33,7 @@ export interface MultiselectOptions extends SharedOptions {
choices: (Choice | string)[];
maxVisible?: number;
preSelectedChoices?: (Choice | string)[];
validators?: Validator[];
}

export interface ConfirmOptions extends SharedOptions {
Expand Down
10 changes: 5 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,11 @@
"license": "ISC",
"type": "module",
"devDependencies": {
"@nodesecure/eslint-config": "^1.7.0",
"@types/node": "^20.4.5",
"c8": "^8.0.0",
"eslint": "^8.44.0",
"esmock": "^2.3.1"
"@nodesecure/eslint-config": "^1.8.0",
"@types/node": "^20.8.2",
"c8": "^8.0.1",
"eslint": "^8.50.0",
"esmock": "^2.5.1"
},
"dependencies": {
"is-unicode-supported": "^1.3.0",
Expand Down
38 changes: 32 additions & 6 deletions src/multiselect-prompt.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { AbstractPrompt } from "./abstract-prompt.js";
import { SYMBOLS } from "./constants.js";

export class MultiselectPrompt extends AbstractPrompt {
#validators;

activeIndex = 0;
selectedIndexes = [];

Expand All @@ -21,7 +23,8 @@ export class MultiselectPrompt extends AbstractPrompt {
stdin = process.stdin,
stdout = process.stdout,
choices,
preSelectedChoices
preSelectedChoices,
validators = []
} = options ?? {};

super(message, stdin, stdout);
Expand Down Expand Up @@ -55,6 +58,8 @@ export class MultiselectPrompt extends AbstractPrompt {
return choice.label.length;
}));

this.#validators = validators;

if (!preSelectedChoices) {
return;
}
Expand Down Expand Up @@ -152,7 +157,8 @@ export class MultiselectPrompt extends AbstractPrompt {
const render = (options = {}) => {
const {
initialRender = false,
clearRender = false
clearRender = false,
error = null
} = options;

if (!initialRender) {
Expand All @@ -170,6 +176,12 @@ export class MultiselectPrompt extends AbstractPrompt {
return;
}

if (error) {
this.stdout.moveCursor(0, -2);
this.stdout.clearScreenDown();
this.#showQuestion(error);
}

this.#showChoices();
};

Expand Down Expand Up @@ -202,12 +214,22 @@ export class MultiselectPrompt extends AbstractPrompt {
render();
}
else if (key.name === "return") {
const labels = this.selectedIndexes.map((index) => this.choices[index].label ?? this.choices[index]);
const values = this.selectedIndexes.map((index) => this.choices[index].value ?? this.choices[index]);

for (const validator of this.#validators) {
if (!validator.validate(values)) {
const error = validator.error(values);
render({ error });

return;
}
}

this.stdin.off("keypress", onKeypress);

render({ clearRender: true });

const labels = this.selectedIndexes.map((index) => this.choices[index].label ?? this.choices[index]);
const values = this.selectedIndexes.map((index) => this.choices[index].value ?? this.choices[index]);

this.#showAnsweredQuestion(labels.join(", "));

Expand All @@ -222,10 +244,14 @@ export class MultiselectPrompt extends AbstractPrompt {
});
}

#showQuestion() {
const hint = kleur.gray(
#showQuestion(error = null) {
let hint = kleur.gray(
`(Press ${kleur.bold("<a>")} to toggle all, ${kleur.bold("<space>")} to select, ${kleur.bold("<return>")} to submit)`
);
if (error) {
hint += ` ${kleur.red().bold(`[${error}]`)}`;
}

this.write(`${SYMBOLS.QuestionMark} ${kleur.bold(this.message)} ${hint}${EOL}`);
}
}
2 changes: 1 addition & 1 deletion src/validators.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export function required() {
return {
validate: (input) => input !== "",
validate: (input) => (Array.isArray(input) ? input.length > 0 : Boolean(input)),
error: () => "required"
};
}
42 changes: 41 additions & 1 deletion test/multi-select-prompt.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { MultiselectPrompt } from "../src/multiselect-prompt.js";
import { TestingPrompt } from "./helpers/testing-prompt.js";
import { mockProcess } from "./helpers/mock-process.js";
import { PromptAgent } from "../src/prompt-agent.js";
import { multiselect } from "../index.js";
import { multiselect, required } from "../index.js";

const kInputs = {
a: { name: "a" },
Expand Down Expand Up @@ -505,4 +505,44 @@ describe("MultiselectPrompt", () => {
"✔ Choose option › option1"
]);
});

it("It should render with validation error.", async() => {
const logs = [];
const message = "Choose between foo & bar";
const options = {
choices: ["foo", "bar"],
validators: [required()]
};
const inputs = [
kInputs.return,
kInputs.space,
kInputs.return
];
const multiselectPrompt = await TestingPrompt.MultiselectPrompt(
message,
{
...options,
inputs,
onStdoutWrite: (log) => logs.push(log)
}
);

const input = await multiselectPrompt.multiselect();

assert.deepStrictEqual(logs, [
"? Choose between foo & bar (Press <a> to toggle all, <space> to select, <return> to submit)",
" ○ foo",
" ○ bar",
// we press <return> so it re-render question with error
"? Choose between foo & bar (Press <a> to toggle all, <space> to select, <return> to submit) [required]",
" ○ foo",
" ○ bar",
// we press <space> so it select 'foo'
" ● foo",
" ○ bar",
// we press <return> so 'foo' is returned
"✔ Choose between foo & bar › foo"
]);
assert.deepEqual(input, ["foo"]);
});
});

0 comments on commit b96f2c3

Please sign in to comment.