Skip to content

Commit

Permalink
feat(inquirer): export types Question, DistinctQuestion and Answers (#…
Browse files Browse the repository at this point in the history
…1559)

---------

Co-authored-by: Simon Boudrias <admin@simonboudrias.com>
  • Loading branch information
mshima and SBoudrias authored Sep 27, 2024
1 parent eeffe0c commit 66a675e
Show file tree
Hide file tree
Showing 4 changed files with 106 additions and 26 deletions.
77 changes: 72 additions & 5 deletions packages/inquirer/inquirer.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ import { vi, expect, beforeEach, afterEach, describe, it, expectTypeOf } from 'v
import { of } from 'rxjs';
import { AbortPromptError, createPrompt } from '@inquirer/core';
import type { InquirerReadline } from '@inquirer/type';
import inquirer, { type QuestionMap } from './src/index.mjs';
import type { Answers } from './src/types.mjs';
import inquirer from './src/index.mjs';
import type { QuestionMap, Answers, Question, DistinctQuestion } from './src/index.mjs';
import { _ } from './src/ui/prompt.mjs';

declare module './src/index.mjs' {
Expand Down Expand Up @@ -58,7 +58,7 @@ class StubFailingPrompt {
close() {}
}

class StubEventualyFailingPrompt {
class StubEventuallyFailingPrompt {
timeout?: NodeJS.Timeout;

run() {
Expand All @@ -80,6 +80,73 @@ beforeEach(() => {
inquirer.registerPrompt('failing', StubFailingPrompt);
});

describe('exported types', () => {
it('Question type is not any', () => {
expectTypeOf({}).not.toMatchTypeOf<Question>();
});

it('exported Question type requires type, name and message', () => {
const question = {
type: 'input',
name: 'q1',
message: 'message',
} as const;
expectTypeOf(question).toMatchTypeOf<Question>();
expectTypeOf(question).toMatchTypeOf<DistinctQuestion>();
expectTypeOf({ name: 'q1', message: 'message' }).not.toMatchTypeOf<Question>();
expectTypeOf({ type: 'stub', message: 'message' }).not.toMatchTypeOf<Question>();
expectTypeOf({ type: 'stub', name: 'q1' }).not.toMatchTypeOf<Question>();
});

it('Exported types can be used with "as const satisfies Question" to keep prompt type inference', async () => {
const question = {
type: 'stub',
name: 'q1',
message: 'message',
} as const satisfies Question;
expectTypeOf(await inquirer.prompt(question)).toEqualTypeOf<{ q1: any }>();

const questions = [
{
type: 'stub',
name: 'q1',
message: 'message',
},
{
type: 'stub',
name: 'q2',
message: 'message',
},
] as const satisfies Question[];
expectTypeOf(await inquirer.prompt(questions)).toEqualTypeOf<{ q1: any; q2: any }>();

const questions2 = [
{
type: 'input',
name: 'q1',
message: 'message',
when: false,
},
{
type: 'password',
name: 'q2',
message: 'message',
when: false,
},
] as const satisfies DistinctQuestion[];
expectTypeOf(await inquirer.prompt(questions2)).toEqualTypeOf<{ q1: any; q2: any }>();
});

it('exported Answers type is not any', () => {
expectTypeOf(false).not.toMatchTypeOf<Answers>();
});

it('exported Answers type matches any object', () => {
expectTypeOf({}).toMatchTypeOf<Answers>();
expectTypeOf({ foo: 'bar' }).toMatchTypeOf<Answers>();
});
});

describe('inquirer.prompt(...)', () => {
describe('interfaces', () => {
it('takes a prompts array', async () => {
Expand Down Expand Up @@ -791,7 +858,7 @@ describe('AbortSignal support', () => {
const localPrompt = inquirer.createPromptModule<TestQuestions>({
signal: AbortSignal.abort(),
});
localPrompt.registerPrompt('stub', StubEventualyFailingPrompt);
localPrompt.registerPrompt('stub', StubEventuallyFailingPrompt);

const promise = localPrompt({ type: 'stub', name: 'q1', message: 'message' });
await expect(promise).rejects.toThrow(AbortPromptError);
Expand All @@ -802,7 +869,7 @@ describe('AbortSignal support', () => {
const localPrompt = inquirer.createPromptModule<TestQuestions>({
signal: abortController.signal,
});
localPrompt.registerPrompt('stub', StubEventualyFailingPrompt);
localPrompt.registerPrompt('stub', StubEventuallyFailingPrompt);

const promise = localPrompt({ type: 'stub', name: 'q1', message: 'message' });
setTimeout(() => abortController.abort(), 0);
Expand Down
18 changes: 13 additions & 5 deletions packages/inquirer/src/index.mts
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,20 @@ import type {
import type {
Answers,
CustomQuestion,
BuiltInQuestion,
UnnamedDistinctQuestion,
StreamOptions,
QuestionMap,
PromptSession,
} from './types.mjs';
import { Observable } from 'rxjs';

export type { QuestionMap } from './types.mjs';
export type {
QuestionMap,
Question,
DistinctQuestion,
Answers,
PromptSession,
} from './types.mjs';

const builtInPrompts: PromptCollection = {
input,
Expand All @@ -60,8 +66,10 @@ type PromptReturnType<T> = Promise<Prettify<T>> & {
export function createPromptModule<
Prompts extends Record<string, Record<string, unknown>> = never,
>(opt?: StreamOptions) {
type Question<A extends Answers> = BuiltInQuestion<A> | CustomQuestion<A, Prompts>;
type NamedQuestion<A extends Answers> = Question<A> & {
type SpecificQuestion<A extends Answers> =
| UnnamedDistinctQuestion<A>
| CustomQuestion<A, Prompts>;
type NamedQuestion<A extends Answers> = SpecificQuestion<A> & {
name: Extract<keyof A, string>;
};
function promptModule<
Expand All @@ -76,7 +84,7 @@ export function createPromptModule<
PrefilledAnswers extends Answers = object,
>(
questions: {
[name in keyof A]: Question<Prettify<PrefilledAnswers & A>>;
[name in keyof A]: SpecificQuestion<Prettify<PrefilledAnswers & A>>;
},
answers?: PrefilledAnswers,
): PromptReturnType<Prettify<PrefilledAnswers & Answers<Extract<keyof A, string>>>>;
Expand Down
19 changes: 12 additions & 7 deletions packages/inquirer/src/types.mts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export interface QuestionMap {
type KeyValueOrAsyncGetterFunction<T, k extends string, A extends Answers> =
T extends Record<string, any> ? T[k] | AsyncGetterFunction<T[k], A> : never;

export type AnyQuestion<A extends Answers, Type extends string = string> = {
export type Question<A extends Answers = Answers, Type extends string = string> = {
type: Type;
name: string;
message: string | AsyncGetterFunction<string, A>;
Expand Down Expand Up @@ -75,7 +75,7 @@ type QuestionWithGetters<
}
>;

export type BuiltInQuestion<A extends Answers = object> =
export type UnnamedDistinctQuestion<A extends Answers = object> =
| QuestionWithGetters<'checkbox', Parameters<typeof checkbox>[0], A>
| QuestionWithGetters<'confirm', Parameters<typeof confirm>[0], A>
| QuestionWithGetters<'editor', Parameters<typeof editor>[0], A>
Expand All @@ -89,17 +89,22 @@ export type BuiltInQuestion<A extends Answers = object> =
| QuestionWithGetters<'list', Parameters<typeof select>[0], A>
| QuestionWithGetters<'select', Parameters<typeof select>[0], A>;

export type DistinctQuestion<A extends Answers = Answers> = Prettify<
UnnamedDistinctQuestion<A> & {
name: Extract<keyof A, string>;
}
>;

export type CustomQuestion<
A extends Answers,
Q extends Record<string, Record<string, any>>,
> = {
[key in Extract<keyof Q, string>]: Readonly<QuestionWithGetters<key, Q[key], A>>;
}[Extract<keyof Q, string>];

export type PromptSession<A extends Answers> =
| AnyQuestion<A>[]
| Record<string, Omit<AnyQuestion<A>, 'name'>>
| Observable<AnyQuestion<A>>
| AnyQuestion<A>;
export type PromptSession<
A extends Answers = Answers,
Q extends Question<A> = Question<A>,
> = Q[] | Record<string, Omit<Q, 'name'>> | Observable<Q> | Q;

export type StreamOptions = Prettify<Context & { skipTTYChecks?: boolean }>;
18 changes: 9 additions & 9 deletions packages/inquirer/src/ui/prompt.mts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import type { InquirerReadline } from '@inquirer/type';
import ansiEscapes from 'ansi-escapes';
import type {
Answers,
AnyQuestion,
Question,
AsyncGetterFunction,
PromptSession,
StreamOptions,
Expand Down Expand Up @@ -66,7 +66,7 @@ export const _ = {
async function fetchAsyncQuestionProperty<
A extends Answers,
Prop extends keyof Q,
Q extends AnyQuestion<A>,
Q extends Question<A>,
>(
question: Q,
prop: Prop,
Expand Down Expand Up @@ -162,13 +162,13 @@ function setupReadlineOptions(opt: StreamOptions) {

function isQuestionArray<A extends Answers>(
questions: PromptSession<A>,
): questions is AnyQuestion<A>[] {
): questions is Question<A>[] {
return Array.isArray(questions);
}

function isQuestionMap<A extends Answers>(
questions: PromptSession<A>,
): questions is Record<string, Omit<AnyQuestion<A>, 'name'>> {
): questions is Record<string, Omit<Question<A>, 'name'>> {
return Object.values(questions).every(
(maybeQuestion) =>
typeof maybeQuestion === 'object' &&
Expand Down Expand Up @@ -209,15 +209,15 @@ export default class PromptsRunner<A extends Answers> {
// Keep global reference to the answers
this.answers = typeof answers === 'object' ? { ...answers } : {};

let obs: Observable<AnyQuestion<A>>;
let obs: Observable<Question<A>>;
if (isQuestionArray(questions)) {
obs = from(questions);
} else if (isObservable(questions)) {
obs = questions;
} else if (isQuestionMap(questions)) {
// Case: Called with a set of { name: question }
obs = from(
Object.entries(questions).map(([name, question]): AnyQuestion<A> => {
Object.entries(questions).map(([name, question]): Question<A> => {
return Object.assign({}, question, { name });
}),
);
Expand Down Expand Up @@ -256,7 +256,7 @@ export default class PromptsRunner<A extends Answers> {
.finally(() => this.close());
}

private prepareQuestion = async (question: AnyQuestion<A>) => {
private prepareQuestion = async (question: Question<A>) => {
const [message, defaultValue, resolvedChoices] = await Promise.all([
fetchAsyncQuestionProperty(question, 'message', this.answers),
fetchAsyncQuestionProperty(question, 'default', this.answers),
Expand Down Expand Up @@ -288,7 +288,7 @@ export default class PromptsRunner<A extends Answers> {
});
};

private fetchAnswer = async (rawQuestion: AnyQuestion<A>) => {
private fetchAnswer = async (rawQuestion: Question<A>) => {
const question = await this.prepareQuestion(rawQuestion);
const prompt = this.prompts[question.type];

Expand Down Expand Up @@ -387,7 +387,7 @@ export default class PromptsRunner<A extends Answers> {
this.abortController?.abort();
};

private shouldRun = async (question: AnyQuestion<A>): Promise<boolean> => {
private shouldRun = async (question: Question<A>): Promise<boolean> => {
if (
question.askAnswered !== true &&
_.get(this.answers, question.name) !== undefined
Expand Down

0 comments on commit 66a675e

Please sign in to comment.