Skip to content

Commit

Permalink
Fix(inquirer): Improve ReturnType of inquirer.prompt (not perfect, bu…
Browse files Browse the repository at this point in the history
…t better)
  • Loading branch information
SBoudrias committed Jul 8, 2024
1 parent a9ee70f commit df13190
Show file tree
Hide file tree
Showing 4 changed files with 65 additions and 60 deletions.
17 changes: 8 additions & 9 deletions packages/inquirer/src/index.mts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
editor,
Separator,
} from '@inquirer/prompts';
import type { Prettify } from '@inquirer/type';
import { default as PromptsRunner } from './ui/prompt.mjs';
import type {
PromptCollection,
Expand Down Expand Up @@ -48,23 +49,21 @@ const defaultPrompts: PromptCollection = {
* Create a new self-contained prompt module.
*/
export function createPromptModule(opt?: StreamOptions) {
function promptModule<T extends Answers = Answers>(
function promptModule<T extends Answers>(
questions:
| Question<T>
| QuestionArray<T>
| QuestionAnswerMap<T>
| QuestionObservable<T>
| QuestionArray<T>,
| Question<T>,
answers?: Partial<T>,
): Promise<T> & { ui: PromptsRunner } {
): Promise<Prettify<T>> & { ui: PromptsRunner<T> } {
const runner = new PromptsRunner<T>(promptModule.prompts, opt);

try {
return runner.run(questions, answers);
} catch (error) {
const promise = Promise.reject(error);
// @ts-expect-error Monkey patch the UI on the promise object so
promise.ui = runner;
return promise as Promise<never> & { ui: PromptsRunner };
const promise = Promise.reject<T>(error);
return Object.assign(promise, { ui: runner });
}
}

Expand All @@ -85,7 +84,7 @@ export function createPromptModule(opt?: StreamOptions) {
* Register the defaults provider prompts
*/
promptModule.restoreDefaultPrompts = function () {
this.prompts = { ...defaultPrompts };
promptModule.prompts = { ...defaultPrompts };
};

promptModule.restoreDefaultPrompts();
Expand Down
62 changes: 37 additions & 25 deletions packages/inquirer/src/types.mts
Original file line number Diff line number Diff line change
Expand Up @@ -13,43 +13,55 @@ import {
import type { Prettify } from '@inquirer/type';
import { Observable } from 'rxjs';

export type Answers = { [key: string]: any };

interface QuestionMap {
input: { type: 'input' } & Parameters<typeof input>[0];
select: { type: 'select' } & Parameters<typeof select>[0];
/** @deprecated Prompt type `list` is renamed to `select` */
list: { type: 'list' } & Parameters<typeof select>[0];
number: { type: 'number' } & Parameters<typeof number>[0];
confirm: { type: 'confirm' } & Parameters<typeof confirm>[0];
rawlist: { type: 'rawlist' } & Parameters<typeof rawlist>[0];
expand: { type: 'expand' } & Parameters<typeof expand>[0];
checkbox: { type: 'checkbox' } & Parameters<typeof checkbox>[0];
password: { type: 'password' } & Parameters<typeof password>[0];
editor: { type: 'editor' } & Parameters<typeof editor>[0];
}
// eslint-disable-next-line @typescript-eslint/ban-types
type LiteralUnion<T extends F, F = string> = T | (F & {});
type KeyUnion<T> = LiteralUnion<Extract<keyof T, string>>;

export type Answers = {
[key: string]: any;
};

type whenFunction<T extends Answers = Answers> =
type whenFunction<T extends Answers> =
| ((answers: Partial<T>) => boolean | Promise<boolean>)
| ((this: { async: () => () => void }, answers: Partial<T>) => void);

type InquirerFields<T extends Answers = Answers> = {
name: keyof T;
type InquirerFields<T extends Answers> = {
name: KeyUnion<T>;
when?: boolean | whenFunction<T>;
askAnswered?: boolean;
};

export type Question<T extends Answers = Answers> = QuestionMap[keyof QuestionMap] &
InquirerFields<T>;
interface QuestionMap<T extends Answers> {
input: Prettify<{ type: 'input' } & Parameters<typeof input>[0] & InquirerFields<T>>;
select: Prettify<{ type: 'select' } & Parameters<typeof select>[0] & InquirerFields<T>>;
list: Prettify<{ type: 'list' } & Parameters<typeof select>[0] & InquirerFields<T>>;
number: Prettify<{ type: 'number' } & Parameters<typeof number>[0] & InquirerFields<T>>;
confirm: Prettify<
{ type: 'confirm' } & Parameters<typeof confirm>[0] & InquirerFields<T>
>;
rawlist: Prettify<
{ type: 'rawlist' } & Parameters<typeof rawlist>[0] & InquirerFields<T>
>;
expand: Prettify<{ type: 'expand' } & Parameters<typeof expand>[0] & InquirerFields<T>>;
checkbox: Prettify<
{ type: 'checkbox' } & Parameters<typeof checkbox>[0] & InquirerFields<T>
>;
password: Prettify<
{ type: 'password' } & Parameters<typeof password>[0] & InquirerFields<T>
>;
editor: Prettify<{ type: 'editor' } & Parameters<typeof editor>[0] & InquirerFields<T>>;
}

export type Question<T extends Answers> = QuestionMap<T>[keyof QuestionMap<T>];

export type QuestionAnswerMap<T extends Answers = Answers> = Record<
keyof T,
Omit<Question<T>, 'name'>
export type QuestionAnswerMap<T extends Answers> = Record<
KeyUnion<T>,
Prettify<Omit<Question<T>, 'name'>>
>;

export type QuestionArray<T extends Answers = Answers> = Array<Question<T>>;
export type QuestionArray<T extends Answers> = Question<T>[];

export type QuestionObservable<T extends Answers = Answers> = Observable<Question<T>>;
export type QuestionObservable<T extends Answers> = Observable<Question<T>>;

export type StreamOptions = Prettify<
Parameters<typeof input>[1] & { skipTTYChecks?: boolean }
Expand Down
20 changes: 9 additions & 11 deletions packages/inquirer/src/ui/prompt.mts
Original file line number Diff line number Diff line change
Expand Up @@ -161,8 +161,8 @@ function setupReadlineOptions(opt: StreamOptions = {}) {
};
}

function isQuestionMap<T extends Answers = Answers>(
questions: Question<T> | QuestionAnswerMap<T> | QuestionArray<T>,
function isQuestionMap<T extends Answers>(
questions: QuestionArray<T> | QuestionAnswerMap<T> | Question<T>,
): questions is QuestionAnswerMap<T> {
return Object.values(questions).every(
(maybeQuestion) =>
Expand All @@ -185,7 +185,7 @@ function isPromptConstructor(
/**
* Base interface class other can inherits from
*/
export default class PromptsRunner<T extends Answers = Answers> extends Base {
export default class PromptsRunner<T extends Answers> extends Base {
prompts: PromptCollection;
answers: Partial<T> = {};
process: Observable<any>;
Expand All @@ -202,12 +202,12 @@ export default class PromptsRunner<T extends Answers = Answers> extends Base {

run(
questions:
| Question<T>
| QuestionArray<T>
| QuestionAnswerMap<T>
| QuestionObservable<T>
| QuestionArray<T>,
| Question<T>,
answers?: Partial<T>,
): Promise<T> & { ui: PromptsRunner } {
): Promise<T> & { ui: PromptsRunner<T> } {
// Keep global reference to the answers
this.answers = typeof answers === 'object' ? { ...answers } : {};

Expand Down Expand Up @@ -244,11 +244,9 @@ export default class PromptsRunner<T extends Answers = Answers> extends Base {
).then(
() => this.onCompletion(),
(error) => this.onError(error),
);
) as Promise<T>;

// @ts-expect-error Monkey patch the UI on the promise object so
promise.ui = this;
return promise as Promise<T> & { ui: PromptsRunner };
return Object.assign(promise, { ui: this });
}

/**
Expand Down Expand Up @@ -386,7 +384,7 @@ export default class PromptsRunner<T extends Answers = Answers> extends Base {
}
return;
}),
).pipe(filter((val): val is Question => val != null)),
).pipe(filter((val): val is Question<T> => val != null)),
);
}
}
26 changes: 11 additions & 15 deletions packages/inquirer/test/inquirer.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -156,12 +156,10 @@ describe('inquirer.prompt', () => {
});

it('should take a single prompt and return answer', async () => {
const config = {
const answers = await inquirer.prompt({
type: 'stub',
name: 'q1',
};

const answers = await inquirer.prompt(config);
});
expect(answers).toEqual({ q1: 'bar' });
});

Expand Down Expand Up @@ -352,7 +350,7 @@ describe('inquirer.prompt', () => {
}
inquirer.registerPrompt('stubSelect', FakeSelect);

const prompts = [
await inquirer.prompt([
{
type: 'stub',
name: 'name1',
Expand All @@ -363,19 +361,18 @@ describe('inquirer.prompt', () => {
type: 'stubSelect',
name: 'name',
message: 'message',
choices(answers) {
choices(answers: { name1: string }) {
expect(answers.name1).toEqual('bar');
return stubChoices;
},
},
];

await inquirer.prompt(prompts);
]);
});

it('should expose the Reactive interface', async () => {
const spy = vi.fn();
const prompts = [

const promise = inquirer.prompt([
{
type: 'stub',
name: 'name1',
Expand All @@ -388,9 +385,7 @@ describe('inquirer.prompt', () => {
message: 'message',
answer: 'doe',
},
];

const promise = inquirer.prompt(prompts);
]);
promise.ui.process.subscribe(spy);

await promise;
Expand Down Expand Up @@ -616,14 +611,15 @@ describe('inquirer.prompt', () => {
});

it('should not run prompt if nested answer exists for question', async () => {
const answers = await inquirer.prompt(
const answers = await inquirer.prompt<{ prefilled: { nested: string } }>(
[
{
type: 'input',
name: 'prefilled.nested',
when: throwFunc.bind(undefined, 'when'),
validate: throwFunc.bind(undefined, 'validate'),
transformer: throwFunc.bind(undefined, 'transformer'),
// @ts-expect-error ignoring this unused field for test purpose
filter: throwFunc.bind(undefined, 'filter'),
message: 'message',
default: 'newValue',
Expand All @@ -633,7 +629,7 @@ describe('inquirer.prompt', () => {
prefilled: { nested: 'prefilled' },
},
);
expect(answers['prefilled'].nested).toEqual('prefilled');
expect(answers.prefilled.nested).toEqual('prefilled');
});

it('should run prompt if answer exists for question and askAnswered is set', async () => {
Expand Down

0 comments on commit df13190

Please sign in to comment.