diff --git a/.changeset/red-walls-greet.md b/.changeset/red-walls-greet.md new file mode 100644 index 00000000..7bceacd1 --- /dev/null +++ b/.changeset/red-walls-greet.md @@ -0,0 +1,5 @@ +--- +'@clack/prompts': minor +--- + +add prompt `workflow` builder diff --git a/examples/basic/package.json b/examples/basic/package.json index d23f29c2..46975ad4 100644 --- a/examples/basic/package.json +++ b/examples/basic/package.json @@ -11,7 +11,8 @@ "scripts": { "start": "jiti ./index.ts", "spinner": "jiti ./spinner.ts", - "spinner-ci": "npx cross-env CI=\"true\" jiti ./spinner-ci.ts" + "spinner-ci": "npx cross-env CI=\"true\" jiti ./spinner-ci.ts", + "workflow": "jiti ./workflow.ts" }, "devDependencies": { "jiti": "^1.17.0" diff --git a/examples/basic/workflow.ts b/examples/basic/workflow.ts new file mode 100644 index 00000000..c42cc5a1 --- /dev/null +++ b/examples/basic/workflow.ts @@ -0,0 +1,65 @@ +import * as p from '@clack/prompts'; + +(async () => { + const results = await p + .workflow() + .step('name', () => p.text({ message: 'What is your package name?' })) + .step('type', () => + p.select({ + message: 'Pick a project type:', + initialValue: 'ts', + maxItems: 5, + options: [ + { value: 'ts', label: 'TypeScript' }, + { value: 'js', label: 'JavaScript' }, + { value: 'rust', label: 'Rust' }, + { value: 'go', label: 'Go' }, + { value: 'python', label: 'Python' }, + { value: 'coffee', label: 'CoffeeScript', hint: 'oh no' }, + ], + }) + ) + .step('install', () => + p.confirm({ + message: 'Install dependencies?', + initialValue: false, + }) + ) + .forkStep( + 'fork', + ({ results }) => results.install, + ({ results }) => { + return p.workflow().step('package', () => + p.select({ + message: 'Pick a package manager:', + initialValue: 'pnpm', + options: [ + { + label: 'npm', + value: 'npm', + }, + { + label: 'yarn', + value: 'yarn', + }, + { + label: 'pnpm', + value: 'pnpm', + }, + ], + }) + ); + } + ) + .run(); + + await p + .workflow() + .step('cancel', () => p.text({ message: 'Try cancel prompt (Ctrl + C):' })) + .step('afterCancel', () => p.text({ message: 'This will not appear!' })) + .onCancel(({ results }) => { + p.cancel('Workflow canceled'); + process.exit(0); + }) + .run(); +})(); diff --git a/packages/prompts/README.md b/packages/prompts/README.md index ed7b5b4f..a6bc88e3 100644 --- a/packages/prompts/README.md +++ b/packages/prompts/README.md @@ -125,7 +125,7 @@ s.stop('Installed via npm'); ## Utilities -### Grouping +### Group Grouping prompts together is a great way to keep your code organized. This accepts a JSON object with a name that can be used to reference the group later. The second argument is an optional but has a `onCancel` callback that will be called if the user cancels one of the prompts in the group. @@ -189,3 +189,69 @@ log.message('Hello, World', { symbol: color.cyan('~') }); ``` [clack-log-prompts](https://github.com/natemoo-re/clack/blob/main/.github/assets/clack-logs.png) + +### Workflow + +Works just like `group` but infer types way better and treats your group like a workflow, allowing you to create conditional steps (forks) along the process. + +```js +import * as p from '@clack/prompts'; + +const results = await p + .workflow() + .step('name', () => p.text({ message: 'What is your package name?' })) + .step('type', () => + p.select({ + message: `Pick a project type:`, + initialValue: 'ts', + maxItems: 5, + options: [ + { value: 'ts', label: 'TypeScript' }, + { value: 'js', label: 'JavaScript' }, + { value: 'rust', label: 'Rust' }, + { value: 'go', label: 'Go' }, + { value: 'python', label: 'Python' }, + { value: 'coffee', label: 'CoffeeScript', hint: 'oh no' }, + ], + }) + ) + .step('install', () => + p.confirm({ + message: 'Install dependencies?', + initialValue: false, + }) + ) + .step('fork', ({ results }) => { + if (results.install === true) { + return p + .workflow() + .step('package', () => + p.select({ + message: 'Pick a package manager:', + initialValue: 'pnpm', + options: [ + { + label: 'npm', + value: 'npm', + }, + { + label: 'yarn', + value: 'yarn', + }, + { + label: 'pnpm', + value: 'pnpm', + }, + ], + }) + ) + .run(); + } + }) + .onCancel(() => { + p.cancel('Workflow canceled'); + process.exit(0); + }) + .run(); +console.log(results); +``` diff --git a/packages/prompts/src/index.ts b/packages/prompts/src/index.ts index 2c152845..33a6a152 100644 --- a/packages/prompts/src/index.ts +++ b/packages/prompts/src/index.ts @@ -774,8 +774,27 @@ export const spinner = () => { }; }; -export type PromptGroupAwaitedReturn = { - [P in keyof T]: Exclude, symbol>; +type Prettify = { + [P in keyof T]: T[P]; +} & {}; + +export type PromptAwaitedReturn = Exclude, symbol>; + +export type PromptGroupAwaitedReturn = Prettify<{ + [P in keyof T]: PromptAwaitedReturn; +}>; + +// biome-ignore lint/complexity/noBannedTypes: +export type PromptWithOptions = {}> = ( + opts: Prettify< + { + results: PromptGroupAwaitedReturn; + } & TOptions + > +) => TResult; + +export type PromptGroup = { + [P in keyof T]: PromptWithOptions>, undefined | Promise>; }; export interface PromptGroupOptions { @@ -783,19 +802,9 @@ export interface PromptGroupOptions { * Control how the group can be canceled * if one of the prompts is canceled. */ - onCancel?: (opts: { results: Prettify>> }) => void; + onCancel?: PromptWithOptions, void>; } -type Prettify = { - [P in keyof T]: T[P]; -} & {}; - -export type PromptGroup = { - [P in keyof T]: (opts: { - results: Prettify>>>; - }) => undefined | Promise; -}; - /** * Define a group of prompts to be displayed * and return a results of objects within the group @@ -803,7 +812,7 @@ export type PromptGroup = { export const group = async ( prompts: PromptGroup, opts?: PromptGroupOptions -): Promise>> => { +): Promise> => { const results = {} as any; const promptNames = Object.keys(prompts); @@ -857,3 +866,118 @@ export const tasks = async (tasks: Task[]) => { s.stop(result || task.title); } }; + +type NextWorkflowBuilder< + TResults extends Record, + TKey extends string, + TResult, +> = WorkflowBuilder< + Prettify< + { + [Key in keyof TResults]: Key extends TKey ? TResult : TResults[Key]; + } & { + [Key in TKey as undefined extends TResult ? never : TKey]: TResult; + } & { + [Key in TKey as undefined extends TResult ? TKey : never]?: TResult; + } + > +>; + +type WorkflowStep = { + name: TName; + prompt: PromptWithOptions; + setResult: boolean; + condition?: PromptWithOptions; +}; + +// biome-ignore lint/complexity/noBannedTypes: +class WorkflowBuilder = {}> { + private results: TResults = {} as TResults; + private steps: WorkflowStep[] = []; + private cancelCallback: PromptWithOptions, void> | undefined; + + public step( + name: TName extends keyof TResults ? never : TName, + prompt: PromptWithOptions + ): NextWorkflowBuilder> { + this.steps.push({ name, prompt, setResult: true }); + return this as any; + } + + public conditionalStep( + name: TName, + condition: PromptWithOptions, + prompt: PromptWithOptions + ): NextWorkflowBuilder< + TResults, + TName, + | (TName extends keyof TResults ? TResults[TName] : never) + | PromptAwaitedReturn + | undefined + > { + this.steps.push({ name, prompt, condition, setResult: true }); + return this as any; + } + + public forkStep>( + name: TName, + condition: PromptWithOptions, + subWorkflow: PromptWithOptions> + ): NextWorkflowBuilder< + TResults, + TName, + (TName extends keyof TResults ? TResults[TName] : never) | TResult | undefined + > { + this.steps.push({ + name, + prompt: ({ results }) => { + return subWorkflow({ results }).run(); + }, + condition, + setResult: true, + }); + return this as any; + } + + public logStep( + name: string, + prompt: PromptWithOptions + ): WorkflowBuilder { + this.steps.push({ name, prompt, setResult: false }); + return this; + } + + public customStep( + step: WorkflowStep + ): NextWorkflowBuilder> { + this.steps.push(step); + return this as any; + } + + public onCancel(cb: PromptWithOptions, void>): WorkflowBuilder { + this.cancelCallback = cb; + return this; + } + + public async run(): Promise { + for (const step of this.steps) { + if (step.condition && !step.condition({ results: this.results as any })) { + continue; + } + const result = await step.prompt({ results: this.results as any }); + if (isCancel(result)) { + this.cancelCallback?.({ results: this.results as any }); + continue; + } + if (step.setResult) { + //@ts-ignore + this.results[step.name] = result; + } + } + return this.results; + } +} + +export const workflow = () => { + return new WorkflowBuilder(); +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da2a1e3c..459f09d4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5369,4 +5369,3 @@ snapshots: yocto-queue@0.1.0: {} yocto-queue@1.1.1: {} - \ No newline at end of file