Skip to content

Commit 0c1cc54

Browse files
committed
refactor(prompt): refactor prompt cli to use utils
1 parent e75ce8d commit 0c1cc54

File tree

13 files changed

+480
-558
lines changed

13 files changed

+480
-558
lines changed

@commitlint/prompt/fixtures/cli.js

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
const {prompter} = require("../lib");
2+
3+
process.stdin.isTTY = false;
4+
process.stdout.isTTY = false;
5+
prompter(null, (answers) => {
6+
console.log('!--------------------!')
7+
console.log(answers)
8+
})

@commitlint/prompt/package.json

+6-4
Original file line numberDiff line numberDiff line change
@@ -37,14 +37,16 @@
3737
},
3838
"devDependencies": {
3939
"@commitlint/utils": "^11.0.0",
40-
"commitizen": "4.2.2"
40+
"@types/inquirer": "^7.3.1",
41+
"@types/concat-stream": "^1.6.0",
42+
"commitizen": "4.2.2",
43+
"concat-stream": "^2.0.0"
4144
},
4245
"dependencies": {
4346
"@commitlint/load": "^11.0.0",
4447
"chalk": "^4.0.0",
45-
"lodash": "^4.17.19",
46-
"throat": "^5.0.0",
47-
"vorpal": "^1.12.0"
48+
"inquirer": "^7.3.3",
49+
"lodash": "^4.17.19"
4850
},
4951
"gitHead": "cb565dfcca3128380b9b3dc274aedbcae34ce5ca"
5052
}

@commitlint/prompt/src/index.ts

+2-4
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
1-
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
2-
// @ts-ignore
3-
import vorpal from 'vorpal';
1+
import inquirer from 'inquirer';
42
import input from './input';
53

64
type Commit = (input: string) => void;
@@ -12,6 +10,6 @@ type Commit = (input: string) => void;
1210
* @return generated commit message
1311
*/
1412
export async function prompter(_: unknown, commit: Commit): Promise<void> {
15-
const message = await input(vorpal);
13+
const message = await input(inquirer.createPromptModule());
1614
commit(message);
1715
}

@commitlint/prompt/src/input.test.ts

+129
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import {spawn} from 'child_process';
2+
import path from 'path';
3+
import concat from 'concat-stream';
4+
5+
// const DOWN = '\x1B\x5B\x42'
6+
// const UP = '\x1B\x5B\x41'
7+
const ENTER = '\x0D';
8+
9+
async function runCli(args: string[], options: string[], timeout = 200) {
10+
const proc = spawn('node', args, {
11+
stdio: [null, null, null],
12+
env: {
13+
FORCE_COLOR: '0',
14+
},
15+
});
16+
proc.stdin.setDefaultEncoding('utf-8');
17+
18+
const loop = function (opt: string[]) {
19+
if (opt.length > 0) {
20+
setTimeout(function () {
21+
proc.stdin.write(opt[0]);
22+
loop(opt.slice(1));
23+
}, timeout);
24+
} else {
25+
proc.stdin.end();
26+
}
27+
};
28+
29+
loop(options);
30+
31+
return new Promise<string>(function (resolve) {
32+
proc.stdout.pipe(
33+
concat(function (result) {
34+
resolve(result.toString());
35+
})
36+
);
37+
});
38+
}
39+
40+
const cliPath = path.join(__dirname, '..', 'fixtures', 'cli.js');
41+
42+
test('test1', async () => {
43+
const result = await runCli(
44+
[cliPath, '--no-color'],
45+
[
46+
'test',
47+
ENTER,
48+
'test',
49+
ENTER,
50+
'test',
51+
ENTER,
52+
':skip',
53+
ENTER,
54+
':skip',
55+
ENTER,
56+
]
57+
);
58+
expect(result.trim()).toMatchInlineSnapshot(`
59+
"Please enter a type: [required] [tab-completion] [header]
60+
<type> holds information about the goal of a change.
61+
62+
<type>(<scope>): <subject>
63+
<body>
64+
<footer>
65+
66+
67+
? type:
68+
82 characters left? type: t
69+
81 characters left? type: te
70+
80 characters left? type: tes
71+
79 characters left? type: test
72+
78 characters left? type: test
73+
Please enter a scope: [optional] [tab-completion] [header]
74+
<scope> marks which sub-component of the project is affected
75+
76+
test(<scope>): <subject>
77+
<body>
78+
<footer>
79+
80+
81+
? scope:
82+
85 characters left? scope: t
83+
84 characters left? scope: te
84+
83 characters left? scope: tes
85+
82 characters left? scope: test
86+
81 characters left? scope: test
87+
Please enter a subject: [required] [header]
88+
<subject> is a short, high-level description of the change
89+
90+
test(test): <subject>
91+
<body>
92+
<footer>
93+
94+
95+
? subject:
96+
79 characters left? subject: t
97+
78 characters left? subject: te
98+
77 characters left? subject: tes
99+
76 characters left? subject: test
100+
75 characters left? subject: test
101+
Please enter a body: [optional] [multi-line]
102+
<body> holds additional information about the change
103+
104+
test(test): test
105+
<body>
106+
<footer>
107+
108+
109+
? body: ? body: :? body: :s? body: :sk? body: :ski? body: :skip? body: 
110+
Please enter a footer: [optional] [multi-line]
111+
<footer> holds further meta data, such as breaking changes and issue ids
112+
113+
test(test): test
114+
<footer>
115+
116+
117+
? footer: ? footer: :? footer: :s? footer: :sk? footer: :ski? footer: :skip? footer: 
118+
!--------------------!
119+
test(test): test"
120+
`);
121+
});
122+
123+
test('test2', async () => {
124+
const result = await runCli(
125+
[cliPath, '--no-color'],
126+
['foo', ENTER, ':skip', ENTER, 'bar', ENTER, 'baz', ENTER, 'bas', ENTER]
127+
);
128+
expect(result.trim()).toMatchInlineSnapshot();
129+
});

@commitlint/prompt/src/input.ts

+27-38
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,21 @@
11
import load from '@commitlint/load';
2-
import throat from 'throat';
2+
import {DistinctQuestion, PromptModule} from 'inquirer';
33

44
import format from './library/format';
55
import getPrompt from './library/get-prompt';
66
import settings from './settings';
7-
import {InputSetting, Prompter, Result} from './library/types';
8-
import {getHasName, getMaxLength, getRules} from './library/utils';
7+
import {InputSetting, Result} from './library/types';
98

10-
export default input;
9+
import InputCustomPrompt from './inquirer/InputCustomPrompt';
10+
import {getHasName, getMaxLength, getRules} from './library/utils';
1111

1212
/**
1313
* Get user input by interactive prompt based on
1414
* conventional-changelog-lint rules.
1515
* @param prompter
1616
* @return commit message
1717
*/
18-
async function input(prompter: () => Prompter): Promise<string> {
19-
const results: Result = {
20-
type: null,
21-
scope: null,
22-
subject: null,
23-
body: null,
24-
footer: null,
25-
};
26-
18+
export default async function input(prompter: PromptModule): Promise<string> {
2719
const {rules} = await load();
2820
const parts = ['type', 'scope', 'subject', 'body', 'footer'] as const;
2921
const headerParts = ['type', 'scope', 'subject'];
@@ -33,31 +25,28 @@ async function input(prompter: () => Prompter): Promise<string> {
3325
);
3426
const maxLength = getMaxLength(headerLengthRule);
3527

36-
await Promise.all(
37-
parts.map(
38-
throat(1, async (input) => {
39-
const inputRules = getRules(input, rules);
40-
const inputSettings: InputSetting = settings[input];
41-
42-
if (headerParts.includes(input) && maxLength < Infinity) {
43-
inputSettings.header = {
44-
length: maxLength,
45-
};
46-
}
47-
48-
results[input] = await getPrompt(input, {
49-
rules: inputRules,
50-
settings: inputSettings,
51-
results,
52-
prompter,
53-
});
54-
})
55-
)
56-
).catch((err) => {
28+
try {
29+
prompter.registerPrompt('input-custom', InputCustomPrompt);
30+
const questions: DistinctQuestion<Result>[] = [];
31+
32+
for (const input of parts) {
33+
const inputSettings: InputSetting = settings[input];
34+
const inputRules = getRules(input, rules);
35+
if (headerParts.includes(input) && maxLength < Infinity) {
36+
inputSettings.header = {
37+
length: maxLength,
38+
};
39+
}
40+
const question = getPrompt(input, inputRules, inputSettings);
41+
if (question) {
42+
questions.push(question);
43+
}
44+
}
45+
46+
const results = await prompter<Result>(questions);
47+
return format(results);
48+
} catch (err) {
5749
console.error(err);
5850
return '';
59-
});
60-
61-
// Return the results
62-
return format(results);
51+
}
6352
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import {Interface as ReadlineInterface, Key} from 'readline';
2+
3+
import chalk from 'chalk';
4+
import inquirer from 'inquirer';
5+
import InputPrompt from 'inquirer/lib/prompts/input';
6+
import observe from 'inquirer/lib/utils/events';
7+
8+
import SuccessfulPromptStateData = inquirer.prompts.SuccessfulPromptStateData;
9+
import Answers = inquirer.Answers;
10+
import InputCustomOptions = inquirer.InputCustomOptions;
11+
import Validator = inquirer.Validator;
12+
13+
interface KeyDescriptor {
14+
value: string;
15+
key: Key;
16+
}
17+
18+
export default class InputCustomPrompt<
19+
TQuestion extends InputCustomOptions = InputCustomOptions
20+
> extends InputPrompt<TQuestion> {
21+
constructor(
22+
question: TQuestion,
23+
readLine: ReadlineInterface,
24+
answers: Answers
25+
) {
26+
super(question, readLine, answers);
27+
28+
if (this.opt.log) {
29+
console.log(this.opt.log(answers));
30+
}
31+
32+
if (!this.opt.maxLength) {
33+
this.throwParamError('maxLength');
34+
}
35+
36+
const events = observe(this.rl);
37+
events.keypress.subscribe(this.onSpaceKey.bind(this));
38+
39+
this.opt.validate = this.extendedValidate(this.opt.validate);
40+
}
41+
42+
extendedValidate(validate?: Validator<TQuestion>): Validator<TQuestion> {
43+
return (input, answers) => {
44+
if (input.length > this.opt.maxLength(answers)) {
45+
return 'Input contains too many characters!';
46+
}
47+
if (input.length === 0) {
48+
if (!this.opt.required) {
49+
return chalk.blue(
50+
`ℹ Enter ${chalk.bold(':skip')} to omit ${chalk.bold(
51+
this.opt.name
52+
)}.`
53+
);
54+
} else {
55+
// Show help if enum is defined and input may not be empty
56+
return `⚠ ${chalk.bold(this.opt.name)} may not be empty.`;
57+
}
58+
}
59+
60+
if (validate) {
61+
return validate(input, answers);
62+
}
63+
return true;
64+
};
65+
}
66+
67+
onSpaceKey(e: KeyDescriptor): void {
68+
// console.log(e)
69+
if (e.key.name === 'tab') {
70+
this.rl.write(' ', {ctrl: true, name: 'b'});
71+
// console.log(this.rl.line, '\n')
72+
// console.log(this.getQuestion() + '\n')
73+
// this.answer = this.answer.slice(0, this.answer.length - 1)
74+
}
75+
// process.exit(1)
76+
}
77+
78+
onEnd(eventArgs: SuccessfulPromptStateData): void {
79+
if (!this.opt.required) {
80+
if (eventArgs.value === ':skip') {
81+
eventArgs.value = '';
82+
}
83+
}
84+
super.onEnd(eventArgs);
85+
}
86+
87+
measureInput(input: string): number {
88+
if (this.opt.filter) {
89+
return this.opt.filter(input, this.answers).length;
90+
}
91+
return input.length;
92+
}
93+
94+
render(error?: string): void {
95+
const answered = this.status === 'answered';
96+
97+
let bottomContent = '';
98+
let message = this.getQuestion();
99+
const length = this.measureInput(this.rl.line);
100+
101+
if (answered) {
102+
message += chalk.cyan(this.answer);
103+
} else if (this.opt.transformer) {
104+
message += this.opt.transformer(this.rl.line, this.answers, {});
105+
}
106+
107+
if (error) {
108+
bottomContent = chalk.red('>> ') + error;
109+
} else if (!answered) {
110+
const maxLength = this.opt.maxLength(this.answers);
111+
if (maxLength < Infinity) {
112+
const lengthRemaining = maxLength - length;
113+
const color =
114+
lengthRemaining <= 5
115+
? chalk.red
116+
: lengthRemaining <= 10
117+
? chalk.yellow
118+
: chalk.grey;
119+
bottomContent = color(`${lengthRemaining} characters left`);
120+
}
121+
}
122+
123+
this.screen.render(message, bottomContent);
124+
}
125+
}

0 commit comments

Comments
 (0)