inquirer-checkbox-plus-prompt
Version:
Checkbox with autocomplete and other additions for Inquirer
549 lines (407 loc) • 11.9 kB
JavaScript
/**
* Checkbox Plus
*
* @author Mohammad Fares <faressoft.com@gmail.com>
*/
'use strict';
var _ = require('lodash');
var chalk = require('chalk');
var { map, takeUntil } = require('rxjs/operators');
var cliCursor = require('cli-cursor');
var figures = require('figures');
var Base = require('inquirer/lib/prompts/base');
var Choices = require('inquirer/lib/objects/choices');
var observe = require('inquirer/lib/utils/events');
var Paginator = require('inquirer/lib/utils/paginator');
/**
* CheckboxPlusPrompt
*/
class CheckboxPlusPrompt extends Base {
/**
* Initialize the prompt
*
* @param {Object} questions
* @param {Object} rl
* @param {Object} answers
*/
constructor(questions, rl, answers) {
super(questions, rl, answers);
// Default value for the highlight option
if (typeof this.opt.highlight == 'undefined') {
this.opt.highlight = false;
}
// Default value for the searchable option
if (typeof this.opt.searchable == 'undefined') {
this.opt.searchable = false;
}
// Default value for the default option
if (typeof this.opt.default == 'undefined') {
this.opt.default = null;
}
// Doesn't have source option
if (!this.opt.source) {
this.throwParamError('source');
}
// Init
this.pointer = 0;
this.firstSourceLoading = true;
this.choices = new Choices([], answers);
this.checkedChoices = [];
this.value = [];
this.lastQuery = null;
this.searching = false;
this.lastSourcePromise = null;
this.default = this.opt.default;
this.opt.default = null;
this.paginator = new Paginator(this.screen);
}
/**
* Start the Inquiry session
*
* @param {Function} callback callback when prompt is done
* @return {this}
*/
_run(callback) {
var self = this;
this.done = callback;
this.executeSource().then(function(result) {
var events = observe(self.rl);
var validation = self.handleSubmitEvents(
events.line.pipe(map(self.getCurrentValue.bind(self)))
);
validation.success.forEach(self.onEnd.bind(self));
validation.error.forEach(self.onError.bind(self));
events.normalizedUpKey
.pipe(takeUntil(validation.success))
.forEach(self.onUpKey.bind(self));
events.normalizedDownKey
.pipe(takeUntil(validation.success))
.forEach(self.onDownKey.bind(self));
events.keypress
.pipe(takeUntil(validation.success))
.forEach(self.onKeypress.bind(self));
events.spaceKey
.pipe(takeUntil(validation.success))
.forEach(self.onSpaceKey.bind(self));
// If the search is enabled
if (!self.opt.searchable) {
events.numberKey
.pipe(takeUntil(validation.success))
.forEach(self.onNumberKey.bind(self));
events.aKey
.pipe(takeUntil(validation.success))
.forEach(self.onAllKey.bind(self));
events.iKey
.pipe(takeUntil(validation.success))
.forEach(self.onInverseKey.bind(self));
} else {
events.keypress
.pipe(takeUntil(validation.success))
.forEach(self.onKeypress.bind(self));
}
if (self.rl.line) {
self.onKeypress();
}
// Init the prompt
cliCursor.hide();
self.render();
});
return this;
}
/**
* Execute the source function to get the choices and render them
*/
executeSource() {
var self = this;
var sourcePromise = null;
// Remove spaces
this.rl.line = _.trim(this.rl.line);
// Same last search query that already loaded
if (this.rl.line === this.lastQuery) {
return;
}
// If the search is enabled
if (this.opt.searchable) {
sourcePromise = this.opt.source(this.answers, this.rl.line);
} else {
sourcePromise = this.opt.source(this.answers, null);
}
this.lastQuery = this.rl.line;
this.lastSourcePromise = sourcePromise;
this.searching = true;
sourcePromise.then(function(choices) {
// Is not the last issued promise
if (self.lastSourcePromise !== sourcePromise) {
return;
}
// Reset the searching status
self.searching = false;
// Save the new choices
self.choices = new Choices(choices, self.answers);
// Foreach choice
self.choices.forEach(function(choice) {
// Is the current choice included in the current checked choices
if (_.findIndex(self.value, _.isEqual.bind(null, choice.value)) != -1) {
self.toggleChoice(choice, true);
} else {
self.toggleChoice(choice, false);
}
// The default is not applied yet
if (self.default) {
// Is the current choice included in the default values
if (_.findIndex(self.default, _.isEqual.bind(null, choice.value)) != -1) {
self.toggleChoice(choice, true);
}
}
});
// Reset the pointer to select the first choice
self.pointer = 0;
self.render();
self.default = null;
self.firstSourceLoading = false;
});
return sourcePromise;
}
/**
* Render the prompt
*
* @param {Object} error
*/
render(error) {
// Render question
var message = this.getQuestion();
var bottomContent = '';
// Answered
if (this.status === 'answered') {
message += chalk.cyan(this.selection.join(', '));
return this.screen.render(message, bottomContent);
}
// No search query is entered before
if (this.firstSourceLoading) {
// If the search is enabled
if (this.opt.searchable) {
message +=
'(Press ' +
chalk.cyan.bold('<space>') +
' to select, ' +
'or type anything to filter the list)';
} else {
message +=
'(Press ' +
chalk.cyan.bold('<space>') +
' to select, ' +
chalk.cyan.bold('<a>') +
' to toggle all, ' +
chalk.cyan.bold('<i>') +
' to invert selection)';
}
}
// If the search is enabled
if (this.opt.searchable) {
// Print the current search query
message += this.rl.line;
}
// Searching mode
if (this.searching) {
message += '\n ' + chalk.cyan('Searching...');
// No choices
} else if (!this.choices.length) {
message += '\n ' + chalk.yellow('No results...');
// Has choices
} else {
var choicesStr = this.renderChoices(this.choices, this.pointer);
var indexPosition = this.choices.indexOf(
this.choices.getChoice(this.pointer)
);
message += '\n' + this.paginator.paginate(choicesStr, indexPosition, this.opt.pageSize);
}
if (error) {
bottomContent = chalk.red('>> ') + error;
}
this.screen.render(message, bottomContent);
}
/**
* A callback function for the event:
* When the user press `Enter` key
*
* @param {Object} state
*/
onEnd(state) {
this.status = 'answered';
// Rerender prompt (and clean subline error)
this.render();
this.screen.done();
cliCursor.show();
this.done(state.value);
}
/**
* A callback function for the event:
* When something wrong happen
*
* @param {Object} state
*/
onError(state) {
this.render(state.isValid);
}
/**
* Get the current values of the selected choices
*
* @return {Array}
*/
getCurrentValue() {
this.selection = _.map(this.checkedChoices, 'short');
return _.map(this.checkedChoices, 'value');
}
/**
* A callback function for the event:
* When the user press `Up` key
*/
onUpKey() {
var len = this.choices.realLength;
this.pointer = this.pointer > 0 ? this.pointer - 1 : len - 1;
this.render();
}
/**
* A callback function for the event:
* When the user press `Down` key
*/
onDownKey() {
var len = this.choices.realLength;
this.pointer = this.pointer < len - 1 ? this.pointer + 1 : 0;
this.render();
}
/**
* A callback function for the event:
* When the user press a number key
*/
onNumberKey(input) {
if (input <= this.choices.realLength) {
this.pointer = input - 1;
this.toggleChoice(this.choices.getChoice(this.pointer));
}
this.render();
}
/**
* A callback function for the event:
* When the user press `Space` key
*/
onSpaceKey() {
// When called no results
if (!this.choices.getChoice(this.pointer)) {
return;
}
this.toggleChoice(this.choices.getChoice(this.pointer));
this.render();
}
/**
* A callback function for the event:
* When the user press 'a' key
*/
onAllKey() {
var shouldBeChecked = Boolean(
this.choices.find(function(choice) {
return choice.type !== 'separator' && !choice.checked;
})
);
this.choices.forEach(function(choice) {
if (choice.type !== 'separator') {
choice.checked = shouldBeChecked;
}
});
this.render();
}
/**
* A callback function for the event:
* When the user press `i` key
*/
onInverseKey() {
this.choices.forEach(function(choice) {
if (choice.type !== 'separator') {
choice.checked = !choice.checked;
}
});
this.render();
}
/**
* A callback function for the event:
* When the user press any key
*/
onKeypress() {
this.executeSource();
this.render();
}
/**
* Toggle (check/uncheck) a specific choice
*
* @param {Boolean} checked if not specified the status will be toggled
* @param {Object} choice
*/
toggleChoice(choice, checked) {
// Default value for checked
if (typeof checked === 'undefined') {
checked = !choice.checked;
}
// Remove the choice's value from the checked values
_.remove(this.value, _.isEqual.bind(null, choice.value));
// Remove the checkedChoices with the value of the current choice
_.remove(this.checkedChoices, function(checkedChoice) {
return _.isEqual(choice.value, checkedChoice.value);
});
choice.checked = false;
// Is the choice checked
if (checked) {
this.value.push(choice.value);
this.checkedChoices.push(choice);
choice.checked = true;
}
}
/**
* Get the checkbox figure (sign)
*
* @param {Boolean} checked
* @return {String}
*/
getCheckboxFigure(checked) {
return checked ? chalk.green(figures.radioOn) : figures.radioOff;
}
/**
* Render the checkbox choices
*
* @param {Array} choices
* @param {Number} pointer the position of the pointer
* @return {String} rendered content
*/
renderChoices(choices, pointer) {
var self = this;
var output = '';
var separatorOffset = 0;
// Foreach choice
choices.forEach(function(choice, index) {
// Is a separator
if (choice.type === 'separator') {
separatorOffset++;
output += ' ' + choice + '\n';
return;
}
// Is the choice disabled
if (choice.disabled) {
separatorOffset++;
output += ' - ' + choice.name;
output += ' (' + (_.isString(choice.disabled) ? choice.disabled : 'Disabled') + ')';
output += '\n';
return;
}
// Is the current choice is the selected choice
if (index - separatorOffset === pointer) {
output += chalk.cyan(figures.pointer);
output += self.getCheckboxFigure(choice.checked) + ' ';
output += self.opt.highlight ? chalk.gray(choice.name) : choice.name;
} else {
output += ' ' + self.getCheckboxFigure(choice.checked) + ' ' + choice.name;
}
output += '\n';
});
return output.replace(/\n$/, '');
}
}
module.exports = CheckboxPlusPrompt;