Skip to content

Commit

Permalink
change the style of multiselect
Browse files Browse the repository at this point in the history
  • Loading branch information
km1chno committed Aug 23, 2024
1 parent 6246346 commit 0016b92
Show file tree
Hide file tree
Showing 10 changed files with 177 additions and 25 deletions.
10 changes: 9 additions & 1 deletion src/commands/react-native-ci-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,
}))
)

Expand Down
1 change: 1 addition & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
162 changes: 150 additions & 12 deletions src/extensions/interactive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '●'
Expand Down Expand Up @@ -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<string[]> => {
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<string[] | symbol>

const selected = await multiselectPromise

if (isCancel(selected)) {
throw Error('The script execution has been canceled by the user.')
Expand All @@ -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)
}

Expand Down Expand Up @@ -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<string[]>
confirm: (message: string, type?: 'normal' | 'warning') => Promise<boolean>
surveyStep: (message: string) => void
Expand Down
6 changes: 3 additions & 3 deletions src/recipes/detox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions src/recipes/eas-update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down
1 change: 1 addition & 0 deletions src/recipes/jest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down
1 change: 1 addition & 0 deletions src/recipes/lint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down
3 changes: 2 additions & 1 deletion src/recipes/prettier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down
3 changes: 2 additions & 1 deletion src/recipes/typescript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down
9 changes: 5 additions & 4 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>)
| null

export interface RecipeMeta {
name: string
flag: string
description: string
selectHint: string
}

export type RunResult =
| ((toolbox: CycliToolbox, context: ProjectContext) => Promise<string>)
| null

export interface CycliRecipe {
meta: RecipeMeta
execute: (toolbox: CycliToolbox, context: ProjectContext) => Promise<void>
Expand Down

0 comments on commit 0016b92

Please sign in to comment.