const { inquirer } = require("../Configurer.js");
const Promptlet = require("./Promptlet.js");
class PromptSet {
/**
* Valid finishing modes for the PromptSet<br>
* Aggressive: Combination of 'confirm' and 'choice'<br>
* Confirm: Confirm after all prerequisites are met and every edit after that as well. Identical to auto if nothing is editable<br>
* Choice: Add an option to close the list at the end after all prerequisites are met<br>
* Auto: Automatically stops execution and closes the list after prerequisites are met
* @static
* @readonly
* @type {string[]}
*/
static finishModes = ["aggressive", "confirm", "choice", "auto"];
/**
* All the method property names of the Promptlet prototype
* @static
* @type {string[]}
*/
static passthroughProperties = Object.getOwnPropertyNames(Promptlet.prototype)
.filter(prop => {
const details = Object.getOwnPropertyDescriptor(Promptlet.prototype, prop);
// Allows all Promptlet member functions to be attached to the PromptSet as long as it is not overridden by a function in PromptSet
return !Object.getOwnPropertyNames(PromptSet.prototype).includes(prop) && !details.get && !details.set && typeof details.value === "function";
});
/**
* Modifies the PromptSet prototype with passthrough functions for the this.previous Promptlet instance of each PromptSet.<br>
* All passthrough functions return this PromptSet for chaining rather than the return value of the Promptlet
* @static
* @param {PromptSet} instance The PromptSet being modified with the passthrough functions
*/
static attachPassthrough(instance) {
for(const prop of PromptSet.passthroughProperties) {
Object.defineProperty(instance, prop, {
value: (...args) => {
instance.searchSet(instance.refreshPrevious())[prop](...args);
return instance;
}
});
}
}
/**
* Throws an error if the identifier is not a string or Promptlet instance
* @static
* @param identifier {string|Promptlet} Identifier to check
* @return {string|Promptlet} Returns the identifier untouched if no error is encountered
* @throws {TypeError} Thrown if identifier is not the right type
*/
static isIdentifier(identifier) {
if(typeof identifier !== "string" && !identifier instanceof Promptlet) {
throw new TypeError("Identifier must be a 'string' or 'Promptlet' instance");
}
return identifier;
}
/**
* Instantiates a new PromptSet
* @class
* @classdesc Class that manages and contains instances of Promptlets
* @alias PromptSet
* @memberOf module:Prompt-Set.Classes
*/
constructor() {
PromptSet.attachPassthrough(this);
this.clear();
}
/**
* Where to place the cursor in the PromptSet's list of Promptlets when started<br>
* Used to save the current position and return to it after the prompt is answered<br>
* Can be set to the name property of a Promptlet (preferred by internal code) or its index in the set
* @type {string|number}
*/
defaultPosition;
/**
* The complete set of Promptlets in the PromptSet. Stored in the order they are added in
* @type {Array<Promptlet>}
*/
PromptletSet;
/**
* Is true when all necessary Promptlets have been answered
* @type {boolean}
*/
satisfied;
/**
* Whether to automatically clear the console after each prompt<br>
* Inquirer will usually do it automatically regardless but this is just in case
* @type {boolean}
*/
autoclear;
/**
* Most recently added or edited Promptlet. May be undefined if nothing has been added or if the added item was deleted
* @type {Promptlet|undefined}
*/
previous;
/**
* A string from PromptSet.finishModes. Determines when and how to ask the user if they are finished yet.<br>
* See [PromptSet.finishModes]{@link PromptSet#finishModes} for more details
* @type {string}
*/
finishMode;
/**
* Confirmation Promptlet that determines whether to end execution or continue for edits or optional prompts
* @type {Promptlet}
*/
finishPrompt = new Promptlet({
optionName: "Done?",
name: "FINISH_PROMPT",
message: "Confirm that you are finished (Default: No)",
type: "confirm",
default: false,
value: false
});
/**
* Getter that returns an array of Promptlet names from the current set in order
* @readonly
* @type {string[]}
*/
get names() {
return this.PromptletSet.map(promptlet => promptlet.name);
}
/**
* Empties and resets the PromptSet for reuse
*/
clear() {
this.PromptletSet = [];
this.defaultPosition = 0;
this.satisfied = false;
this.previous = undefined;
this.autoclear = true;
this.finishMode = PromptSet.finishModes[2];
}
/**
* Search the PromptSet for a specific Promptlet
* @param {string|Promptlet} identifier The name of the Promptlet to search for. If a Promptlet is provided, the Promptlet.name property will be used
* @return {Promptlet} The searched Promptlet if found
* @throws {TypeError} Identifier is not a string or Promptlet
* @throws {RangeError} Identifier is not found in the set
*/
searchSet(identifier) {
PromptSet.isIdentifier(identifier);
let found;
if(identifier instanceof Promptlet && this.PromptletSet.includes(identifier)) {
found = identifier;
} else if(typeof identifier === "string" && this.names.includes(identifier)) {
found = this.PromptletSet[this.names.indexOf(identifier)];
} else {
throw new RangeError("No matching Promptlet found in set");
}
return found;
}
/**
* Creates a new Promptlet and immediately adds it to the PromptSet
* @param {PromptletOptions|PromptletOptions[]} constructorArgs Argument passed to the Promptlet constructor. If this is an array, it will be looped through recursively from first to last
* @return {PromptSet} Returns 'this' PromptSet for chaining
*/
addNew(constructorArgs) {
if(Array.isArray(constructorArgs)) {
constructorArgs.forEach(args => this.addNew(args));
return this;
}
this.add(new Promptlet(constructorArgs));
return this;
}
/**
* Adds already instantiated Promptlets to the PromptSet
* @param {Promptlet} set Promptlet to add to this PromptSet
* @return {PromptSet} Returns 'this' PromptSet for chaining
* @throws {TypeError} Thrown if set is not a Promptlet instance
*/
add(set) {
if(!set instanceof Promptlet) throw new TypeError("PromptSet.add() only accepts 'Promptlet' instances");
if(this.names.includes(set.name)) {
console.warn("Overwriting a prompt with an identical name");
this.remove(set.name);
}
this.PromptletSet.push(set);
this.refreshPrevious(set);
return this;
}
/**
* Remove a Promptlet from the PromptSet<br>
* Warning: Will make Promptlets that have the removed Promptlet as a prerequisite no longer selectable
* @param identifier Identifier for the Promptlet to remove from the PromptSet (Promptlet must be in set)
* @return {PromptSet} Returns 'this' PromptSet for chaining
*/
remove(identifier) {
this.PromptletSet.splice(this.names.indexOf(this.resetPrevious(identifier).name), 1);
return this;
}
/**
* Adds a prerequisite Promptlet that must be completed before this one
* @param {string|Promptlet} prerequisiteIdentifier Identifier for a prerequisite Promptlet (Promptlet must be in set)
* @param {string|Promptlet} [addToIdentifier] Identifier for a Promptlet to add prerequisite to (Promptlet must be in set). Will use PromptSet.previous if not provided
* @return {PromptSet} Returns 'this' PromptSet for chaining
*/
addPrerequisite(prerequisiteIdentifier, addToIdentifier) {
this.refreshPrevious(addToIdentifier).addPrerequisite(prerequisiteIdentifier);
return this;
}
/**
* Remove a prerequisite from a specific Promptlet
* @param {string|Promptlet} removeIdentifier Identifier for a prerequisite Promptlet (Promptlet must be in set)
* @param {string|Promptlet} [removeFromIdentifier] Identifier for a Promptlet to remove prerequisite from (Promptlet must be in set). Will use PromptSet.previous if not provided
* @return {PromptSet} Returns 'this' PromptSet for chaining
*/
removePrerequisite(removeIdentifier, removeFromIdentifier) {
this.refreshPrevious(removeFromIdentifier).removePrerequisite(removeIdentifier);
return this;
}
/**
* Update or fetch the value of this.previous
* @param {Promptlet|string} [newPrevious] New value to set as previous. Return value may vary depending on the type of this parameter
* @return {Promptlet|undefined} Returns a Promptlet. If newPrevious is a Promptlet, it will be returned untouched. If it is as string, the Promptlet will be automatically looked up. If newPrevious is not provided, this.previous will be returned (See PromptSet.previous for details).
* @throws {TypeError} newPrevious is not a string or Promptlet
*/
refreshPrevious(newPrevious) {
if(newPrevious !== undefined) {
this.previous = this.searchSet(newPrevious);
}
return this.previous;
}
/**
* Clear this.previous if it matches the targetPrevious. Use when a Promptlet is being removed from the set
* @param {Promptlet|string} targetPrevious Promptlet to assure is not equal to this.previous
* @return {Promptlet} Returns targetPrevious as a Promptlet. If targetPrevious was a string, it will be looked up
* @throws {TypeError} targetPrevious is not a string or Promptlet
*/
resetPrevious(targetPrevious) {
targetPrevious = this.searchSet(targetPrevious);
if(this.previous === targetPrevious) this.previous = undefined;
return targetPrevious;
}
/**
* Used for determining whether all the prerequisites for a Promptlet have been met
* @param {Promptlet} chosenPromptlet A Promptlet to check prerequisites from
* @param {boolean} [silent = false] When not set to true, the first unsatisfied prerequisite will be logged through the console
* @return {boolean} Whether the chosenPromptlet has had all of its prerequisites met
*/
prereqSatisfied(chosenPromptlet, silent = false) {
let preSatisfied = true;
for(const prerequisite of chosenPromptlet.prerequisites) {
const pre = this.searchSet(prerequisite);
if(!pre.satisfied) {
preSatisfied = false;
if(!silent) console.log(`Prompt must be answered before this:\n${pre.optionName}`);
break;
}
}
return preSatisfied;
}
/**
* Returns true when all required Promptlets have been answered
* @return {boolean} Whether all necessary Promptlets have been answered
*/
isSatisfied() {
for(const promptlet of this.PromptletSet) {
if(promptlet.info.required && !promptlet.satisfied) return false;
}
return true;
}
/**
* Toggle or set whether or not to confirm that the user is done before terminating the PromptSet
* @param {string|number} [mode = PromptSet.finishModes[3]] The finish mode to use. See [PromptSet#finishModes]{@link PromptSet.finishModes} for details
* @return {PromptSet} Returns 'this' PromptSet for chaining
* @throws {RangeError} Index out of bounds for finish mode array
* @throws {TypeError} Throws if mode is not a string, a number, or a number string
*/
setFinishMode(mode) {
if(typeof mode === "number" || !isNaN(Number(mode))) {
mode = Math.trunc(Number(mode));
if(mode < 0 || mode >= PromptSet.finishModes.length) throw new RangeError(`Index Out of Bounds: ${mode}\nExpected 0 to ${PromptSet.finishModes.length}`);
this.finishMode = PromptSet.finishModes[mode];
} else if(typeof mode === "string") {
mode = mode.trim().toLowerCase();
this.finishMode = PromptSet.finishModes.includes(mode) ? mode : PromptSet.finishModes[3];
} else throw new TypeError(`String or Number expected. Received: <Type: ${typeof mode}> (${mode})`);
return this;
}
/**
* Creates a list prompt for the user to select what to answer from the PromptSet
* @async
* @return {Promptlet} Returns the selected Promptlet from the set. Does not take into account prerequisites or editable state [PromptSet.start]{@link PromptSet#start}
*/
async selectPromptlet() {
const choiceList = this.generateList();
if(choiceList.length === 1) return this.searchSet(choiceList[0].value);
const chosenPrompt = await inquirer({
type: "list",
name: "SELECTED_PROMPTLET",
default: this.defaultPosition,
message: "Choose a prompt to answer",
choices: choiceList
});
this.clearConsole();
this.defaultPosition = chosenPrompt["SELECTED_PROMPTLET"];
return this.defaultPosition === this.finishPrompt.name ? this.finishPrompt : this.searchSet(this.defaultPosition);
}
/**
* Generates the list of prompts that are displayed for selection
* @return {Array<{name: string, value: string}>}
*/
generateList() {
const list = this.PromptletSet
.map(promptlet => promptlet.generateListing(this.prereqSatisfied(promptlet, true)));
if(this.isSatisfied() && (this.finishMode === PromptSet.finishModes[0] || this.finishMode === PromptSet.finishModes[2])) {
this.finishPrompt.satisfied = false;
list.push(this.finishPrompt.generateListing(this.prereqSatisfied(this.finishPrompt, true)));
}
return list;
}
/**
* Clears console if PromptSet.autoclear is set to a truthy value
*/
clearConsole() {
if(this.autoclear) console.clear();
}
/**
* Returns whether to close the list (True after everything needed to be answered has been answered)
* @async
* @return {boolean} Whether to end execution
*/
async isFinished() {
if(!this.isSatisfied()) return false;
switch(this.finishMode) {
case PromptSet.finishModes[0]:
case PromptSet.finishModes[1]:
let finish = this.PromptletSet.every(promptlet => {
return promptlet.satisfied && !promptlet.editable;
});
if(finish) return true;
finish = await this.finishPrompt.start(this.reduce());
this.clearConsole();
return finish;
case PromptSet.finishModes[2]:
return this.finishPrompt.value;
case PromptSet.finishModes[3]:
return true;
}
}
/**
* Starts up the PromptSet and finishes once all the necessary questions are answered
* @return {Promise<Object>} Returns a Promise that resolves to the result of [PromptSet.reduce]{@link PromptSet#reduce}
*/
start() {
if(this.PromptletSet.length === 0) throw new RangeError("Cannot start an empty PromptSet");
return new Promise(async resolve => {
let skipCheck = false;
while(skipCheck || !await this.isFinished()) {
skipCheck = false;
const chosenPromptlet = await this.selectPromptlet();
if(chosenPromptlet.satisfied && !chosenPromptlet.editable) {
this.clearConsole();
console.log("Prompt Already Answered. (Editing this prompt is disabled)");
skipCheck = true;
continue;
}
if(!this.prereqSatisfied(chosenPromptlet) || (chosenPromptlet === this.finishPrompt && this.finishMode === PromptSet.finishModes[0])) continue;
await chosenPromptlet.start(this.reduce());
this.clearConsole();
}
this.satisfied = true;
resolve(this.reduce());
});
}
/**
* Collects the values of every Promptlet into an Object.<br>
* Note: Skips unanswered Promptlets
* @return {Object} All results in "name: value" pairs
*/
reduce() {
return this.PromptletSet
.filter(promptlet => promptlet.satisfied)
.reduce((acc, promptlet) => {
acc[promptlet.name] = promptlet.value;
return acc;
}, {});
}
/**
* Returns the JSON.stringify() version of [PromptSet.reduce]{@link PromptSet#reduce}
* @return {string} JSON with all the values as a string. Parse with JSON.parse() if needed or use for debugging
*/
toString() {
return JSON.stringify(this.reduce());
}
}
module.exports = PromptSet;