symfony-style-console
Version:
Use the style and utilities of the Symfony Console in Node.js
181 lines (180 loc) • 6.38 kB
JavaScript
import { arrContains, flipObject } from '../Helper/Helper';
let readline;
/**
* Provides simple Q&A with text input, password input, choice and confirmation
*
* @author Florian Reuschel <florian@loilo.de>
*/
export default class Questionnaire {
/**
* Creates a new Questionnaire instance.
*
* @param output The output to use for the questions.
*/
constructor(output) {
Questionnaire.initReadline();
this.output = output;
}
/**
* Initializes the Node.js `readline` module.
*/
static initReadline() {
if (!readline) {
readline = require('readline');
}
}
/**
* Checks if a string does contain anything but whitespace.
*
* @param value The string to check
*/
static isFilled(value) {
return !!value.trim().length;
}
/**
* Disables the printing of `stdin` data to `stdout`.
*
* @param char The character that's currently going into `stdin`
*/
static suppressStdinOutput(char) {
switch (String(char)) {
case '\n':
case '\r':
case '\u0004':
process.stdin.pause();
process.stdin.removeListener('data', Questionnaire.suppressStdinOutput);
break;
default:
setImmediate(() => {
process.stdout.write(`\u001b[2K\u001b[200D > `);
});
break;
}
}
/**
* Performs the actual interaction between user and terminal via `readline`.
*
* @param _ Question options
*/
doAsk({ question, validator = null, hideInput = false, errorMsg = 'Invalid value.' }) {
this.output.writeln(question);
if (hideInput) {
process.stdin.resume();
process.stdin.on('data', Questionnaire.suppressStdinOutput);
}
const rl = readline.createInterface(process.stdin, process.stdout);
rl.setPrompt(' > ');
rl.prompt();
return new Promise(resolve => {
rl.on('line', (value) => {
rl.history.shift();
rl.close();
this.output.newLine();
if (!validator || validator(value)) {
resolve(value);
}
else {
this.output.error(typeof errorMsg === 'function' ? errorMsg(value) : errorMsg);
resolve(this.doAsk({ question, hideInput, validator, errorMsg }));
}
});
});
}
/**
* Ask for a string answer.
*
* @param question The (formatted) question to put
* @param defaultValue A default value to provide
* @param validator A validator callback
*/
ask(question, defaultValue = null, validator = null) {
const hasDefaultValue = defaultValue != null;
let formattedQuestion = ` <fg=green>${question}</>`;
if (hasDefaultValue) {
formattedQuestion += ` [<fg=yellow>${defaultValue}</>]`;
}
formattedQuestion += ':';
return (this.doAsk({
question: formattedQuestion,
validator: validator || (hasDefaultValue ? null : Questionnaire.isFilled),
errorMsg: 'A value is required.'
})
// Return default value if exists and input is empty
.then(value => !hasDefaultValue || Questionnaire.isFilled(value)
? value
: defaultValue));
}
/**
* Ask for a string answer, hide input chars.
*
* @param question The (formatted) question to put
* @param validator A validator callback
*/
askHidden(question, validator = null) {
const formattedQuestion = ` <fg=green>${question}</>:`;
return this.doAsk({
question: formattedQuestion,
hideInput: true,
validator: validator || Questionnaire.isFilled,
errorMsg: 'A value is required.'
});
}
/**
* Ask for picking an option.
*
* @param question The (formatted) question to put
* @param choices A value-label map of options
* @param defaultValue A default value to provide
*/
choice(question, choices, defaultValue = null) {
const hasDefaultValue = defaultValue != null;
const flippedChoices = flipObject(choices);
const choiceValues = Object.keys(choices);
const choiceLabels = Object.keys(flippedChoices);
if (hasDefaultValue && !arrContains(choiceLabels, defaultValue)) {
throw new RangeError(`Invalid default value "${defaultValue}",` +
`must be one of: ${choiceLabels
.map(label => `"${label}"`)
.join(', ')}`);
}
let formattedQuestion = ` <fg=green>${question}</>`;
if (hasDefaultValue) {
formattedQuestion += ` [<fg=yellow>${defaultValue}</>]`;
}
formattedQuestion += ':\n';
for (const option in choices) {
formattedQuestion += ` [<fg=yellow>${option}</>] ${choices[option]}\n`;
}
formattedQuestion = formattedQuestion.slice(0, -1);
return (this.doAsk({
question: formattedQuestion,
validator(value) {
return Questionnaire.isFilled(value)
? arrContains(choiceValues, value)
: hasDefaultValue;
},
errorMsg(value) {
return `Value "${value}" is invalid.`;
}
})
// Return default value if exists and input is empty
.then(value => !hasDefaultValue || Questionnaire.isFilled(value)
? value
: flippedChoices[defaultValue]));
}
/**
* Ask a yes/no question.
*
* @param question The (formatted) question to put
* @param defaultValue If the answer should default to "yes"
*/
confirm(question, defaultValue = true) {
const formattedQuestion = ` <fg=green>${question} (yes/no)</> [<fg=yellow>${defaultValue ? 'yes' : 'no'}</>]`;
const truthyRegex = /^y/i;
return this.doAsk({
question: formattedQuestion
}).then(value => Questionnaire.isFilled(value)
? truthyRegex.test(value.trim())
: defaultValue);
}
}