diff --git a/.github/workflows/build-templates.yml b/.github/workflows/build-templates.yml index 45bda5712..092f5233e 100644 --- a/.github/workflows/build-templates.yml +++ b/.github/workflows/build-templates.yml @@ -45,7 +45,7 @@ jobs: language: kotlin-swift - type: fabric-view language: cpp - - type: legacy-module + - type: legacy-view language: cpp include: - os: ubuntu-latest diff --git a/packages/create-react-native-library/src/index.ts b/packages/create-react-native-library/src/index.ts index dbf87992f..f01eed082 100644 --- a/packages/create-react-native-library/src/index.ts +++ b/packages/create-react-native-library/src/index.ts @@ -3,7 +3,7 @@ import fs from 'fs-extra'; import kleur from 'kleur'; import yargs from 'yargs'; import ora from 'ora'; -import prompts from './utils/prompts'; +import { prompt } from './utils/prompt'; import generateExampleApp from './exampleApp/generateExampleApp'; import { addCodegenBuildScript } from './exampleApp/addCodegenBuildScript'; import { createInitialGitCommit } from './utils/initialCommit'; @@ -56,22 +56,15 @@ async function create(_argv: yargs.Arguments) { const basename = path.basename(folder); - const { questions, singleChoiceAnswers } = await createQuestions({ - basename, - local, - argv, - }); + const questions = await createQuestions({ basename, local }); assertUserInput(questions, argv); - const promptAnswers = await prompts(questions); - - const answers = { - ...argv, - local, - ...singleChoiceAnswers, + const promptAnswers = await prompt(questions, argv); + const answers: Answers = { ...promptAnswers, - } as Required; + local, + }; assertUserInput(questions, answers); @@ -161,7 +154,7 @@ async function promptLocalLibrary(argv: Args) { if (hasPackageJson) { // If we're under a project with package.json, ask the user if they want to create a local library - const answers = await prompts({ + const answers = await prompt({ type: 'confirm', name: 'local', message: `Looks like you're under a project folder. Do you want to create a local library?`, @@ -181,7 +174,7 @@ async function promptPath(argv: Args, local: boolean) { if (argv.name && !local) { folder = path.join(process.cwd(), argv.name); } else { - const answers = await prompts({ + const answers = await prompt({ type: 'text', name: 'folder', message: `Where do you want to create the library?`, diff --git a/packages/create-react-native-library/src/input.ts b/packages/create-react-native-library/src/input.ts index 853388e07..6b02c9a21 100644 --- a/packages/create-react-native-library/src/input.ts +++ b/packages/create-react-native-library/src/input.ts @@ -1,8 +1,8 @@ -import { version } from '../package.json'; -import validateNpmPackage from 'validate-npm-package-name'; import githubUsername from 'github-username'; +import validateNpmPackage from 'validate-npm-package-name'; import type yargs from 'yargs'; -import type { PromptObject } from './utils/prompts'; +import { version } from '../package.json'; +import type { Question } from './utils/prompt'; import { spawn } from './utils/spawn'; export type ArgName = @@ -111,14 +111,6 @@ const TYPE_CHOICES: { }, ]; -export type Question = Omit< - PromptObject, - 'validate' | 'name' -> & { - validate?: (value: string) => boolean | string; - name: keyof Answers; -}; - export const acceptedArgs: Record = { slug: { description: 'Name of the npm package', @@ -180,8 +172,8 @@ export type Answers = { authorUrl: string; repoUrl: string; languages: ProjectLanguages; - type?: ProjectType; - example?: ExampleApp; + type: ProjectType; + example: ExampleApp; reactNativeVersion?: string; local?: boolean; }; @@ -189,11 +181,9 @@ export type Answers = { export async function createQuestions({ basename, local, - argv, }: { basename: string; local: boolean; - argv: Args; }) { let name, email; @@ -204,7 +194,7 @@ export async function createQuestions({ // Ignore error } - const initialQuestions: Question[] = [ + const questions: Question[] = [ { type: 'text', name: 'slug', @@ -295,7 +285,7 @@ export async function createQuestions({ ]; if (!local) { - initialQuestions.push({ + questions.push({ type: 'select', name: 'example', message: 'What type of example app do you want to create?', @@ -313,48 +303,7 @@ export async function createQuestions({ }); } - const singleChoiceAnswers: Partial = {}; - const finalQuestions: Question[] = []; - - for (const question of initialQuestions) { - // Skip questions which are passed as parameter and pass validation - const argValue = argv[question.name]; - if (argValue && question.validate?.(argValue) !== false) { - continue; - } - - // Don't prompt questions with a single choice - if (Array.isArray(question.choices) && question.choices.length === 1) { - const onlyChoice = question.choices[0]!; - singleChoiceAnswers[question.name] = onlyChoice.value; - - continue; - } - - const { type, choices } = question; - - // Don't prompt dynamic questions with a single choice - if (type === 'select' && typeof choices === 'function') { - question.type = (prev, values, prompt) => { - const dynamicChoices = choices(prev, { ...argv, ...values }, prompt); - - if (dynamicChoices && dynamicChoices.length === 1) { - const onlyChoice = dynamicChoices[0]!; - singleChoiceAnswers[question.name] = onlyChoice.value; - return null; - } - - return type; - }; - } - - finalQuestions.push(question); - } - - return { - questions: finalQuestions, - singleChoiceAnswers, - }; + return questions; } export function createMetadata(answers: Answers) { diff --git a/packages/create-react-native-library/src/template.ts b/packages/create-react-native-library/src/template.ts index 1d7c7d7d2..dd7507a12 100644 --- a/packages/create-react-native-library/src/template.ts +++ b/packages/create-react-native-library/src/template.ts @@ -99,7 +99,7 @@ export function generateTemplateConfiguration({ }: { bobVersion: string; basename: string; - answers: Required; + answers: Answers; }): TemplateConfiguration { const { slug, languages, type } = answers; diff --git a/packages/create-react-native-library/src/utils/assert.ts b/packages/create-react-native-library/src/utils/assert.ts index c666f3efa..4e277e294 100644 --- a/packages/create-react-native-library/src/utils/assert.ts +++ b/packages/create-react-native-library/src/utils/assert.ts @@ -1,6 +1,7 @@ import kleur from 'kleur'; import { spawn } from './spawn'; -import type { Answers, Args, Question } from '../input'; +import type { Answers, Args } from '../input'; +import type { Question } from './prompt'; export async function assertNpxExists() { try { @@ -25,8 +26,8 @@ export async function assertNpxExists() { * Makes sure the answers are in expected form and ends the process with error if they are not */ export function assertUserInput( - questions: Question[], - answers: Answers | Args + questions: Question[], + answers: Partial ) { for (const [key, value] of Object.entries(answers)) { if (value == null) { @@ -39,35 +40,40 @@ export function assertUserInput( continue; } - let valid = question.validate ? question.validate(String(value)) : true; + let validation; // We also need to guard against invalid choices // If we don't already have a validation message to provide a better error - if (typeof valid !== 'string' && 'choices' in question) { + if ('choices' in question) { const choices = typeof question.choices === 'function' - ? question.choices( - undefined, - // @ts-expect-error: it complains about optional values, but it should be fine - answers, - question - ) + ? question.choices(undefined, answers) : question.choices; - if (choices && !choices.some((choice) => choice.value === value)) { - valid = `Supported values are - ${choices.map((c) => - kleur.green(c.value) - )}`; + if (choices && choices.every((choice) => choice.value !== value)) { + if (choices.length > 1) { + validation = `Must be one of ${choices + .map((choice) => kleur.green(choice.value)) + .join(', ')}`; + } else if (choices[0]) { + validation = `Must be '${kleur.green(choices[0].value)}'`; + } else { + validation = false; + } } } - if (valid !== true) { + if (validation == null && question.validate) { + validation = question.validate(String(value)); + } + + if (validation != null && validation !== true) { let message = `Invalid value ${kleur.red( String(value) )} passed for ${kleur.blue(key)}`; - if (typeof valid === 'string') { - message += `: ${valid}`; + if (typeof validation === 'string') { + message += `: ${validation}`; } console.log(message); diff --git a/packages/create-react-native-library/src/utils/prompt.ts b/packages/create-react-native-library/src/utils/prompt.ts new file mode 100644 index 000000000..6543acd16 --- /dev/null +++ b/packages/create-react-native-library/src/utils/prompt.ts @@ -0,0 +1,98 @@ +import prompts from 'prompts'; + +type Choice = { + title: string; + value: string; + description?: string; +}; + +export type Question = Omit< + prompts.PromptObject, + 'validate' | 'name' | 'choices' +> & { + name: T; + validate?: (value: string) => boolean | string; + choices?: + | Choice[] + | ((prev: unknown, values: Partial>) => Choice[]); +}; + +/** + * Wrapper around `prompts` with additional features: + * + * - Improved type-safety + * - Read answers from passed arguments + * - Skip questions with a single choice + * - Exit on canceling the prompt + */ +export async function prompt( + questions: Question[] | Question, + argv?: Record, + options?: prompts.Options +) { + const singleChoiceAnswers = {}; + const promptQuestions = []; + + if (Array.isArray(questions)) { + for (const question of questions) { + // Skip questions which are passed as parameter and pass validation + const argValue = argv?.[question.name]; + + if (argValue && question.validate?.(argValue) !== false) { + continue; + } + + // Don't prompt questions with a single choice + if (Array.isArray(question.choices) && question.choices.length === 1) { + const onlyChoice = question.choices[0]; + + if (onlyChoice?.value) { + // @ts-expect-error assume the passed value is correct + singleChoiceAnswers[question.name] = onlyChoice.value; + } + + continue; + } + + const { type, choices } = question; + + // Don't prompt dynamic questions with a single choice + if (type === 'select' && typeof choices === 'function') { + question.type = (prev, values) => { + const dynamicChoices = choices(prev, { ...argv, ...values }); + + if (dynamicChoices && dynamicChoices.length === 1) { + const onlyChoice = dynamicChoices[0]; + + if (onlyChoice?.value) { + // @ts-expect-error assume the passed value is correct + singleChoiceAnswers[question.name] = onlyChoice.value; + } + + return null; + } + + return type; + }; + } + + promptQuestions.push(question); + } + } else { + promptQuestions.push(questions); + } + + const promptAnswers = await prompts(promptQuestions, { + onCancel() { + // Exit the CLI on cancel + process.exit(1); + }, + ...options, + }); + + return { + ...argv, + ...singleChoiceAnswers, + ...promptAnswers, + }; +} diff --git a/packages/create-react-native-library/src/utils/prompts.ts b/packages/create-react-native-library/src/utils/prompts.ts deleted file mode 100644 index 4ed4ae7b4..000000000 --- a/packages/create-react-native-library/src/utils/prompts.ts +++ /dev/null @@ -1,16 +0,0 @@ -import promptsModule from 'prompts'; - -export default function prompts( - args: promptsModule.PromptObject | promptsModule.PromptObject[], - options?: promptsModule.Options -) { - return promptsModule(args, { - onCancel() { - process.exit(1); - }, - ...options, - }); -} - -export type PromptObject = - promptsModule.PromptObject;