diff --git a/CHANGELOG.md b/CHANGELOG.md index 728cd7b..a6b6284 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index d01692a..46c0491 100644 --- a/README.md +++ b/README.md @@ -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'; @@ -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. diff --git a/docs/architecture.md b/docs/architecture.md index 14809f6..ea44b99 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. diff --git a/docs/prompts.md b/docs/prompts.md index b1c8f6d..62c4331 100644 --- a/docs/prompts.md +++ b/docs/prompts.md @@ -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. diff --git a/docs/wizard.md b/docs/wizard.md new file mode 100644 index 0000000..d67b13e --- /dev/null +++ b/docs/wizard.md @@ -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. diff --git a/src/cli.tsx b/src/cli.tsx index fd5b3d6..d711a87 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -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'; @@ -19,20 +19,21 @@ const main = async (): Promise => { } 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(, {columns})}\n`); + process.stdout.write(`${renderToString(, {columns})}\n`); return; } try { const answers = await runForm>(({submit, cancel}) => ( - + )); process.stdout.write(`\n${JSON.stringify(answers, undefined, 2)}\n`); } catch (error) { diff --git a/src/cli/options.ts b/src/cli/options.ts index 3eb6526..008bf70 100644 --- a/src/cli/options.ts +++ b/src/cli/options.ts @@ -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; }; diff --git a/src/demo/DemoForm.tsx b/src/demo/DemoForm.tsx index 69ebc7b..f1546ab 100644 --- a/src/demo/DemoForm.tsx +++ b/src/demo/DemoForm.tsx @@ -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([ { @@ -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) => 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(demoFields[0]); return ( { + 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) => ( + undefined)} + {...(onCancel === undefined ? {} : {onCancel})} + /> +); diff --git a/src/demo/index.ts b/src/demo/index.ts index 8f89e96..ba3cdb7 100644 --- a/src/demo/index.ts +++ b/src/demo/index.ts @@ -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 ReactNode> = { + prompts: DemoForm, + wizard: DemoWizard, +}; diff --git a/src/demo/types.ts b/src/demo/types.ts new file mode 100644 index 0000000..e6c63df --- /dev/null +++ b/src/demo/types.ts @@ -0,0 +1,6 @@ +export interface DemoProps { + /** Read keys. A snapshot renders the first screen without waiting. */ + interactive?: boolean; + onSubmit?: (answers: Record) => void; + onCancel?: () => void; +} diff --git a/src/index.ts b/src/index.ts index 7385687..4806524 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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'; diff --git a/src/prompts/Prompt.tsx b/src/prompts/Prompt.tsx index 1be8a3e..01555f7 100644 --- a/src/prompts/Prompt.tsx +++ b/src/prompts/Prompt.tsx @@ -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'; @@ -76,16 +76,13 @@ export const Prompt = ({ {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); - }, [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); }; const accept = (value: unknown): void => { diff --git a/src/prompts/ask.tsx b/src/prompts/ask.tsx index 9183494..c8f59f2 100644 --- a/src/prompts/ask.tsx +++ b/src/prompts/ask.tsx @@ -1,4 +1,4 @@ -import {useState} from 'react'; +import {useEffect, useRef, useState} from 'react'; import {Box} from 'ink'; import {runForm, type RunFormOptions} from '../run.js'; import {relayTheme, type FormTheme} from '../theme.js'; @@ -34,44 +34,71 @@ export interface PromptListProps { fields: readonly Field[]; /** Answers to start each field from, keyed by field name. */ initialValues?: Readonly>; + /** + * Start at the last field, showing the earlier ones as answered from + * `initialValues`, such as when returning to a step of a wizard. + */ + resume?: boolean; onSubmit: (answers: Record) => void; /** Called after each answer with the field asked next, if any. */ onAnswer?: (field: Field, value: unknown, next: Field | undefined) => void; + /** Called on Esc at the first field. Esc at a later field returns to the one before. */ onCancel?: () => void; isActive?: boolean; theme?: FormTheme; } +const startIndex = ( + fields: readonly Field[], + values: Readonly>, + resume: boolean, +): number => { + if (!resume) return 0; + const missing = fields.findIndex((field) => !(field.name in values)); + return missing === -1 ? Math.max(0, fields.length - 1) : missing; +}; + /** Fields asked one after another, with earlier answers kept above the current one. */ export const PromptList = ({ fields, initialValues = {}, + resume = false, onSubmit, onAnswer, onCancel, isActive = true, theme = relayTheme, }: PromptListProps) => { - const [answers, setAnswers] = useState>({}); - const index = Object.keys(answers).length; + const [values, setValues] = useState>(() => ({...initialValues})); + const [index, setIndex] = useState(() => startIndex(fields, initialValues, resume)); const current = fields[index]; + + const submitted = useRef(false); + useEffect(() => { + if (fields.length > 0 || submitted.current) return; + submitted.current = true; + onSubmit({}); + }, [fields, onSubmit]); + return ( {fields.slice(0, index).map((field) => ( - + ))} {current === undefined ? null : ( { - const next = {...answers, [current.name]: value}; - setAnswers(next); + const next = {...values, [current.name]: value}; + setValues(next); onAnswer?.(current, value, fields[index + 1]); - if (index + 1 === fields.length) onSubmit(next); + if (index + 1 < fields.length) return setIndex(index + 1); + setIndex(fields.length); + onSubmit(Object.fromEntries(fields.map((field) => [field.name, next[field.name]]))); }} - {...(onCancel === undefined ? {} : {onCancel})} + onCancel={() => (index > 0 ? setIndex(index - 1) : onCancel?.())} isActive={isActive} theme={theme} /> diff --git a/src/run.tsx b/src/run.tsx index 80be57b..d9198ed 100644 --- a/src/run.tsx +++ b/src/run.tsx @@ -39,20 +39,24 @@ export const runForm = async ( build: (controls: FormControls) => ReactNode, options: RunFormOptions = {}, ): Promise => { - // A form may finish during its first render, before `render` has returned, - // so the outcome lives on an object the controls and the caller share. + // Unmount on the next turn of the event loop: a form may finish during its + // first render, before `render` has returned, and the update that shows the + // final answer should be drawn before the form closes. const state: {settled: boolean; result?: {value: T}; app?: Instance} = {settled: false}; + const close = (): void => { + setTimeout(() => state.app?.unmount(), 0); + }; const controls: FormControls = { submit: (value) => { if (state.settled) return; state.settled = true; state.result = {value}; - state.app?.unmount(); + close(); }, cancel: () => { if (state.settled) return; state.settled = true; - state.app?.unmount(); + close(); }, }; const app = render( @@ -65,7 +69,6 @@ export const runForm = async ( }, ); state.app = app; - if (state.settled) app.unmount(); const interrupt = (): void => controls.cancel(); process.once('SIGTERM', interrupt); try { diff --git a/src/wizard/Wizard.tsx b/src/wizard/Wizard.tsx new file mode 100644 index 0000000..6daa37f --- /dev/null +++ b/src/wizard/Wizard.tsx @@ -0,0 +1,260 @@ +import {useEffect, useRef, useState} from 'react'; +import {Box, Text} from 'ink'; +import {FormFrame} from '../frame/index.js'; +import {AnsweredLine, Prompt, PromptList, type Field} from '../prompts/index.js'; +import {relayTheme, type FormTheme} from '../theme.js'; + +export type WizardAnswers = Record; + +export interface WizardStep { + /** A stable identifier, unique within the wizard. */ + id: string; + /** A short name shown in the progress row and on the review screen. */ + title: string; + /** One line shown above the step's questions. */ + description?: string; + /** The step's questions, or a function of the answers given so far. */ + fields: readonly Field[] | ((answers: WizardAnswers) => readonly Field[]); + /** Skip the step when this returns false. Answers to skipped steps are dropped. */ + when?: (answers: WizardAnswers) => boolean; +} + +export interface WizardProps { + title: string; + steps: readonly WizardStep[]; + /** Answers to start from, such as an existing configuration. */ + initialValues?: WizardAnswers; + /** Show every answer and ask for confirmation before submitting. Defaults to true. */ + review?: boolean; + onSubmit: (answers: WizardAnswers) => void; + /** Called on Esc at the first question of the first step. */ + onCancel?: () => void; + isActive?: boolean; + theme?: FormTheme; +} + +const REVIEW = Symbol('review'); +const DONE = Symbol('done'); +type Position = {step: string; resume: boolean} | typeof REVIEW | typeof DONE; + +export const stepFields = (step: WizardStep, answers: WizardAnswers): readonly Field[] => + typeof step.fields === 'function' ? step.fields(answers) : step.fields; + +/** The steps that apply to a set of answers, in order. */ +export const visibleSteps = (steps: readonly WizardStep[], answers: WizardAnswers): WizardStep[] => + steps.filter((step) => step.when?.(answers) ?? true); + +/** Keep only the answers to fields of steps that still apply. */ +export const collectAnswers = ( + steps: readonly WizardStep[], + answers: WizardAnswers, +): WizardAnswers => { + const result: WizardAnswers = {}; + for (const step of visibleSteps(steps, answers)) { + for (const field of stepFields(step, answers)) { + if (field.name in answers) result[field.name] = answers[field.name]; + } + } + return result; +}; + +const Progress = ({ + steps, + completed, + current, + review, + theme, +}: { + steps: readonly WizardStep[]; + completed: ReadonlySet; + current: string | undefined; + review: boolean; + theme: FormTheme; +}) => { + const items = steps.map((step) => ({ + key: step.id, + title: step.title, + state: step.id === current ? 'current' : completed.has(step.id) ? 'done' : 'todo', + })); + if (review) + items.push({key: '', title: 'Review', state: current === undefined ? 'current' : 'todo'}); + return ( + + {items.map((item, index) => ( + + {index === 0 ? '' : · } + {item.state === 'done' ? ( + ✔ {item.title} + ) : item.state === 'current' ? ( + + › {item.title} + + ) : ( + {item.title} + )} + + ))} + + ); +}; + +const Summary = ({ + steps, + answers, + theme, +}: { + steps: readonly WizardStep[]; + answers: WizardAnswers; + theme: FormTheme; +}) => ( + + {visibleSteps(steps, answers).map((step) => ( + + {step.title} + {stepFields(step, answers).map((field) => ( + + ))} + + ))} + +); + +const SUBMIT = '\u0000submit'; + +/** + * A setup flow split into steps. Each step asks its questions with + * `PromptList`; Esc returns to the previous question or step, and the review + * screen lets the person change any step before submitting. + */ +export const Wizard = ({ + title, + steps, + initialValues = {}, + review = true, + onSubmit, + onCancel, + isActive = true, + theme = relayTheme, +}: WizardProps) => { + const [answers, setAnswers] = useState(initialValues); + const [completed, setCompleted] = useState>(new Set()); + const [position, setPosition] = useState(() => { + const first = visibleSteps(steps, initialValues)[0]; + return first === undefined ? (review ? REVIEW : DONE) : {step: first.id, resume: false}; + }); + + const visible = visibleSteps(steps, answers); + const finish = (nextAnswers: WizardAnswers, nextCompleted: ReadonlySet): void => { + const pending = visibleSteps(steps, nextAnswers).find((step) => !nextCompleted.has(step.id)); + if (pending !== undefined) setPosition({step: pending.id, resume: false}); + else setPosition(review ? REVIEW : DONE); + }; + + const submitted = useRef(false); + useEffect(() => { + if (position !== DONE || submitted.current) return; + submitted.current = true; + onSubmit(collectAnswers(steps, answers)); + }, [position, steps, answers, onSubmit]); + + const back = (from: number): void => { + const previous = visible[from - 1]; + if (previous === undefined) return onCancel?.(); + const nextCompleted = new Set(completed); + nextCompleted.delete(previous.id); + setCompleted(nextCompleted); + setPosition({step: previous.id, resume: true}); + }; + + let body; + let current: string | undefined; + let subtitle: string; + if (position === DONE) { + subtitle = 'done'; + body = ; + } else if (position === REVIEW) { + subtitle = 'review'; + body = ( + + + ({value: step.id, label: `Change ${step.title}`})), + ], + }} + onSubmit={(choice) => { + if (choice === SUBMIT) return setPosition(DONE); + const nextCompleted = new Set(completed); + nextCompleted.delete(choice); + setCompleted(nextCompleted); + setPosition({step: choice, resume: false}); + }} + onCancel={() => back(visible.length)} + isActive={isActive} + theme={theme} + /> + + ); + } else { + const index = Math.max( + 0, + visible.findIndex((step) => step.id === position.step), + ); + const step = visible[index]; + current = step?.id; + subtitle = `step ${index + 1} of ${visible.length}`; + body = + step === undefined ? null : ( + + {step.description === undefined ? null : ( + {step.description} + )} + { + const nextAnswers = {...answers, ...stepAnswers}; + const nextCompleted = new Set(completed).add(step.id); + setAnswers(nextAnswers); + setCompleted(nextCompleted); + finish(nextAnswers, nextCompleted); + }} + onCancel={() => back(index)} + isActive={isActive} + theme={theme} + /> + + ); + } + + return ( + enter continue · esc back · ctrl+c cancel + ) + } + > + {position === DONE ? null : ( + + )} + {body} + + ); +}; diff --git a/src/wizard/index.ts b/src/wizard/index.ts new file mode 100644 index 0000000..aced259 --- /dev/null +++ b/src/wizard/index.ts @@ -0,0 +1,10 @@ +export {runWizard, type RunWizardOptions} from './run.js'; +export { + collectAnswers, + stepFields, + visibleSteps, + Wizard, + type WizardAnswers, + type WizardProps, + type WizardStep, +} from './Wizard.js'; diff --git a/src/wizard/run.tsx b/src/wizard/run.tsx new file mode 100644 index 0000000..cdd9328 --- /dev/null +++ b/src/wizard/run.tsx @@ -0,0 +1,34 @@ +import {runForm, type RunFormOptions} from '../run.js'; +import type {FormTheme} from '../theme.js'; +import {Wizard, type WizardAnswers, type WizardStep} from './Wizard.js'; + +export interface RunWizardOptions extends RunFormOptions { + title: string; + initialValues?: WizardAnswers; + review?: boolean; + theme?: FormTheme; +} + +/** + * Run a multi-step setup flow and resolve with the answers to every step that + * applied. Rejects with `FormCancelledError` on Ctrl+C or on Esc at the first + * question. + */ +export const runWizard = ( + steps: readonly WizardStep[], + options: RunWizardOptions, +): Promise => + runForm( + ({submit, cancel}) => ( + submit(answers as T)} + onCancel={cancel} + /> + ), + options, + ); diff --git a/tests/cli-options.test.ts b/tests/cli-options.test.ts index 60f87f8..fb379a7 100644 --- a/tests/cli-options.test.ts +++ b/tests/cli-options.test.ts @@ -2,14 +2,20 @@ import {describe, expect, it} from 'vitest'; import {parseCliOptions} from '../src/cli/options.js'; describe('CLI options', () => { - it('reads the demonstration flags', () => { + it('runs the prompts demonstration by default', () => { expect(parseCliOptions(['--demo', '--snapshot'])).toMatchObject({ - demo: true, + demo: 'prompts', snapshot: true, help: false, }); }); + it('reads a demonstration name', () => { + expect(parseCliOptions(['--demo', 'wizard']).demo).toBe('wizard'); + expect(parseCliOptions(['--demo=wizard']).demo).toBe('wizard'); + expect(() => parseCliOptions(['--demo=nope'])).toThrow('Unknown demonstration'); + }); + it('rejects unknown options', () => { expect(() => parseCliOptions(['--wat'])).toThrow('Unknown option'); }); diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 17f4d8a..ef51ed7 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -13,6 +13,12 @@ describe('relay-forms command', () => { expect(stdout).toContain('Relay Forms'); }, 30_000); + it('prints the first step of the wizard demonstration', async () => { + const {stdout} = await run(process.execPath, [tsx, cli, '--demo', 'wizard', '--snapshot']); + expect(stdout).toContain('step 1 of 3'); + expect(stdout).toContain('Project name'); + }, 30_000); + it('explains how to start when no mode is chosen', async () => { await expect(run(process.execPath, [tsx, cli])).rejects.toMatchObject({code: 2}); }, 30_000); diff --git a/tests/demo.test.tsx b/tests/demo.test.tsx index 3bb1623..6e95e48 100644 --- a/tests/demo.test.tsx +++ b/tests/demo.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import {describe, expect, it} from 'vitest'; -import {DemoForm} from '../src/demo/index.js'; +import {DemoForm, DemoWizard} from '../src/demo/index.js'; import {runForm} from '../src/run.js'; import {fakeTerminal, tick} from './helpers.js'; @@ -28,3 +28,30 @@ describe('demo setup flow', () => { expect(terminal.output()).toContain('✔ Features · Linting'); }); }); + +describe('demo wizard', () => { + it('asks the service step only for web services', async () => { + const terminal = fakeTerminal(); + const pending = runForm>( + ({submit, cancel}) => , + terminal, + ); + await tick(); + // name, kind (service), package manager, features, port, token, git, review + const keys = ['api', '\r', '\u001B[B', '\r', '\r', '\r', '\r', '\r', 'n', '\r']; + for (const key of keys) { + terminal.press(key); + await tick(); + } + await expect(pending).resolves.toEqual({ + name: 'api', + kind: 'service', + packageManager: 'npm', + features: ['tests', 'lint'], + port: 3000, + token: '', + git: false, + }); + expect(terminal.output()).toContain('Relay Forms · done'); + }); +}); diff --git a/tests/prompt.test.tsx b/tests/prompt.test.tsx index 9be26f6..dba65d4 100644 --- a/tests/prompt.test.tsx +++ b/tests/prompt.test.tsx @@ -1,7 +1,7 @@ import React from 'react'; import {render} from 'ink-testing-library'; import {describe, expect, expectTypeOf, it, vi} from 'vitest'; -import {ask, askAll, defineFields, Prompt, type Field} from '../src/prompts/index.js'; +import {ask, askAll, defineFields, Prompt, PromptList, type Field} from '../src/prompts/index.js'; import {FormCancelledError} from '../src/run.js'; import {fakeTerminal, tick} from './helpers.js'; @@ -204,3 +204,19 @@ describe('askAll', () => { expectTypeOf(answers).toEqualTypeOf<{name: string; pm: 'npm' | 'pnpm'; git: boolean}>(); }); }); + +describe('PromptList', () => { + it('returns to the previous question on Esc, keeping its answer', async () => { + const fields: Field[] = [ + {kind: 'text', name: 'first', label: 'First'}, + {kind: 'text', name: 'second', label: 'Second'}, + ]; + const onSubmit = vi.fn(); + const view = render(); + await tick(); + await press(view.stdin, 'a', enter, escape); + expect(view.lastFrame()).toContain('? First a'); + await press(view.stdin, 'b', enter, 'c', enter); + expect(onSubmit).toHaveBeenCalledWith({first: 'ab', second: 'c'}); + }); +}); diff --git a/tests/wizard.test.tsx b/tests/wizard.test.tsx new file mode 100644 index 0000000..4801e68 --- /dev/null +++ b/tests/wizard.test.tsx @@ -0,0 +1,149 @@ +import React from 'react'; +import {render} from 'ink-testing-library'; +import {describe, expect, it, vi} from 'vitest'; +import {FormCancelledError} from '../src/run.js'; +import { + collectAnswers, + runWizard, + visibleSteps, + Wizard, + type WizardStep, +} from '../src/wizard/index.js'; +import {fakeTerminal, tick} from './helpers.js'; + +const enter = '\r'; +const escape = '\u001B'; +const down = '\u001B[B'; + +const steps: WizardStep[] = [ + { + id: 'project', + title: 'Project', + fields: [ + {kind: 'text', name: 'name', label: 'Name', initial: 'demo'}, + {kind: 'select', name: 'kind', label: 'Kind', options: [{value: 'library'}, {value: 'app'}]}, + ], + }, + { + id: 'deploy', + title: 'Deploy', + description: 'Where the app runs.', + when: (answers) => answers.kind === 'app', + fields: [ + { + kind: 'select', + name: 'target', + label: 'Target', + options: [{value: 'fly'}, {value: 'docker'}], + }, + ], + }, + { + id: 'extras', + title: 'Extras', + fields: (answers) => [ + {kind: 'confirm', name: 'git', label: `Initialize git for ${String(answers.name)}?`}, + ], + }, +]; + +const press = async (stdin: {write: (data: string) => void}, ...keys: string[]) => { + for (const key of keys) { + stdin.write(key); + await tick(); + } +}; + +describe('wizard steps', () => { + it('skips steps whose condition fails and drops their answers', () => { + const answers = {name: 'demo', kind: 'library', target: 'fly', git: true}; + expect(visibleSteps(steps, answers).map((step) => step.id)).toEqual(['project', 'extras']); + expect(collectAnswers(steps, answers)).toEqual({name: 'demo', kind: 'library', git: true}); + }); +}); + +describe('Wizard', () => { + it('shows progress, skips conditional steps, and submits after review', async () => { + const onSubmit = vi.fn(); + const view = render(); + await tick(); + expect(view.lastFrame()).toContain('› Project · Extras · Review'); + expect(view.lastFrame()).toContain('step 1 of 2'); + await press(view.stdin, enter, enter); + expect(view.lastFrame()).toContain('✔ Project · › Extras'); + expect(view.lastFrame()).toContain('Initialize git for demo?'); + await press(view.stdin, 'y'); + expect(view.lastFrame()).toContain('Everything look right?'); + expect(view.lastFrame()).toContain('Kind · library'); + await press(view.stdin, enter); + expect(onSubmit).toHaveBeenCalledWith({name: 'demo', kind: 'library', git: true}); + }); + + it('asks a step that a changed answer makes relevant', async () => { + const onSubmit = vi.fn(); + const view = render(); + await tick(); + await press(view.stdin, enter, enter, 'n'); + // Review: pick "Change Project", keep the name, switch the kind to app. + await press(view.stdin, down, enter, enter, down, enter); + expect(view.lastFrame()).toContain('Where the app runs.'); + await press(view.stdin, down, enter); + expect(view.lastFrame()).toContain('Target · docker'); + await press(view.stdin, enter); + expect(onSubmit).toHaveBeenCalledWith({ + name: 'demo', + kind: 'app', + target: 'docker', + git: false, + }); + }); + + it('returns to the last question of the previous step on Esc', async () => { + const view = render( undefined} />); + await tick(); + await press(view.stdin, 'x', enter, enter); + expect(view.lastFrame()).toContain('› Extras'); + await press(view.stdin, escape); + expect(view.lastFrame()).toContain('› Project'); + expect(view.lastFrame()).toContain('✔ Name · demox'); + expect(view.lastFrame()).toContain('? Kind'); + }); + + it('submits after the last step when review is off', async () => { + const onSubmit = vi.fn(); + const view = render( + , + ); + await tick(); + await press(view.stdin, enter, enter, 'y'); + expect(onSubmit).toHaveBeenCalledWith({name: 'kept', kind: 'library', git: true}); + }); +}); + +describe('runWizard', () => { + it('resolves with the answers and leaves a summary in the terminal', async () => { + const terminal = fakeTerminal(); + const pending = runWizard(steps, {...terminal, title: 'Create', review: false}); + await tick(); + for (const key of [enter, enter, 'y']) { + terminal.press(key); + await tick(); + } + await expect(pending).resolves.toEqual({name: 'demo', kind: 'library', git: true}); + expect(terminal.output()).toContain('Create · done'); + }); + + it('cancels on Esc at the first question', async () => { + const terminal = fakeTerminal(); + const pending = runWizard(steps, {...terminal, title: 'Create'}); + await tick(); + terminal.press(escape); + await expect(pending).rejects.toBeInstanceOf(FormCancelledError); + }); +});