Skip to content

Commit

Permalink
Merge branch 'builder' of https://github.com/Mist3rBru/clack into bui…
Browse files Browse the repository at this point in the history
…lder
  • Loading branch information
orochaa committed Dec 15, 2024
2 parents 4c98bd2 + 3702152 commit 7fd94a0
Show file tree
Hide file tree
Showing 6 changed files with 277 additions and 17 deletions.
5 changes: 5 additions & 0 deletions .changeset/red-walls-greet.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clack/prompts': minor
---

add prompt `workflow` builder
3 changes: 2 additions & 1 deletion examples/basic/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
65 changes: 65 additions & 0 deletions examples/basic/workflow.ts
Original file line number Diff line number Diff line change
@@ -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();
})();
68 changes: 67 additions & 1 deletion packages/prompts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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);
```
152 changes: 138 additions & 14 deletions packages/prompts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -750,36 +750,45 @@ function ansiRegex() {
return new RegExp(pattern, 'g');
}

export type PromptGroupAwaitedReturn<T> = {
[P in keyof T]: Exclude<Awaited<T[P]>, symbol>;
type Prettify<T> = {
[P in keyof T]: T[P];
} & {};

export type PromptAwaitedReturn<T> = Exclude<Awaited<T>, symbol>;

export type PromptGroupAwaitedReturn<T> = Prettify<{
[P in keyof T]: PromptAwaitedReturn<T[P]>;
}>;

// biome-ignore lint/complexity/noBannedTypes: <explanation>
export type PromptWithOptions<TResults, TResult, TOptions extends Record<string, unknown> = {}> = (
opts: Prettify<
{
results: PromptGroupAwaitedReturn<TResults>;
} & TOptions
>
) => TResult;

export type PromptGroup<T> = {
[P in keyof T]: PromptWithOptions<Partial<Omit<T, P>>, undefined | Promise<T[P] | undefined>>;
};

export interface PromptGroupOptions<T> {
/**
* Control how the group can be canceled
* if one of the prompts is canceled.
*/
onCancel?: (opts: { results: Prettify<Partial<PromptGroupAwaitedReturn<T>>> }) => void;
onCancel?: PromptWithOptions<Partial<T>, void>;
}

type Prettify<T> = {
[P in keyof T]: T[P];
} & {};

export type PromptGroup<T> = {
[P in keyof T]: (opts: {
results: Prettify<Partial<PromptGroupAwaitedReturn<Omit<T, P>>>>;
}) => undefined | Promise<T[P] | undefined>;
};

/**
* Define a group of prompts to be displayed
* and return a results of objects within the group
*/
export const group = async <T>(
prompts: PromptGroup<T>,
opts?: PromptGroupOptions<T>
): Promise<Prettify<PromptGroupAwaitedReturn<T>>> => {
): Promise<PromptGroupAwaitedReturn<T>> => {
const results = {} as any;
const promptNames = Object.keys(prompts);

Expand Down Expand Up @@ -833,3 +842,118 @@ export const tasks = async (tasks: Task[]) => {
s.stop(result || task.title);
}
};

type NextWorkflowBuilder<
TResults extends Record<string, unknown>,
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<TName extends string, TResults, TResult = unknown> = {
name: TName;
prompt: PromptWithOptions<TResults, TResult>;
setResult: boolean;
condition?: PromptWithOptions<TResults, boolean>;
};

// biome-ignore lint/complexity/noBannedTypes: <explanation>
class WorkflowBuilder<TResults extends Record<string, unknown> = {}> {
private results: TResults = {} as TResults;
private steps: WorkflowStep<string, TResults>[] = [];
private cancelCallback: PromptWithOptions<Partial<TResults>, void> | undefined;

public step<TName extends string, TResult>(
name: TName extends keyof TResults ? never : TName,
prompt: PromptWithOptions<TResults, TResult>
): NextWorkflowBuilder<TResults, TName, PromptAwaitedReturn<TResult>> {
this.steps.push({ name, prompt, setResult: true });
return this as any;
}

public conditionalStep<TName extends string, TResult>(
name: TName,
condition: PromptWithOptions<TResults, boolean>,
prompt: PromptWithOptions<TResults, TResult>
): NextWorkflowBuilder<
TResults,
TName,
| (TName extends keyof TResults ? TResults[TName] : never)
| PromptAwaitedReturn<TResult>
| undefined
> {
this.steps.push({ name, prompt, condition, setResult: true });
return this as any;
}

public forkStep<TName extends string, TResult extends Record<string, unknown>>(
name: TName,
condition: PromptWithOptions<TResults, boolean>,
subWorkflow: PromptWithOptions<TResults, WorkflowBuilder<TResult>>
): 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<TResults, void>
): WorkflowBuilder<TResults> {
this.steps.push({ name, prompt, setResult: false });
return this;
}

public customStep<TName extends string, TResult>(
step: WorkflowStep<TName, TResults, TResult>
): NextWorkflowBuilder<TResults, TName, PromptAwaitedReturn<TResult>> {
this.steps.push(step);
return this as any;
}

public onCancel(cb: PromptWithOptions<Partial<TResults>, void>): WorkflowBuilder<TResults> {
this.cancelCallback = cb;
return this;
}

public async run(): Promise<TResults> {
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();
};
1 change: 0 additions & 1 deletion pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 7fd94a0

Please sign in to comment.