Skip to content

Commit 4001101

Browse files
committed
Merge branch 'builder' of https://github.com/Mist3rBru/clack into builder
2 parents 2bbd33e + b17310c commit 4001101

File tree

5 files changed

+276
-16
lines changed

5 files changed

+276
-16
lines changed

.changeset/red-walls-greet.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clack/prompts': minor
3+
---
4+
5+
add prompt `workflow` builder

examples/basic/package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
},
1111
"scripts": {
1212
"start": "jiti ./index.ts",
13-
"spinner": "jiti ./spinner.ts"
13+
"spinner": "jiti ./spinner.ts",
14+
"workflow": "jiti ./workflow.ts"
1415
},
1516
"devDependencies": {
1617
"jiti": "^1.17.0"

examples/basic/workflow.ts

+65
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import * as p from '@clack/prompts';
2+
3+
(async () => {
4+
const results = await p
5+
.workflow()
6+
.step('name', () => p.text({ message: 'What is your package name?' }))
7+
.step('type', () =>
8+
p.select({
9+
message: `Pick a project type:`,
10+
initialValue: 'ts',
11+
maxItems: 5,
12+
options: [
13+
{ value: 'ts', label: 'TypeScript' },
14+
{ value: 'js', label: 'JavaScript' },
15+
{ value: 'rust', label: 'Rust' },
16+
{ value: 'go', label: 'Go' },
17+
{ value: 'python', label: 'Python' },
18+
{ value: 'coffee', label: 'CoffeeScript', hint: 'oh no' },
19+
],
20+
})
21+
)
22+
.step('install', () =>
23+
p.confirm({
24+
message: 'Install dependencies?',
25+
initialValue: false,
26+
})
27+
)
28+
.forkStep(
29+
'fork',
30+
({ results }) => results.install,
31+
({ results }) => {
32+
return p.workflow().step('package', () =>
33+
p.select({
34+
message: 'Pick a package manager:',
35+
initialValue: 'pnpm',
36+
options: [
37+
{
38+
label: 'npm',
39+
value: 'npm',
40+
},
41+
{
42+
label: 'yarn',
43+
value: 'yarn',
44+
},
45+
{
46+
label: 'pnpm',
47+
value: 'pnpm',
48+
},
49+
],
50+
})
51+
);
52+
}
53+
)
54+
.run();
55+
56+
await p
57+
.workflow()
58+
.step('cancel', () => p.text({ message: 'Try cancel prompt (Ctrl + C):' }))
59+
.step('afterCancel', () => p.text({ message: 'This will not appear!' }))
60+
.onCancel(({ results }) => {
61+
p.cancel('Workflow canceled');
62+
process.exit(0);
63+
})
64+
.run();
65+
})();

packages/prompts/README.md

+68-1
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ s.stop('Installed via npm');
123123

124124
## Utilities
125125

126-
### Grouping
126+
### Group
127127

128128
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.
129129

@@ -157,6 +157,73 @@ const group = await p.group(
157157
console.log(group.name, group.age, group.color);
158158
```
159159

160+
### Workflow
161+
162+
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.
163+
164+
```js
165+
import * as p from '@clack/prompts';
166+
167+
const results = await p
168+
.workflow()
169+
.step('name', () => p.text({ message: 'What is your package name?' }))
170+
.step('type', () =>
171+
p.select({
172+
message: `Pick a project type:`,
173+
initialValue: 'ts',
174+
maxItems: 5,
175+
options: [
176+
{ value: 'ts', label: 'TypeScript' },
177+
{ value: 'js', label: 'JavaScript' },
178+
{ value: 'rust', label: 'Rust' },
179+
{ value: 'go', label: 'Go' },
180+
{ value: 'python', label: 'Python' },
181+
{ value: 'coffee', label: 'CoffeeScript', hint: 'oh no' },
182+
],
183+
})
184+
)
185+
.step('install', () =>
186+
p.confirm({
187+
message: 'Install dependencies?',
188+
initialValue: false,
189+
})
190+
)
191+
.step('fork', ({ results }) => {
192+
if (results.install === true) {
193+
return p
194+
.workflow()
195+
.step('package', () =>
196+
p.select({
197+
message: 'Pick a package manager:',
198+
initialValue: 'pnpm',
199+
options: [
200+
{
201+
label: 'npm',
202+
value: 'npm',
203+
},
204+
{
205+
label: 'yarn',
206+
value: 'yarn',
207+
},
208+
{
209+
label: 'pnpm',
210+
value: 'pnpm',
211+
},
212+
],
213+
})
214+
)
215+
.run();
216+
}
217+
})
218+
.onCancel(() => {
219+
p.cancel('Workflow canceled');
220+
process.exit(0);
221+
})
222+
.run();
223+
224+
console.log(results);
225+
```
226+
160227
### Tasks
161228

162229
Execute multiple tasks in spinners.

packages/prompts/src/index.ts

+136-14
Original file line numberDiff line numberDiff line change
@@ -730,36 +730,44 @@ function ansiRegex() {
730730
return new RegExp(pattern, 'g');
731731
}
732732

733-
export type PromptGroupAwaitedReturn<T> = {
734-
[P in keyof T]: Exclude<Awaited<T[P]>, symbol>;
733+
type Prettify<T> = {
734+
[P in keyof T]: T[P];
735+
} & {};
736+
737+
export type PromptAwaitedReturn<T> = Exclude<Awaited<T>, symbol>;
738+
739+
export type PromptGroupAwaitedReturn<T> = Prettify<{
740+
[P in keyof T]: PromptAwaitedReturn<T[P]>;
741+
}>;
742+
743+
export type PromptWithOptions<TResults, TResult, TOptions extends Record<string, unknown> = {}> = (
744+
opts: Prettify<
745+
{
746+
results: PromptGroupAwaitedReturn<TResults>;
747+
} & TOptions
748+
>
749+
) => TResult;
750+
751+
export type PromptGroup<T> = {
752+
[P in keyof T]: PromptWithOptions<Partial<Omit<T, P>>, void | Promise<T[P] | void>>;
735753
};
736754

737755
export interface PromptGroupOptions<T> {
738756
/**
739757
* Control how the group can be canceled
740758
* if one of the prompts is canceled.
741759
*/
742-
onCancel?: (opts: { results: Prettify<Partial<PromptGroupAwaitedReturn<T>>> }) => void;
760+
onCancel?: PromptWithOptions<Partial<T>, void>;
743761
}
744762

745-
type Prettify<T> = {
746-
[P in keyof T]: T[P];
747-
} & {};
748-
749-
export type PromptGroup<T> = {
750-
[P in keyof T]: (opts: {
751-
results: Prettify<Partial<PromptGroupAwaitedReturn<Omit<T, P>>>>;
752-
}) => void | Promise<T[P] | void>;
753-
};
754-
755763
/**
756764
* Define a group of prompts to be displayed
757765
* and return a results of objects within the group
758766
*/
759767
export const group = async <T>(
760768
prompts: PromptGroup<T>,
761769
opts?: PromptGroupOptions<T>
762-
): Promise<Prettify<PromptGroupAwaitedReturn<T>>> => {
770+
): Promise<PromptGroupAwaitedReturn<T>> => {
763771
const results = {} as any;
764772
const promptNames = Object.keys(prompts);
765773

@@ -784,6 +792,120 @@ export const group = async <T>(
784792
return results;
785793
};
786794

795+
type NextWorkflowBuilder<
796+
TResults extends Record<string, unknown>,
797+
TKey extends string,
798+
TResult,
799+
> = WorkflowBuilder<
800+
Prettify<
801+
{
802+
[Key in keyof TResults]: Key extends TKey ? TResult : TResults[Key];
803+
} & {
804+
[Key in TKey as undefined extends TResult ? never : TKey]: TResult;
805+
} & {
806+
[Key in TKey as undefined extends TResult ? TKey : never]?: TResult;
807+
}
808+
>
809+
>;
810+
811+
type WorkflowStep<TName extends string, TResults, TResult = unknown> = {
812+
name: TName;
813+
prompt: PromptWithOptions<TResults, TResult>;
814+
setResult: boolean;
815+
condition?: PromptWithOptions<TResults, boolean>;
816+
};
817+
818+
class WorkflowBuilder<TResults extends Record<string, unknown> = {}> {
819+
private results: TResults = {} as TResults;
820+
private steps: WorkflowStep<string, TResults>[] = [];
821+
private cancelCallback: PromptWithOptions<Partial<TResults>, void> | undefined;
822+
823+
public step<TName extends string, TResult>(
824+
name: TName extends keyof TResults ? never : TName,
825+
prompt: PromptWithOptions<TResults, TResult>
826+
): NextWorkflowBuilder<TResults, TName, PromptAwaitedReturn<TResult>> {
827+
this.steps.push({ name, prompt, setResult: true });
828+
return this as any;
829+
}
830+
831+
public conditionalStep<TName extends string, TResult>(
832+
name: TName,
833+
condition: PromptWithOptions<TResults, boolean>,
834+
prompt: PromptWithOptions<TResults, TResult>
835+
): NextWorkflowBuilder<
836+
TResults,
837+
TName,
838+
| (TName extends keyof TResults ? TResults[TName] : never)
839+
| PromptAwaitedReturn<TResult>
840+
| undefined
841+
> {
842+
this.steps.push({ name, prompt, condition, setResult: true });
843+
return this as any;
844+
}
845+
846+
public forkStep<TName extends string, TResult extends Record<string, unknown>>(
847+
name: TName,
848+
condition: PromptWithOptions<TResults, boolean>,
849+
subWorkflow: PromptWithOptions<TResults, WorkflowBuilder<TResult>>
850+
): NextWorkflowBuilder<
851+
TResults,
852+
TName,
853+
(TName extends keyof TResults ? TResults[TName] : never) | TResult | undefined
854+
> {
855+
this.steps.push({
856+
name,
857+
prompt: ({ results }) => {
858+
return subWorkflow({ results }).run();
859+
},
860+
condition,
861+
setResult: true,
862+
});
863+
return this as any;
864+
}
865+
866+
public logStep(
867+
name: string,
868+
prompt: PromptWithOptions<TResults, void>
869+
): WorkflowBuilder<TResults> {
870+
this.steps.push({ name, prompt, setResult: false });
871+
return this;
872+
}
873+
874+
public customStep<TName extends string, TResult>(
875+
step: WorkflowStep<TName, TResults, TResult>
876+
): NextWorkflowBuilder<TResults, TName, PromptAwaitedReturn<TResult>> {
877+
this.steps.push(step);
878+
return this as any;
879+
}
880+
881+
public onCancel(cb: PromptWithOptions<Partial<TResults>, void>): WorkflowBuilder<TResults> {
882+
this.cancelCallback = cb;
883+
return this;
884+
}
885+
886+
public async run(): Promise<TResults> {
887+
for (const step of this.steps) {
888+
if (step.condition && !step.condition({ results: this.results as any })) {
889+
continue;
890+
}
891+
const result = await step.prompt({ results: this.results as any });
892+
if (isCancel(result)) {
893+
this.cancelCallback?.({ results: this.results as any });
894+
continue;
895+
}
896+
if (step.setResult) {
897+
//@ts-ignore
898+
this.results[step.name] = result;
899+
}
900+
}
901+
return this.results;
902+
}
903+
}
904+
905+
export const workflow = () => {
906+
return new WorkflowBuilder();
907+
};
908+
787909
export type Task = {
788910
/**
789911
* Task title

0 commit comments

Comments
 (0)