diff --git a/README.md b/README.md index fefe29b..5991a7d 100644 --- a/README.md +++ b/README.md @@ -54,18 +54,21 @@ Simple prompt, similar to `rl.question()` with an improved UI. Use `options.secure` if you need to hide both input and answer. -Use `options.validators` to handle user input. - Use `options.signal` to set an `AbortSignal` (throws a [AbortError](#aborterror)). +Use `options.validators` to handle user input. + **Example** ```js const packageName = await question('Package name', { validators: [ { - validate: (value) => !existsSync(join(process.cwd(), value)), - error: (value) => `Folder ${value} already exists` + validate: (value) => { + if (!existsSync(join(process.cwd(), value))) { + return `Folder ${value} already exists` + } + } } ] }); diff --git a/src/prompts/multiselect.ts b/src/prompts/multiselect.ts index 6b131ae..068b222 100644 --- a/src/prompts/multiselect.ts +++ b/src/prompts/multiselect.ts @@ -9,7 +9,7 @@ import wcwidth from "@topcli/wcwidth"; import { AbstractPrompt, AbstractPromptOptions } from "./abstract.js"; import { stripAnsi } from "../utils.js"; import { SYMBOLS } from "../constants.js"; -import { PromptValidator } from "../validators.js"; +import { isValid, PromptValidator, resultError } from "../validators.js"; import { Choice } from "../types.js"; // CONSTANTS @@ -247,9 +247,10 @@ export class MultiselectPrompt extends AbstractPrompt { }); for (const validator of this.#validators) { - if (!validator.validate(values)) { - const error = validator.error(values); - render({ error }); + const validationResult = validator.validate(values); + + if (isValid(validationResult) === false) { + render({ error: resultError(validationResult) }); return; } diff --git a/src/prompts/question.ts b/src/prompts/question.ts index 5bbacd6..92b1d11 100644 --- a/src/prompts/question.ts +++ b/src/prompts/question.ts @@ -9,7 +9,7 @@ import wcwidth from "@topcli/wcwidth"; import { AbstractPrompt, AbstractPromptOptions } from "./abstract.js"; import { stripAnsi } from "../utils.js"; import { SYMBOLS } from "../constants.js"; -import { PromptValidator } from "../validators.js"; +import { isValid, PromptValidator, resultError } from "../validators.js"; export interface QuestionOptions extends AbstractPromptOptions { defaultValue?: string; @@ -86,9 +86,10 @@ export class QuestionPrompt extends AbstractPrompt { this.stdout.clearScreenDown(); for (const validator of this.#validators) { - if (!validator.validate(this.answer!)) { - const error = validator.error(this.answer!); - this.#setQuestionSuffixError(error); + const validationResult = validator.validate(this.answer!); + + if (isValid(validationResult) === false) { + this.#setQuestionSuffixError(resultError(validationResult)); this.answerBuffer = this.#question(); return; diff --git a/src/prompts/select.ts b/src/prompts/select.ts index 181e5f7..cacd01e 100644 --- a/src/prompts/select.ts +++ b/src/prompts/select.ts @@ -9,7 +9,7 @@ import wcwidth from "@topcli/wcwidth"; import { AbstractPrompt, AbstractPromptOptions } from "./abstract.js"; import { stripAnsi } from "../utils.js"; import { SYMBOLS } from "../constants.js"; -import { PromptValidator } from "../validators.js"; +import { isValid, PromptValidator, resultError } from "../validators.js"; import { Choice } from "../types.js"; // CONSTANTS @@ -199,9 +199,10 @@ export class SelectPrompt extends AbstractPrompt { const value = typeof choice === "string" ? choice : choice.value; for (const validator of this.#validators) { - if (!validator.validate(value)) { - const error = validator.error(value); - render({ error }); + const validationResult = validator.validate(value); + + if (isValid(validationResult) === false) { + render({ error: resultError(validationResult) }); return; } diff --git a/src/validators.ts b/src/validators.ts index 39b0aaa..d78060f 100644 --- a/src/validators.ts +++ b/src/validators.ts @@ -1,11 +1,45 @@ +export type ValidResponseObject = { + isValid?: true; +} +export type InvalidResponseObject = { + isValid: false; + error: string; +} +export type ValidationResponseObject = ValidResponseObject | InvalidResponseObject; +export type ValidationResponse = InvalidResponse | ValidResponse; +export type InvalidResponse = string | InvalidResponseObject; +export type ValidResponse = null | undefined | true | ValidResponseObject; + export interface PromptValidator { - validate: (input: T) => boolean; - error: (input: T) => string; + validate: (input: T) => ValidationResponse; } export function required(): PromptValidator { return { - validate: (input) => (Array.isArray(input) ? input.length > 0 : Boolean(input)), - error: () => "required" + validate: (input) => { + const isValid = (Array.isArray(input) ? input.length > 0 : Boolean(input)); + + return isValid ? null : { isValid, error: "required" }; + } }; } + +export function isValid(result: ValidationResponse): result is ValidResponse { + if (typeof result === "object") { + return result?.isValid !== false; + } + + if (typeof result === "string") { + return result.length > 0; + } + + return true; +} + +export function resultError(result: InvalidResponse) { + if (typeof result === "object") { + return result.error; + } + + return result; +} diff --git a/test/question-prompt.test.ts b/test/question-prompt.test.ts index 5be35f5..2fd4f23 100644 --- a/test/question-prompt.test.ts +++ b/test/question-prompt.test.ts @@ -66,8 +66,14 @@ describe("QuestionPrompt", () => { const questionPrompt = await TestingPrompt.QuestionPrompt("What's your name?", { input: ["test1", "test10", "test2"], validators: [{ - validate: (input) => !(input as string).startsWith("test1"), - error: (input) => `Value cannot start with 'test1', given ${input}.` + validate: (input) => { + const isValid = !(input as string).startsWith("test1"); + if (!isValid) { + return { isValid, error: `Value cannot start with 'test1', given ${input}.` }; + } + + return void 0; + } }], onStdoutWrite: (log) => logs.push(log) }); diff --git a/test/validators.test.ts b/test/validators.test.ts new file mode 100644 index 0000000..4b192f4 --- /dev/null +++ b/test/validators.test.ts @@ -0,0 +1,65 @@ +// Import Node.js Dependencies +import assert from "node:assert"; +import { describe, it } from "node:test"; + +import { InvalidResponseObject, isValid, resultError, ValidationResponseObject } from "../src/validators.js"; + +describe("Validators", () => { + describe("isValid", () => { + const testCases = [ + { + input: "test", + expected: true + }, + { + input: "", + expected: false + }, + { + input: null, + expected: true + }, + { + input: undefined, + expected: true + }, + { + input: { isValid: true } as ValidationResponseObject, + expected: true + }, + { + input: { isValid: false, error: "boo" } as ValidationResponseObject, + expected: false + } + ]; + + for (const testCase of testCases) { + it(`given '${formatInput(testCase.input)}', it should return ${testCase.expected}`, () => { + assert.strictEqual(isValid(testCase.input), testCase.expected); + }); + } + }); + + describe("resultError", () => { + it("should return the error message given an object", () => { + const error = "required"; + const result: InvalidResponseObject = { isValid: false, error }; + + assert.strictEqual(resultError(result), error); + }); + + it("should return the error message given a string", () => { + const error = "required"; + + assert.strictEqual(resultError(error), error); + }); + }); +}); + +function formatInput(input) { + if (typeof input === "object") { + return JSON.stringify(input); + } + + return input; +}