diff --git a/.travis.yml b/.travis.yml index 813ea903..e735877f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,5 +3,6 @@ script: - npm run build - npm test node_js: + - "stable" - "8" - "6" diff --git a/lib/elements/multiselect.js b/lib/elements/multiselect.js index afa2ca23..1f87082d 100644 --- a/lib/elements/multiselect.js +++ b/lib/elements/multiselect.js @@ -30,6 +30,7 @@ class MultiselectPrompt extends Prompt { this.showMinError = false; this.maxChoices = opts.max; this.instructions = opts.instructions; + this.hotkeys = opts.hotkeys || {}; this.optionsPerPage = opts.optionsPerPage || 10; this.value = opts.choices.map((ch, idx) => { if (typeof ch === 'string') @@ -150,11 +151,27 @@ class MultiselectPrompt extends Prompt { this.render(); } - _(c, key) { + _(c) { if (c === ' ') { this.handleSpaceToggle(); } else if (c === 'a') { this.toggleAll(); + } else if (Object.keys(this.hotkeys).includes(c)) { + const hotkeyResponse = this.hotkeys[c].handle() || {}; + if (hotkeyResponse.answers) { + this.value.forEach(value => { + const answerForValue = hotkeyResponse.answers[value.title]; + if (answerForValue !== undefined) { + value.selected = answerForValue; + this.render(); + } + }); + } + + if (['abort', 'submit'].includes(hotkeyResponse.command)) { + this[hotkeyResponse.command](); + } + } else { return this.bell(); } @@ -165,10 +182,17 @@ class MultiselectPrompt extends Prompt { if (typeof this.instructions === 'string') { return this.instructions; } + + let hotkeyInstructions = Object.keys(this.hotkeys) + .map(key => [key, this.hotkeys[key]]) + .reduce((instructions, [hotkeyChar, hotkeyDescriptor]) => + instructions += ` ${hotkeyChar}: ${hotkeyDescriptor.instruction}\n`, '') + return '\nInstructions:\n' + ` ${figures.arrowUp}/${figures.arrowDown}: Highlight option\n` + ` ${figures.arrowLeft}/${figures.arrowRight}/[space]: Toggle selection\n` + (this.maxChoices === undefined ? ` a: Toggle all\n` : '') + + hotkeyInstructions + ` enter/return: Complete answer`; } return ''; diff --git a/package.json b/package.json index ca0c1f2b..c0c67c01 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "start": "node lib/index.js", "build": "babel lib -d dist", "prepublishOnly": "npm run build", - "test": "tape test/*.js | tap-spec" + "test": "npm run build && tape test/*.js | tap-spec" }, "keywords": [ "ui", diff --git a/readme.md b/readme.md index 2f6c5b6a..f9db2e85 100755 --- a/readme.md +++ b/readme.md @@ -1,5 +1,5 @@

- Prompts + Prompts

❯ Prompts

@@ -36,7 +36,7 @@ * **Unified**: consistent experience across all [prompts](#-types). -![split](https://github.com/terkelg/prompts/raw/master/media/split.png) +![split](./media/split.png) ## ❯ Install @@ -47,11 +47,11 @@ $ npm install --save prompts > This package supports Node 6 and above -![split](https://github.com/terkelg/prompts/raw/master/media/split.png) +![split](./media/split.png) ## ❯ Usage -example prompt +example prompt ```js const prompts = require('prompts'); @@ -71,7 +71,7 @@ const prompts = require('prompts'); > See [`example.js`](https://github.com/terkelg/prompts/blob/master/example.js) for more options. -![split](https://github.com/terkelg/prompts/raw/master/media/split.png) +![split](./media/split.png) ## ❯ Examples @@ -155,7 +155,7 @@ const questions = [ ``` -![split](https://github.com/terkelg/prompts/raw/master/media/split.png) +![split](./media/split.png) ## ❯ API @@ -299,7 +299,7 @@ prompts.inject([ '@terkelg', ['#ff0000', '#0000ff'] ]); })(); ``` -![split](https://github.com/terkelg/prompts/raw/master/media/split.png) +![split](./media/split.png) ## ❯ Prompt Objects @@ -418,7 +418,7 @@ The function signature is `(state)` where `state` is an object with a snapshot o The state object has two properties `value` and `aborted`. E.g `{ value: 'This is ', aborted: false }` -![split](https://github.com/terkelg/prompts/raw/master/media/split.png) +![split](./media/split.png) ## ❯ Types @@ -444,7 +444,7 @@ The state object has two properties `value` and `aborted`. E.g `{ value: 'This i Hit tab to autocomplete to `initial` value when provided. #### Example -text prompt +text prompt ```js { @@ -475,7 +475,7 @@ Hit tab to autocomplete to `initial` value when provided. This prompt is a similar to a prompt of type `'text'` with `style` set to `'password'`. #### Example -password prompt +password prompt ```js { @@ -506,7 +506,7 @@ This prompt is working like `sudo` where the input is invisible. This prompt is a similar to a prompt of type `'text'` with style set to `'invisible'`. #### Example -invisible prompt +invisible prompt ```js { @@ -536,7 +536,7 @@ This prompt is a similar to a prompt of type `'text'` with style set to `'invisi You can type in numbers and use up/down to increase/decrease the value. Only numbers are allowed as input. Hit tab to autocomplete to `initial` value when provided. #### Example -number prompt +number prompt ```js { @@ -576,7 +576,7 @@ You can type in numbers and use up/down to increase/decrea Hit y or n to confirm/reject. #### Example -confirm prompt +confirm prompt ```js { @@ -617,7 +617,7 @@ string separated by `separator`. } ``` -list prompt +list prompt | Param | Type | Description | @@ -639,7 +639,7 @@ string separated by `separator`. Use tab or arrow keys/tab/space to switch between options. #### Example -toggle prompt +toggle prompt ```js { @@ -673,7 +673,7 @@ Use tab or arrow keys/tab/space to switch betwe Use up/down to navigate. Use tab to cycle the list. #### Example -select prompt +select prompt ```js { @@ -714,7 +714,7 @@ Use space to toggle select/unselect and up/down By default this prompt returns an `array` containing the **values** of the selected items - not their display title. #### Example -multiselect prompt +multiselect prompt ```js { @@ -727,6 +727,17 @@ By default this prompt returns an `array` containing the **values** of the selec { title: 'Blue', value: '#0000ff', selected: true } ], max: 2, + hotkeys: { + d: { + handle() { + return { + answer: {Red: true, Green: false}, + command: 'submit' + } + }, + instruction: 'Enable red, disable green, and submit the answer.' + } + } hint: '- Space to select. Return to submit' } ``` @@ -745,10 +756,35 @@ By default this prompt returns an `array` containing the **values** of the selec | warn | `string` | Message to display when selecting a disabled option | | onRender | `function` | On render callback. Keyword `this` refers to the current prompt | | onState | `function` | On state change callback. Function signature is an `object` with two properties: `value` and `aborted` | +| hotkeys | `{[hotkeyCharacter]: hotkeyDescriptor}` | Define hotkeys that can select multiple answers at once. | This is one of the few prompts that don't take a initial value. If you want to predefine selected values, give the choice object an `selected` property of `true`. +The type of `hotkeyDescriptor` is: + +```ts +{ + // You can do whatever you want in this function (e.g. trigger a side effect). You can also customize the choices object. + handle: () => { + + // Pass "submit" to have this question be submitted when the user presses the hotkey. + // Pass "abort" to have the question be aborted (as if the user had hit control+c). + command?: 'submit' | 'abort', + + answers: { + // The key in this object is the `title` field in the `choices` array passed above. + // The value is whether this choice will be selected after the hotkey is pressed. + // Pass true to select the choice. False deselects the choice. Undefined leaves the choice as-is. + [answerTitle]: boolean + } + }, + instruction: string +} +``` + +See the [multiselect test fixture](./test/fixtures/multiselect.js) for more examples of using hotkeys. + **↑ back to:** [Prompt types](#-types) *** @@ -763,7 +799,7 @@ The default suggests function is sorting based on the `title` property of the ch You can overwrite how choices are being filtered by passing your own suggest function. #### Example -auto complete prompt +auto complete prompt ```js { @@ -810,7 +846,7 @@ const suggestByTitle = (input, choices) => Use left/right/tab to navigate. Use up/down to change date. #### Example -date prompt +date prompt ```js { @@ -835,7 +871,7 @@ Use left/right/tab to navigate. Use up**Formatting**: See full list of formatting options in the [wiki](https://github.com/terkelg/prompts/wiki/Date-Time-Formatting) -![split](https://github.com/terkelg/prompts/raw/master/media/split.png) +![split](./media/split.png) **↑ back to:** [Prompt types](#-types) diff --git a/test/fixtures/multiselect.js b/test/fixtures/multiselect.js new file mode 100755 index 00000000..dfdd5d95 --- /dev/null +++ b/test/fixtures/multiselect.js @@ -0,0 +1,40 @@ +const prompts = require('../..'); + +prompts([ + { + type: 'multiselect', + name: 'color', + message: 'Pick colors', + choices: [ + { title: 'Red', value: '#ff0000' }, + { title: 'Green', value: '#00ff00' }, + { title: 'Blue', value: '#0000ff' } + ], + hotkeys: { + r: { + handle() { + return {answers: {Red: true, Green: true}}; + }, + instruction: 'Choose Red and Green' + }, + d: { + handle() { + return {command: 'abort'}; + }, + instruction: 'Abort' + }, + e: { + handle() { + return {answers: {Green: true, Blue: true}, command: 'submit'} + }, + instruction: 'Select Green and Blue, and move on the to the next question' + }, + f: { + handle() { + return {answers: {Red: false, Blue: true}, command: 'submit'} + }, + instruction: 'Pay respect. Also, enable Blue and disable Red.' + } + } + } +]).then(response => console.error(JSON.stringify(response, null, 2))); diff --git a/test/multiselect.js b/test/multiselect.js new file mode 100644 index 00000000..9d6a61f3 --- /dev/null +++ b/test/multiselect.js @@ -0,0 +1,130 @@ +const child_process = require('child_process'); +const multiselectFixture = require.resolve('./fixtures/multiselect'); +const readline = require('readline'); + +const test = require('tape'); + +// Running these tests will make your terminal cursor disappear. Sorry. Run `reset` to fix it. + +const awaitOutput = (pipe, output) => new Promise((resolve) => { + let allDataFromPipe = ''; + pipe.on('data', dataFromPipe => { + allDataFromPipe += dataFromPipe; + if (allDataFromPipe.toString().includes(output)) { + resolve(allDataFromPipe); + } + }); +}); + +const spawn = (inputs) => { + const childProcess = child_process.spawn('node', [multiselectFixture]); + const {stdout, stderr, stdin} = childProcess; + + // Pass this env var to get some visibility into what the subproc is doing, and also kinda break the tests. + // But maybe it'll help you. :) + if (process.env.DEBUG) { + stdout.pipe(process.stdout); + process.stdin.pipe(stdin); + } + + childProcess.on('error', (err) => { + console.log('Spawn error:', err); + }); + + const readlineInstance = readline.createInterface({output: stdin, input: stdin, terminal: true }); + + const stderrPromise = new Promise((resolve, reject) => { + try { + stderr.on('data', data => { + childProcess.kill(); + stdout.unpipe(process.stdout); + // I hope the JSON output always comes as a single data event. + resolve(JSON.parse(data.toString())); + }) + } catch (e) { + reject(e); + } + }) + + const stdoutPromise = awaitOutput(stdout, 'Blue').then(allDataFromPipe => { + inputs.forEach(input => { + if (typeof input === 'string') { + readlineInstance.write(input); + } else if (typeof input === 'object') { + readlineInstance.write(undefined, input); + } else { + throw new Error( + 'Argument error: every element of spawn()s input array should be a string or an object, ' + + `but "${input}" was passed.`); + } + }) + return allDataFromPipe; + }); + + return Promise.all([stderrPromise, stdoutPromise]).then(([childProcessResponse, childProcessStdout]) => ({ + response: childProcessResponse, + stdout: childProcessStdout + })); +} + +test('multiselect "a"', t => { + t.plan(1); + spawn(['a', {name: 'enter'}]).then(({response}) => { + t.deepEqual(response, { + color: ['#ff0000', '#00ff00', '#0000ff'] + }, 'pressing "a" selects all options'); + t.end(); + }); +}) + +test('multiselect hotkey that selects multiple answers', t => { + t.plan(2); + spawn(['r', {name: 'enter'}]).then(({response, stdout}) => { + t.ok(stdout.includes('r: Choose Red and Green'), `Stdout includes hotkey instructions`); + + t.deepEqual(response, { + color: ['#ff0000', '#00ff00'] + }, 'pressing hotkey selects the right answers'); + t.end(); + }); +}) + +test('multiselect hotkey that aborts', t => { + t.plan(2); + spawn(['d']).then(({response, stdout}) => { + t.ok(stdout.includes('d: Abort'), `Stdout includes hotkey instructions`); + + t.deepEqual(response, {}, 'pressing hotkey aborts the process'); + t.end(); + }); +}) + +test('multiselect hotkey that chooses answers and submits', t => { + t.plan(2); + spawn(['e']).then(({response, stdout}) => { + t.ok( + stdout.includes('e: Select Green and Blue, and move on the to the next question'), + `Stdout includes hotkey instructions` + ); + + t.deepEqual(response, { + color: ['#00ff00', '#0000ff'] + }, 'pressing hotkey chooses answers and submits'); + t.end(); + }); +}) + +test('multiselect hotkey that chooses answers and submits', t => { + t.plan(2); + spawn(['a', 'f']).then(({response, stdout}) => { + t.ok( + stdout.includes('f: Pay respect. Also, enable Blue and disable Red.'), + `Stdout includes hotkey instructions` + ); + + t.deepEqual(response, { + color: ['#00ff00', '#0000ff'] + }, 'pressing hotkey chooses answers and submits'); + t.end(); + }); +})