Skip to content

Commit c595c0e

Browse files
author
DimitarTachev
committed
feat: interactive app creation
1 parent 017c415 commit c595c0e

13 files changed

+330
-56
lines changed

lib/commands/create-project.ts

+186-10
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,220 @@
11
import * as constants from "../constants";
22
import * as path from "path";
3+
import { isInteractive } from "../common/helpers";
34

45
export class CreateProjectCommand implements ICommand {
56
public enableHooks = false;
6-
public allowedParameters: ICommandParameter[] = [this.$stringParameterBuilder.createMandatoryParameter("Project name cannot be empty.")];
7+
public allowedParameters: ICommandParameter[] = [this.$stringParameter];
8+
private static NgFlavor = "Angular";
9+
private static VueFlavor = "Vue.js";
10+
private static TsFlavor = "Plain TypeScript";
11+
private static JsFlavor = "Plain JavaScript";
12+
private static HelloWorldTemplateKey = "Hello World";
13+
private static HelloWorldTemplateDescription = "A Hello World app";
14+
private static DrawerTemplateKey = "SideDrawer";
15+
private static DrawerTemplateDescription = "An app with pre-built pages that uses a drawer for navigation";
16+
private static TabsTemplateKey = "Tabs";
17+
private static TabsTemplateDescription = "An app with pre-built pages that uses tabs for navigation";
718

8-
private createdProjecData: ICreateProjectData;
19+
private createdProjectData: ICreateProjectData;
920

1021
constructor(private $projectService: IProjectService,
1122
private $logger: ILogger,
1223
private $errors: IErrors,
1324
private $options: IOptions,
14-
private $stringParameterBuilder: IStringParameterBuilder) { }
25+
private $prompter: IPrompter,
26+
private $stringParameter: ICommandParameter) { }
1527

1628
public async execute(args: string[]): Promise<void> {
17-
if ((this.$options.tsc || this.$options.ng) && this.$options.template) {
18-
this.$errors.fail("You cannot use --ng or --tsc options together with --template.");
29+
const interactiveAdverbs = ["First", "Next", "Finally"];
30+
const getNextInteractiveAdverb = () => {
31+
return interactiveAdverbs.shift() || "Next";
32+
};
33+
34+
if ((this.$options.tsc || this.$options.ng || this.$options.vue || this.$options.js) && this.$options.template) {
35+
this.$errors.fail("You cannot use a flavor option like --ng, --vue, --tsc and --js together with --template.");
1936
}
2037

38+
let projectName = args[0];
2139
let selectedTemplate: string;
22-
if (this.$options.tsc) {
40+
if (this.$options.js) {
41+
selectedTemplate = constants.JAVASCRIPT_NAME;
42+
} else if (this.$options.tsc) {
2343
selectedTemplate = constants.TYPESCRIPT_NAME;
2444
} else if (this.$options.ng) {
2545
selectedTemplate = constants.ANGULAR_NAME;
46+
} else if (this.$options.vue) {
47+
selectedTemplate = constants.VUE_NAME;
2648
} else {
2749
selectedTemplate = this.$options.template;
2850
}
2951

30-
this.createdProjecData = await this.$projectService.createProject({
31-
projectName: args[0],
52+
if ((!selectedTemplate || !projectName) && isInteractive()) {
53+
this.printInteractiveCreationIntro();
54+
}
55+
56+
if (!projectName && isInteractive()) {
57+
projectName = await this.$prompter.getString(`${getNextInteractiveAdverb()}, what will be the name of your app?`, { allowEmpty: false });
58+
this.$logger.info();
59+
}
60+
61+
projectName = await this.$projectService.validateProjectName({ projectName: projectName, force: this.$options.force, pathToProject: this.$options.path });
62+
63+
if (!selectedTemplate && isInteractive()) {
64+
selectedTemplate = await this.interactiveFlavorAndTemplateSelection(getNextInteractiveAdverb(), getNextInteractiveAdverb());
65+
}
66+
67+
this.createdProjectData = await this.$projectService.createProject({
68+
projectName: projectName,
3269
template: selectedTemplate,
3370
appId: this.$options.appid,
3471
pathToProject: this.$options.path,
35-
force: this.$options.force,
72+
// its already validated above
73+
force: true,
3674
ignoreScripts: this.$options.ignoreScripts
3775
});
3876
}
3977

78+
private async interactiveFlavorAndTemplateSelection(flavorAdverb: string, templateAdverb: string) {
79+
const selectedFlavor = await this.interactiveFlavorSelection(flavorAdverb);
80+
const selectedTemplate: string = await this.interactiveTemplateSelection(selectedFlavor, templateAdverb);
81+
82+
return selectedTemplate;
83+
}
84+
85+
private async interactiveFlavorSelection(adverb: string) {
86+
const flavorSelection = await this.$prompter.promptForDetailedChoice(`${adverb}, which flavor would you like to use?`, [
87+
{ key: CreateProjectCommand.NgFlavor, description: "Learn more at https://angular.io/" },
88+
{ key: CreateProjectCommand.VueFlavor, description: "Learn more at https://vuejs.org/" },
89+
{ key: CreateProjectCommand.TsFlavor, description: "Learn more at https://www.typescriptlang.org/" },
90+
{ key: CreateProjectCommand.JsFlavor, description: "Learn more at https://www.javascript.com/" },
91+
]);
92+
return flavorSelection;
93+
}
94+
95+
private printInteractiveCreationIntro() {
96+
this.$logger.info();
97+
this.$logger.printMarkdown(`# Let’s create a NativeScript app!`);
98+
this.$logger.printMarkdown(`
99+
Answer the following questions to help us build the right app for you. (Note: you
100+
can skip this prompt next time using the --template option, or the --ng, --vue, --ts,
101+
or --js flags.)
102+
`);
103+
}
104+
105+
private async interactiveTemplateSelection(flavorSelection: string, adverb: string) {
106+
const selectedFlavorTemplates: {
107+
key?: string;
108+
value: string;
109+
description?: string;
110+
}[] = [];
111+
let selectedTemplate: string;
112+
switch (flavorSelection) {
113+
case CreateProjectCommand.NgFlavor: {
114+
selectedFlavorTemplates.push(...this.getNgFlavors());
115+
break;
116+
}
117+
case CreateProjectCommand.VueFlavor: {
118+
selectedFlavorTemplates.push({ value: "https://github.com/NativeScript/template-blank-vue/tarball/0.9.0" });
119+
break;
120+
}
121+
case CreateProjectCommand.TsFlavor: {
122+
selectedFlavorTemplates.push(...this.getTsTemplates());
123+
break;
124+
}
125+
case CreateProjectCommand.JsFlavor: {
126+
selectedFlavorTemplates.push(...this.getJsTemplates());
127+
break;
128+
}
129+
}
130+
if (selectedFlavorTemplates.length > 1) {
131+
this.$logger.info();
132+
const templateChoices = selectedFlavorTemplates.map((template) => {
133+
return { key: template.key, description: template.description };
134+
});
135+
const selectedTemplateKey = await this.$prompter.promptForDetailedChoice(`${adverb}, which template would you like to start from?`, templateChoices);
136+
selectedTemplate = selectedFlavorTemplates.find(t => t.key === selectedTemplateKey).value;
137+
} else {
138+
selectedTemplate = selectedFlavorTemplates[0].value;
139+
}
140+
return selectedTemplate;
141+
}
142+
143+
private getJsTemplates() {
144+
const templates: {
145+
key?: string;
146+
value: string;
147+
description?: string;
148+
}[] = [];
149+
templates.push({
150+
key: CreateProjectCommand.HelloWorldTemplateKey,
151+
value: "tns-template-hello-world",
152+
description: CreateProjectCommand.HelloWorldTemplateDescription
153+
});
154+
templates.push({
155+
key: CreateProjectCommand.DrawerTemplateKey,
156+
value: "tns-template-drawer-navigation",
157+
description: CreateProjectCommand.DrawerTemplateDescription
158+
});
159+
templates.push({
160+
key: CreateProjectCommand.TabsTemplateKey,
161+
value: "tns-template-tab-navigation",
162+
description: CreateProjectCommand.TabsTemplateDescription
163+
});
164+
return templates;
165+
}
166+
167+
private getTsTemplates() {
168+
const templates: {
169+
key?: string;
170+
value: string;
171+
description?: string;
172+
}[] = [];
173+
templates.push({
174+
key: CreateProjectCommand.HelloWorldTemplateKey,
175+
value: "tns-template-hello-world-ts",
176+
description: CreateProjectCommand.HelloWorldTemplateDescription
177+
});
178+
templates.push({
179+
key: CreateProjectCommand.DrawerTemplateKey,
180+
value: "tns-template-drawer-navigation-ts",
181+
description: CreateProjectCommand.DrawerTemplateDescription
182+
});
183+
templates.push({
184+
key: CreateProjectCommand.TabsTemplateKey,
185+
value: "tns-template-tab-navigation-ts",
186+
description: CreateProjectCommand.TabsTemplateDescription
187+
});
188+
return templates;
189+
}
190+
191+
private getNgFlavors() {
192+
const templates: {
193+
key?: string;
194+
value: string;
195+
description?: string;
196+
}[] = [];
197+
templates.push({
198+
key: CreateProjectCommand.HelloWorldTemplateKey,
199+
value: "tns-template-hello-world-ng",
200+
description: CreateProjectCommand.HelloWorldTemplateDescription
201+
});
202+
templates.push({
203+
key: CreateProjectCommand.DrawerTemplateKey,
204+
value: "tns-template-drawer-navigation-ng",
205+
description: CreateProjectCommand.DrawerTemplateDescription
206+
});
207+
templates.push({
208+
key: CreateProjectCommand.TabsTemplateKey,
209+
value: "tns-template-tab-navigation-ng",
210+
description: CreateProjectCommand.TabsTemplateDescription
211+
});
212+
213+
return templates;
214+
}
215+
40216
public async postCommandAction(args: string[]): Promise<void> {
41-
const { projectDir } = this.createdProjecData;
217+
const { projectDir } = this.createdProjectData;
42218
const relativePath = path.relative(process.cwd(), projectDir);
43219
this.$logger.printMarkdown(`Now you can navigate to your project with \`$ cd ${relativePath}\``);
44220
this.$logger.printMarkdown(`After that you can run it on device/emulator by executing \`$ tns run <platform>\``);

lib/common/logger.ts

+3
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,8 @@ export class Logger implements ILogger {
128128
const opts = {
129129
unescape: true,
130130
link: chalk.red,
131+
strong: chalk.green.bold,
132+
firstHeading: chalk.blue.bold,
131133
tableOptions: {
132134
chars: { 'mid': '', 'left-mid': '', 'mid-mid': '', 'right-mid': '' },
133135
style: {
@@ -141,6 +143,7 @@ export class Logger implements ILogger {
141143
};
142144

143145
marked.setOptions({ renderer: new TerminalRenderer(opts) });
146+
144147
const formattedMessage = marked(util.format.apply(null, args));
145148
this.write(formattedMessage);
146149
}

lib/common/prompter.ts

+27-1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { ReadStream } from "tty";
55
const MuteStream = require("mute-stream");
66

77
export class Prompter implements IPrompter {
8+
private descriptionSeparator = "|";
89
private ctrlcReader: readline.ReadLine;
910
private muteStreamInstance: any = null;
1011

@@ -15,6 +16,11 @@ export class Prompter implements IPrompter {
1516
}
1617

1718
public async get(questions: prompt.Question[]): Promise<any> {
19+
_.each(questions, q => {
20+
q.filter = ((selection: string) => {
21+
return selection.split(this.descriptionSeparator)[0].trim();
22+
});
23+
});
1824
try {
1925
this.muteStdout();
2026

@@ -71,7 +77,7 @@ export class Prompter implements IPrompter {
7177
return result.inputString;
7278
}
7379

74-
public async promptForChoice(promptMessage: string, choices: any[]): Promise<string> {
80+
public async promptForChoice(promptMessage: string, choices: string[]): Promise<string> {
7581
const schema: prompt.Question = {
7682
message: promptMessage,
7783
type: "list",
@@ -83,6 +89,26 @@ export class Prompter implements IPrompter {
8389
return result.userAnswer;
8490
}
8591

92+
public async promptForDetailedChoice(promptMessage: string, choices: { key: string, description: string }[]): Promise<string> {
93+
const longestKeyLength = choices.concat().sort(function (a, b) { return b.key.length - a.key.length; })[0].key.length;
94+
const inquirerChoices = choices.map((choice) => {
95+
return {
96+
name: `${_.padEnd(choice.key, longestKeyLength)} ${this.descriptionSeparator} ${choice.description}`,
97+
short: choice.key
98+
};
99+
});
100+
101+
const schema: prompt.Question = {
102+
message: promptMessage,
103+
type: "list",
104+
name: "userAnswer",
105+
choices: inquirerChoices
106+
};
107+
108+
const result = await this.get([schema]);
109+
return result.userAnswer;
110+
}
111+
86112
public async confirm(prompt: string, defaultAction?: () => boolean): Promise<boolean> {
87113
const schema = {
88114
type: "confirm",

lib/constants.ts

+4
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,9 @@ export class ReleaseType {
7676

7777
export const RESERVED_TEMPLATE_NAMES: IStringDictionary = {
7878
"default": "tns-template-hello-world",
79+
"javascript": "tns-template-hello-world",
7980
"tsc": "tns-template-hello-world-ts",
81+
"vue": "https://github.com/NativeScript/template-blank-vue/tarball/0.9.0",
8082
"typescript": "tns-template-hello-world-ts",
8183
"ng": "tns-template-hello-world-ng",
8284
"angular": "tns-template-hello-world-ng"
@@ -100,7 +102,9 @@ class ItunesConnectApplicationTypesClass implements IiTunesConnectApplicationTyp
100102
}
101103

102104
export const ItunesConnectApplicationTypes = new ItunesConnectApplicationTypesClass();
105+
export const VUE_NAME = "vue";
103106
export const ANGULAR_NAME = "angular";
107+
export const JAVASCRIPT_NAME = "javascript";
104108
export const TYPESCRIPT_NAME = "typescript";
105109
export const BUILD_OUTPUT_EVENT_NAME = "buildOutput";
106110
export const CONNECTION_ERROR_EVENT_NAME = "connectionError";

lib/declarations.d.ts

+7
Original file line numberDiff line numberDiff line change
@@ -508,7 +508,14 @@ interface IOptions extends IRelease, IDeviceIdentifier, IJustLaunch, IAvd, IAvai
508508
frameworkVersion: string;
509509
ipa: string;
510510
tsc: boolean;
511+
ts: boolean;
512+
typescript: boolean;
511513
ng: boolean;
514+
angular: boolean;
515+
vue: boolean;
516+
vuejs: boolean;
517+
js: boolean;
518+
javascript: boolean;
512519
androidTypings: boolean;
513520
production: boolean; //npm flag
514521
syncAllFiles: boolean;

lib/definitions/project.d.ts

+1
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ interface ICreateProjectData extends IProjectDir, IProjectName {
5454
}
5555

5656
interface IProjectService {
57+
validateProjectName(opts: { projectName: string, force: boolean, pathToProject: string }) : Promise<string>
5758
/**
5859
* Creates new NativeScript application.
5960
* @param {any} projectSettings Options describing new project - its name, appId, path and template from which to be created.

lib/definitions/prompter.d.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ declare global {
55
get(schemas: prompt.Question[]): Promise<any>;
66
getPassword(prompt: string, options?: IAllowEmpty): Promise<string>;
77
getString(prompt: string, options?: IPrompterOptions): Promise<string>;
8-
promptForChoice(promptMessage: string, choices: any[]): Promise<string>;
8+
promptForChoice(promptMessage: string, choices: string[]): Promise<string>;
9+
promptForDetailedChoice(promptMessage: string, choices: { key: string, description: string }[]): Promise<string>;
910
confirm(prompt: string, defaultAction?: () => boolean): Promise<boolean>;
1011
}
1112
}

lib/options.ts

+23
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,15 @@ export class Options {
6262
port: { type: OptionType.Number },
6363
copyTo: { type: OptionType.String },
6464
platformTemplate: { type: OptionType.String },
65+
js: { type: OptionType.Boolean },
66+
javascript: { type: OptionType.Boolean },
6567
ng: { type: OptionType.Boolean },
68+
angular: { type: OptionType.Boolean },
69+
vue: { type: OptionType.Boolean },
70+
vuejs: { type: OptionType.Boolean },
6671
tsc: { type: OptionType.Boolean },
72+
ts: { type: OptionType.Boolean },
73+
typescript: { type: OptionType.Boolean },
6774
androidTypings: { type: OptionType.Boolean },
6875
bundle: { type: OptionType.String },
6976
all: { type: OptionType.Boolean },
@@ -228,6 +235,22 @@ export class Options {
228235
this.argv.watch = false;
229236
}
230237

238+
if (this.argv.ts || this.argv.typescript) {
239+
this.argv.tsc = true;
240+
}
241+
242+
if (this.argv.angular) {
243+
this.argv.ng = true;
244+
}
245+
246+
if (this.argv.vuejs) {
247+
this.argv.vue = true;
248+
}
249+
250+
if (this.argv.javascript) {
251+
this.argv.js = true;
252+
}
253+
231254
// Default to "nativescript-dev-webpack" if only `--bundle` is passed
232255
if (this.argv.bundle !== undefined || this.argv.hmr) {
233256
this.argv.bundle = this.argv.bundle || "webpack";

0 commit comments

Comments
 (0)