diff --git a/examples/basic/package.json b/examples/basic/package.json index dc3602f2..ab9e95cd 100644 --- a/examples/basic/package.json +++ b/examples/basic/package.json @@ -13,7 +13,8 @@ "stream": "jiti ./stream.ts", "spinner": "jiti ./spinner.ts", "spinner-ci": "npx cross-env CI=\"true\" jiti ./spinner-ci.ts", - "spinner-timer": "jiti ./spinner-timer.ts" + "spinner-timer": "jiti ./spinner-timer.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 60371137..81c7dc75 100644 --- a/packages/prompts/README.md +++ b/packages/prompts/README.md @@ -159,6 +159,72 @@ const group = await p.group( console.log(group.name, group.age, group.color); ``` +### 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); +``` + ### Tasks Execute multiple tasks in spinners. diff --git a/packages/prompts/src/index.ts b/packages/prompts/src/index.ts index e50ecf3a..5e6763d9 100644 --- a/packages/prompts/src/index.ts +++ b/packages/prompts/src/index.ts @@ -844,8 +844,31 @@ export const spinner = ({ indicator = 'dots' }: SpinnerOptions = {}) => { }; }; -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; +}>; + +export type PromptWithOptions< + TResults, + TResult, + // biome-ignore lint/complexity/noBannedTypes: {} is initializing a empty object + TOptions extends Record = {}, +> = ( + opts: Prettify< + { + results: PromptGroupAwaitedReturn; + } & TOptions + > +) => TResult; + +export type PromptGroup = { + [P in keyof T]: PromptWithOptions>, undefined | Promise>; }; export interface PromptGroupOptions { @@ -853,19 +876,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 @@ -873,7 +886,7 @@ export type PromptGroup = { export const group = async ( prompts: PromptGroup, opts?: PromptGroupOptions -): Promise>> => { +): Promise> => { const results = {} as any; const promptNames = Object.keys(prompts); @@ -898,6 +911,121 @@ export const group = async ( return results; }; +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(); +}; + export type Task = { /** * Task title