From 0016b92d4ce71156ce2352acdbbbbf4644785416 Mon Sep 17 00:00:00 2001 From: km1chno Date: Fri, 23 Aug 2024 09:18:57 +0200 Subject: [PATCH] change the style of multiselect --- src/commands/react-native-ci-cli.ts | 10 +- src/constants.ts | 1 + src/extensions/interactive.ts | 162 +++++++++++++++++++++++++--- src/recipes/detox.ts | 6 +- src/recipes/eas-update.ts | 6 +- src/recipes/jest.ts | 1 + src/recipes/lint.ts | 1 + src/recipes/prettier.ts | 3 +- src/recipes/typescript.ts | 3 +- src/types.ts | 9 +- 10 files changed, 177 insertions(+), 25 deletions(-) diff --git a/src/commands/react-native-ci-cli.ts b/src/commands/react-native-ci-cli.ts index 990f6e6..cb652d5 100644 --- a/src/commands/react-native-ci-cli.ts +++ b/src/commands/react-native-ci-cli.ts @@ -11,7 +11,11 @@ import { CycliRecipe, CycliToolbox, ProjectContext } from '../types' import messageFromError from '../utils/messageFromError' import { intersection } from 'lodash' import { addTerminatingNewline } from '../utils/addTerminatingNewline' -import { HELP_FLAG, PRESET_FLAG } from '../constants' +import { + HELP_FLAG, + PRESET_FLAG, + REPOSITORY_FEATURES_HELP_URL, +} from '../constants' const COMMAND = 'react-native-ci-cli' const SKIP_GIT_CHECK_FLAG = 'skip-git-check' @@ -74,13 +78,17 @@ const runReactNativeCiCli = async (toolbox: CycliToolbox) => { const featureFlags = getFeatureOptions().map((option) => option.flag) + // TODO: Better README (features section) to explain what workflows do. (so the user clicking link in hint knows whats going on) + // Also, we can add the link from hint to help message! const selectedFeatureFlags = toolbox.options.isPreset() ? intersection(featureFlags, Object.keys(toolbox.parameters.options)) : await toolbox.interactive.multiselect( 'Select workflows you want to run on every PR', + `Learn more about PR workflows: ${REPOSITORY_FEATURES_HELP_URL}`, RECIPES.map((recipe: CycliRecipe) => ({ label: recipe.meta.name, value: recipe.meta.flag, + hint: recipe.meta.selectHint, })) ) diff --git a/src/constants.ts b/src/constants.ts index 1e0d08f..c70cddb 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -9,3 +9,4 @@ export const LOCK_FILE_TO_MANAGER = { const REPOSITORY_URL = 'https://github.com/software-mansion-labs/react-native-ci-cli' export const REPOSITORY_SECRETS_HELP_URL = `${REPOSITORY_URL}?tab=readme-ov-file#-repository-secrets` +export const REPOSITORY_FEATURES_HELP_URL = `${REPOSITORY_URL}?tab=readme-ov-file#%EF%B8%8F-features` diff --git a/src/extensions/interactive.ts b/src/extensions/interactive.ts index 86b9689..86f8351 100644 --- a/src/extensions/interactive.ts +++ b/src/extensions/interactive.ts @@ -2,37 +2,53 @@ import { print } from 'gluegun' import { outro as clackOutro, intro as clackIntro, - multiselect as clackMultiselect, isCancel, log as clackLog, } from '@clack/prompts' import { CycliToolbox } from '../types' -import { ConfirmPrompt } from '@clack/core' +import { ConfirmPrompt, MultiSelectPrompt } from '@clack/core' interface Spinner { stop: () => void } const COLORS = { + blue: print.colors.blue, + bgWhite: print.colors.bgWhite, + bold: print.colors.bold, cyan: print.colors.cyan, - green: print.colors.green, - yellow: print.colors.yellow, + dim: print.colors.dim, gray: print.colors.gray, - red: print.colors.red, + green: print.colors.green, inverse: print.colors.inverse, - dim: print.colors.dim, + red: print.colors.red, + reset: print.colors.reset, strikethrough: print.colors.strikethrough, - bold: print.colors.bold, + yellow: print.colors.yellow, } type MessageColor = keyof typeof COLORS module.exports = (toolbox: CycliToolbox) => { - const { cyan, yellow, gray, red, inverse, dim, strikethrough, bold } = COLORS + const { + blue, + bgWhite, + bold, + cyan, + dim, + gray, + inverse, + red, + reset, + strikethrough, + yellow, + } = COLORS const S_STEP_ERROR = yellow('▲') const S_SUCCESS = cyan('◆') const S_STEP_CANCEL = red('■') + const S_MULTISELECT_MESSAGE = blue('◆') + const S_R_ARROW = '►' const S_BAR = '│' const S_BAR_END = '└' const S_RADIO_ACTIVE = '●' @@ -130,9 +146,130 @@ module.exports = (toolbox: CycliToolbox) => { const multiselect = async ( message: string, - options: { label: string; value: string }[] + hint: string, + options: { label: string; value: string; hint: string }[] ): Promise => { - const selected = await clackMultiselect({ message: bold(message), options }) + const opt = ( + option: { label: string; value: string; hint?: string }, + state: 'inactive' | 'active' | 'selected' | 'active-selected' + ) => { + const { label, hint } = option + + switch (state) { + case 'active': { + return `${blue(S_RADIO_INACTIVE)} ${bold(label)} ${ + hint ? dim(`(${hint})`) : '' + }` + } + case 'selected': { + return `${blue(S_RADIO_ACTIVE)} ${dim(label)}` + } + case 'active-selected': { + return `${blue(S_RADIO_ACTIVE)} ${label} ${ + option.hint ? dim(`(${hint})`) : '' + }` + } + case 'inactive': { + return `${dim(blue(S_RADIO_INACTIVE))} ${dim(label)}` + } + } + } + + const instruction = reset( + dim( + `Press ${gray(bgWhite(inverse(' space ')))} to select, ${gray( + bgWhite(inverse(' enter ')) + )} to submit` + ) + ) + + const multiselectPromise = new MultiSelectPrompt({ + options, + initialValues: [], + required: true, + cursorAt: options[0].value, + validate(selected: string[]) { + if (this.required && selected.length === 0) + return 'Please select at least one option.' + }, + render() { + const title = `${gray(S_BAR)}\n${S_MULTISELECT_MESSAGE} ${bold( + message + )}\n${ + ['submit', 'cancel'].includes(this.state) ? gray(S_BAR) : blue(S_BAR) + } ${dim(hint)}\n` + + const styleOption = ( + option: { value: string; label: string; hint?: string }, + active: boolean + ) => { + const selected = this.value.includes(option.value) + if (active && selected) { + return opt(option, 'active-selected') + } + if (selected) { + return opt(option, 'selected') + } + return opt(option, active ? 'active' : 'inactive') + } + + const optionsList = + title + + `${blue(S_BAR)}\n` + + blue(S_BAR) + + ' ' + + this.options + .map((option, index) => styleOption(option, this.cursor === index)) + .join(`\n${blue(S_BAR)} `) + + '\n' + + const selectedOptions = this.options.filter(({ value }) => + this.value.includes(value) + ) + + const selectedInfo = `${blue(S_R_ARROW)} ${dim( + `Selected: ${selectedOptions + .map((option) => option.label) + .join(', ')}` + )}` + + switch (this.state) { + case 'submit': { + return `${title}${gray(S_BAR)} \n${gray(S_BAR)} ${selectedInfo} ` + } + case 'cancel': { + const strikethroughSelected = + selectedOptions.length === 0 + ? '' + : `\n${gray(S_BAR)} ${strikethrough( + dim( + selectedOptions.map((option) => option.label).join(', ') + ) + )}\n${gray(S_BAR)}` + return `${title}${gray(S_BAR)}${strikethroughSelected}` + } + case 'error': { + const footer = this.error + .split('\n') + .map((ln, i) => + i === 0 ? `${S_STEP_ERROR} ${yellow(ln)} ` : ` ${ln} ` + ) + .join('\n') + + return `${optionsList}${blue(S_BAR)} \n${blue( + S_BAR + )} ${footer} \n${blue(S_BAR_END)} \n${instruction} ` + } + default: { + return `${optionsList}${blue(S_BAR)} \n${blue( + S_BAR + )} ${selectedInfo} \n${blue(S_BAR_END)} \n${instruction} ` + } + } + }, + }).prompt() as Promise + + const selected = await multiselectPromise if (isCancel(selected)) { throw Error('The script execution has been canceled by the user.') @@ -150,7 +287,7 @@ module.exports = (toolbox: CycliToolbox) => { } const info = (message: string, color?: MessageColor) => { - if (color) print.info(`${COLORS[color](message)}`) + if (color) print.info(`${COLORS[color](message)} `) else print.info(message) } @@ -205,7 +342,8 @@ export interface InteractiveExtension { interactive: { multiselect: ( message: string, - options: { label: string; value: string }[] + hint: string, + options: { label: string; value: string; hint: string }[] ) => Promise confirm: (message: string, type?: 'normal' | 'warning') => Promise surveyStep: (message: string) => void diff --git a/src/recipes/detox.ts b/src/recipes/detox.ts index 48be441..9fecf19 100644 --- a/src/recipes/detox.ts +++ b/src/recipes/detox.ts @@ -117,10 +117,10 @@ const execute = async (toolbox: CycliToolbox, context: ProjectContext) => { export const recipe: CycliRecipe = { meta: { - name: 'Detox E2E tests', + name: 'Detox', flag: FLAG, - description: - 'Generate workflow to run Detox e2e tests on every PR (Expo projects only)', + description: 'Generate workflow to run Detox e2e tests on every PR', + selectHint: 'run detox e2e tests suite', }, execute, } as const diff --git a/src/recipes/eas-update.ts b/src/recipes/eas-update.ts index 446f1e2..461198a 100644 --- a/src/recipes/eas-update.ts +++ b/src/recipes/eas-update.ts @@ -35,10 +35,10 @@ const execute = async ( export const recipe: CycliRecipe = { meta: { - name: 'EAS Update and Preview', + name: 'EAS Preview', flag: FLAG, - description: - 'Generate EAS Update and preview workflow to run on every PR (Expo projects only)', + description: 'Generate EAS Update and preview workflow to run on every PR', + selectHint: 'run EAS Update and generate preview with QR code', }, execute, } diff --git a/src/recipes/jest.ts b/src/recipes/jest.ts index 171fa1f..e7184fd 100644 --- a/src/recipes/jest.ts +++ b/src/recipes/jest.ts @@ -36,6 +36,7 @@ export const recipe: CycliRecipe = { name: 'Jest', flag: FLAG, description: 'Generate Jest workflow to run on every PR', + selectHint: 'test your program with jest', }, execute, } diff --git a/src/recipes/lint.ts b/src/recipes/lint.ts index d883150..a8dd1a9 100644 --- a/src/recipes/lint.ts +++ b/src/recipes/lint.ts @@ -64,6 +64,7 @@ export const recipe: CycliRecipe = { name: 'ESLint', flag: FLAG, description: 'Generate ESLint workflow to run on every PR', + selectHint: 'check your code style with linter', }, execute, } diff --git a/src/recipes/prettier.ts b/src/recipes/prettier.ts index 4d156b6..087a4b5 100644 --- a/src/recipes/prettier.ts +++ b/src/recipes/prettier.ts @@ -44,9 +44,10 @@ const execute = async ( export const recipe: CycliRecipe = { meta: { - name: 'Prettier check', + name: 'Prettier', flag: FLAG, description: 'Generate Prettier check workflow to run on every PR', + selectHint: 'check your code format with prettier', }, execute, } diff --git a/src/recipes/typescript.ts b/src/recipes/typescript.ts index e7772d7..23456cb 100644 --- a/src/recipes/typescript.ts +++ b/src/recipes/typescript.ts @@ -32,9 +32,10 @@ const execute = async ( export const recipe: CycliRecipe = { meta: { - name: 'Typescript check', + name: 'Typescript', flag: FLAG, description: 'Generate Typescript check workflow to run on every PR', + selectHint: 'check your code for compilation errors', }, execute, } diff --git a/src/types.ts b/src/types.ts index efcfdd1..fa66b7c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -29,16 +29,17 @@ export type LockFile = keyof typeof LOCK_FILE_TO_MANAGER export type PackageManager = (typeof LOCK_FILE_TO_MANAGER)[keyof typeof LOCK_FILE_TO_MANAGER] +export type RunResult = + | ((toolbox: CycliToolbox, context: ProjectContext) => Promise) + | null + export interface RecipeMeta { name: string flag: string description: string + selectHint: string } -export type RunResult = - | ((toolbox: CycliToolbox, context: ProjectContext) => Promise) - | null - export interface CycliRecipe { meta: RecipeMeta execute: (toolbox: CycliToolbox, context: ProjectContext) => Promise