Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Unreleased

- Added `Wizard` and `runWizard` for multi-step setup flows with conditional steps, a progress row, and a review screen where any step can be changed.
- Esc in `askAll` and `PromptList` now returns to the previous question instead of cancelling.
- Added `text`, `number`, `confirm`, `select`, and `multiselect` prompts with validation, including asynchronous checks, masked input, and typed answers through `defineFields`.
- Added `ask`, `askAll`, `Prompt`, and `PromptList`, and an interactive demonstration of a project setup flow.
- Added `FormFrame`, the `runForm` renderer with cancellation, Relay themes, and the `relay-forms` demonstration command.
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ npx tsx src/cli.tsx --demo --snapshot

## Ask questions

`askAll` asks a list of fields in order and resolves with the answers, keyed by field name. Answered questions stay in the terminal as a summary.
`askAll` asks a list of fields in order and resolves with the answers, keyed by field name. Answered questions stay in the terminal as a summary, and Esc returns to the previous question.

```ts
import {FormCancelledError, askAll, defineFields} from '@relay-cli/forms';
Expand Down Expand Up @@ -45,6 +45,25 @@ try {

`ask` asks a single field. Fields can be `text`, `number`, `confirm`, `select`, or `multiselect`, and each can validate its answer, including with an asynchronous check. See [the prompt guide](./docs/prompts.md).

## Multi-step wizards

`runWizard` groups fields into steps. Steps can depend on earlier answers, Esc moves back through questions and steps, and a review screen lets the person change any step before the wizard resolves.

```ts
import {runWizard} from '@relay-cli/forms';

const answers = await runWizard(
[
{id: 'project', title: 'Project', fields: [nameField, kindField]},
{id: 'service', title: 'Service', when: (a) => a.kind === 'service', fields: [portField]},
{id: 'publish', title: 'Publish', fields: [tokenField, gitField]},
],
{title: 'Create a project'},
);
```

Run `npm run demo -- wizard` to try it. See [the wizard guide](./docs/wizard.md).

## Build your own form

`runForm` renders any Ink component where the cursor is and resolves with the value the component submits. Ctrl+C cancels it, and `runForm` then rejects with `FormCancelledError`. `Prompt`, `PromptList`, and `FormFrame` are ordinary components, so they can be combined with your own.
Expand Down
1 change: 1 addition & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@ Relay Forms renders setup flows inline, below the command that started them, ins
1. `runForm` renders a form at the cursor and resolves with the value it submits. Ctrl+C and SIGTERM cancel the form, and `runForm` rejects with `FormCancelledError`.
2. `FormFrame` draws the title, a ruled body, and a footer for key hints.
3. A field describes one question. `Prompt` reads keys for it, validates the answer, and then collapses to a single answered line. `PromptList` asks fields in order. `ask` and `askAll` wrap both in `runForm`. See [prompts](./prompts.md).
4. `Wizard` groups fields into steps, decides which steps apply from the answers so far, and ends with a review screen. `runWizard` wraps it in `runForm`. See [wizards](./wizard.md).

The editing rules for text and lists are plain functions (`editText`, and the helpers in `src/prompts/choices.ts`), so they are tested without rendering. Each component is a plain Ink component, so a form can also be rendered inside a larger Ink application.
2 changes: 1 addition & 1 deletion docs/prompts.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ A prompt is one question described by a field. `ask` asks one field, `askAll` as
| `select` | the value | ↑↓ or `j`/`k`, Home/End, Enter | `options`, `initial`, `limit` |
| `multiselect` | the values | ↑↓, Space toggles, `a` toggles all, Enter | `options`, `initial`, `min`, `max`, `limit` |

Every field has a `name`, a `label`, and an optional `hint` shown beneath the active question. Esc cancels, and `ask` and `askAll` then reject with `FormCancelledError`.
Every field has a `name`, a `label`, and an optional `hint` shown beneath the active question. In `askAll` and `PromptList`, Esc returns to the previous question with its answer kept. At the first question Esc cancels, and `ask` and `askAll` then reject with `FormCancelledError`.

Options of `select` and `multiselect` take a `value`, an optional `label` and `hint`, and `disabled`, which may be a string explaining why the option cannot be picked. Lists longer than `limit` (7 by default) scroll.

Expand Down
62 changes: 62 additions & 0 deletions docs/wizard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Wizards

A wizard splits a setup flow into steps. Each step asks its fields with `PromptList`, a progress row shows where the person is, and a review screen lists every answer before the wizard submits.

```ts
import {runWizard, type WizardStep} from '@relay-cli/forms';

const steps: WizardStep[] = [
{
id: 'project',
title: 'Project',
fields: [
{kind: 'text', name: 'name', label: 'Project name', required: true},
{
kind: 'select',
name: 'kind',
label: 'Kind',
options: [{value: 'library'}, {value: 'service'}],
},
],
},
{
id: 'service',
title: 'Service',
when: (answers) => answers.kind === 'service',
fields: [{kind: 'number', name: 'port', label: 'Port', initial: 3000}],
},
{
id: 'publish',
title: 'Publish',
fields: (answers) => [
{kind: 'confirm', name: 'git', label: `Initialize git for ${String(answers.name)}?`},
],
},
];

const answers = await runWizard<{name: string; kind: string; port?: number; git: boolean}>(steps, {
title: 'Create a project',
});
```

## Steps

| Property | Meaning |
| ------------- | ------------------------------------------------------------- |
| `id` | A stable identifier, unique within the wizard. |
| `title` | A short name for the progress row and the review screen. |
| `description` | One line shown above the step's questions. |
| `fields` | The step's fields, or a function of the answers given so far. |
| `when` | Skip the step when this returns false. |

A step skipped by `when` does not appear in the progress row, and its answers are left out of the result even if they were given before an earlier answer changed.

## Navigation

- Enter accepts the current answer and moves on.
- Esc returns to the previous question. At the first question of a step it returns to the last question of the previous step, with its answers kept. At the very first question it cancels, and `runWizard` rejects with `FormCancelledError`.
- The review screen offers `Yes, continue` and a `Change …` entry for each step. Changing a step asks it again from its first question, with the earlier answers filled in. If the new answers make another step apply, that step is asked before the review screen returns.

Pass `review: false` to submit as soon as the last step is answered, and `initialValues` to start from existing answers, such as a configuration file.

`Wizard` is the component behind `runWizard`, for use inside a larger Ink application.
9 changes: 5 additions & 4 deletions src/cli.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import {renderToString} from 'ink';
import {helpText, parseCliOptions} from './cli/options.js';
import {DemoForm} from './demo/index.js';
import {demos} from './demo/index.js';
import {FormCancelledError, runForm} from './run.js';
import {relayFormsVersion} from './version.js';

Expand All @@ -19,20 +19,21 @@ const main = async (): Promise<void> => {
}
if (options.help) return void process.stdout.write(helpText);
if (options.version) return void process.stdout.write(`${relayFormsVersion}\n`);
if (!options.demo) {
if (options.demo === undefined) {
process.stderr.write(`Choose --demo to run the demonstration.\n\n${helpText}`);
process.exitCode = 2;
return;
}
const Demo = demos[options.demo];
if (options.snapshot || !process.stdout.isTTY || !process.stdin.isTTY) {
const columns = process.stdout.columns || 80;
process.stdout.write(`${renderToString(<DemoForm interactive={false} />, {columns})}\n`);
process.stdout.write(`${renderToString(<Demo interactive={false} />, {columns})}\n`);
return;
}

try {
const answers = await runForm<Record<string, unknown>>(({submit, cancel}) => (
<DemoForm onSubmit={submit} onCancel={cancel} />
<Demo onSubmit={submit} onCancel={cancel} />
));
process.stdout.write(`\n${JSON.stringify(answers, undefined, 2)}\n`);
} catch (error) {
Expand Down
34 changes: 26 additions & 8 deletions src/cli/options.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,51 @@
export const demoNames = ['prompts', 'wizard'] as const;
export type DemoName = (typeof demoNames)[number];

export interface CliOptions {
help: boolean;
version: boolean;
demo: boolean;
/** The demonstration to run, or `undefined` when none was chosen. */
demo: DemoName | undefined;
snapshot: boolean;
}

export const helpText = `Relay Forms

Usage:
relay-forms --demo
relay-forms --demo --snapshot
relay-forms --demo [prompts|wizard]
relay-forms --demo wizard --snapshot

Options:
--demo Run the built-in setup demonstration
--demo [name] Run a built-in demonstration (default: prompts)
--snapshot Print the demonstration's first screen and exit
-h, --help Show this help
-v, --version Show the version

Demonstrations:
prompts A project setup flow that uses every kind of question
wizard The same flow split into steps, with a review screen

Relay Forms is a library. Import it from @relay-cli/forms to build your own setup flow.
`;

const isDemoName = (value: string | undefined): value is DemoName =>
demoNames.includes(value as DemoName);

export const parseCliOptions = (arguments_: readonly string[]): CliOptions => {
const options: CliOptions = {help: false, version: false, demo: false, snapshot: false};
for (const argument of arguments_) {
const options: CliOptions = {help: false, version: false, demo: undefined, snapshot: false};
for (let index = 0; index < arguments_.length; index += 1) {
const argument = arguments_[index] ?? '';
if (argument === '-h' || argument === '--help') options.help = true;
else if (argument === '-v' || argument === '--version') options.version = true;
else if (argument === '--demo') options.demo = true;
else if (argument === '--snapshot') options.snapshot = true;
else throw new Error(`Unknown option: ${argument}`);
else if (argument === '--demo' || argument.startsWith('--demo=')) {
const inline = argument.startsWith('--demo=') ? argument.slice('--demo='.length) : undefined;
const next = arguments_[index + 1];
const name = inline ?? (isDemoName(next) ? next : 'prompts');
if (!isDemoName(name)) throw new Error(`Unknown demonstration: ${name}`);
if (inline === undefined && name === next) index += 1;
options.demo = name;
} else throw new Error(`Unknown option: ${argument}`);
}
return options;
};
10 changes: 2 additions & 8 deletions src/demo/DemoForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {Text} from 'ink';
import {FormFrame} from '../frame/index.js';
import {defineFields, keyHints, PromptList, type Field} from '../prompts/index.js';
import {relayTheme} from '../theme.js';
import type {DemoProps} from './types.js';

export const demoFields = defineFields([
{
Expand Down Expand Up @@ -40,15 +41,8 @@ export const demoFields = defineFields([
{kind: 'confirm', name: 'git', label: 'Initialize a git repository?', initial: true},
]);

export interface DemoFormProps {
/** Read keys. A snapshot renders the first question without waiting. */
interactive?: boolean;
onSubmit?: (answers: Record<string, unknown>) => void;
onCancel?: () => void;
}

/** A project setup flow that shows each kind of prompt. */
export const DemoForm = ({interactive = true, onSubmit, onCancel}: DemoFormProps) => {
export const DemoForm = ({interactive = true, onSubmit, onCancel}: DemoProps) => {
const [current, setCurrent] = useState<Field | undefined>(demoFields[0]);
return (
<FormFrame
Expand Down
64 changes: 64 additions & 0 deletions src/demo/DemoWizard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import {Wizard, type WizardStep} from '../wizard/index.js';
import {demoFields} from './DemoForm.js';
import type {DemoProps} from './types.js';

const field = (name: string) => {
const found = demoFields.find((candidate) => candidate.name === name);
if (found === undefined) throw new Error(`No demonstration field named ${name}`);
return found;
};

export const demoSteps: WizardStep[] = [
{
id: 'project',
title: 'Project',
fields: [
field('name'),
{
kind: 'select',
name: 'kind',
label: 'What are you building?',
options: [
{value: 'library', label: 'A library'},
{value: 'service', label: 'A web service'},
],
},
],
},
{
id: 'tooling',
title: 'Tooling',
fields: [field('packageManager'), field('features')],
},
{
id: 'service',
title: 'Service',
description: 'Only asked for web services.',
when: (answers) => answers.kind === 'service',
fields: [field('port')],
},
{
id: 'publish',
title: 'Publish',
fields: (answers) => [
field('token'),
{
kind: 'confirm',
name: 'git',
label: `Initialize a git repository for ${String(answers.name)}?`,
initial: true,
},
],
},
];

/** The project setup flow split into steps, with a review screen. */
export const DemoWizard = ({interactive = true, onSubmit, onCancel}: DemoProps) => (
<Wizard
title="Relay Forms"
steps={demoSteps}
isActive={interactive}
onSubmit={onSubmit ?? (() => undefined)}
{...(onCancel === undefined ? {} : {onCancel})}
/>
);
15 changes: 14 additions & 1 deletion src/demo/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,14 @@
export {DemoForm, demoFields, type DemoFormProps} from './DemoForm.js';
import type {ReactNode} from 'react';
import type {DemoName} from '../cli/options.js';
import {DemoForm} from './DemoForm.js';
import {DemoWizard} from './DemoWizard.js';
import type {DemoProps} from './types.js';

export {DemoForm, demoFields} from './DemoForm.js';
export {DemoWizard, demoSteps} from './DemoWizard.js';
export type {DemoProps} from './types.js';

export const demos: Record<DemoName, (props: DemoProps) => ReactNode> = {
prompts: DemoForm,
wizard: DemoWizard,
};
6 changes: 6 additions & 0 deletions src/demo/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export interface DemoProps {
/** Read keys. A snapshot renders the first screen without waiting. */
interactive?: boolean;
onSubmit?: (answers: Record<string, unknown>) => void;
onCancel?: () => void;
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export * from './prompts/index.js';
export {FormCancelledError, runForm, type FormControls, type RunFormOptions} from './run.js';
export {formThemes, monoTheme, relayTheme, type FormTheme} from './theme.js';
export {relayFormsVersion} from './version.js';
export * from './wizard/index.js';
15 changes: 6 additions & 9 deletions src/prompts/Prompt.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {useEffect, useRef, useState} from 'react';
import {useRef, useState} from 'react';
import {Box, Text, useInput} from 'ink';
import {relayTheme, type FormTheme} from '../theme.js';
import {formatAnswer, validateAnswer} from './format.js';
Expand Down Expand Up @@ -76,16 +76,13 @@ export const Prompt = <F extends Field>({
{isActive: isActive && phase.name !== 'done'},
);

const submitted = useRef(false);
useEffect(() => {
if (phase.name !== 'done' || submitted.current) return;
submitted.current = true;
onSubmit(phase.value as FieldValue<F>);
}, [phase, onSubmit]);

// Submit in the same update that collapses the prompt, so a list can mount
// the next question before the next key arrives.
const settle = (value: unknown, message: string | undefined): void => {
setError(message);
setPhase(message === undefined ? {name: 'done', value} : {name: 'editing'});
if (message !== undefined) return setPhase({name: 'editing'});
setPhase({name: 'done', value});
onSubmit(value as FieldValue<F>);
};

const accept = (value: unknown): void => {
Expand Down
Loading
Loading