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,4 +2,6 @@

## Unreleased

- 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.
46 changes: 35 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,32 +9,56 @@ npm install
npm run demo
```

To print the demonstration without waiting for input:
The demonstration asks the questions of a project setup flow and prints the answers. To print its first screen without waiting for input:

```sh
npx tsx src/cli.tsx --demo --snapshot
```

## Use it in a tool
## Ask questions

`runForm` renders a form where the cursor is and resolves with the value the form submits. Esc and Ctrl+C cancel it, and `runForm` then rejects with `FormCancelledError`.
`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.

```tsx
import {Text} from 'ink';
import {FormCancelledError, FormFrame, runForm} from '@relay-cli/forms';
```ts
import {FormCancelledError, askAll, defineFields} from '@relay-cli/forms';

const fields = defineFields([
{kind: 'text', name: 'name', label: 'Project name', required: true},
{
kind: 'select',
name: 'packageManager',
label: 'Package manager',
options: [{value: 'npm'}, {value: 'pnpm'}],
},
{kind: 'text', name: 'token', label: 'Registry token', mask: true},
{kind: 'confirm', name: 'git', label: 'Initialize a git repository?', initial: true},
]);

try {
const answer = await runForm<string>(({submit}) => (
<FormFrame title="Create project">
<MyField onSubmit={submit} />
</FormFrame>
));
const answers = await askAll(fields);
// answers.packageManager is typed as 'npm' | 'pnpm'
} catch (error) {
if (error instanceof FormCancelledError) process.exitCode = 130;
else throw error;
}
```

`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).

## 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.

```tsx
import {FormFrame, PromptList, runForm} from '@relay-cli/forms';

const answers = await runForm<Record<string, unknown>>(({submit, cancel}) => (
<FormFrame title="Deploy">
<PromptList fields={fields} onSubmit={submit} onCancel={cancel} />
</FormFrame>
));
```

## Development

```sh
Expand Down
3 changes: 2 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,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).

Each part is a plain Ink component, so a form can also be rendered inside a larger Ink application.
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.
58 changes: 58 additions & 0 deletions docs/prompts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Prompts

A prompt is one question described by a field. `ask` asks one field, `askAll` asks a list in order, and the `Prompt` and `PromptList` components do the same inside an Ink application.

## Field kinds

| Kind | Answer | Keys | Options |
| ------------- | ---------- | ---------------------------------------------------- | -------------------------------------------- |
| `text` | `string` | typing, ←→, Home/End, Ctrl+A/E/U/K/W | `initial`, `placeholder`, `mask`, `required` |
| `number` | `number` | as `text`, accepting only digits, `.`, `-`, `e`, `+` | `initial`, `min`, `max`, `integer` |
| `confirm` | `boolean` | `y`, `n`, ←→ or Space to switch, Enter | `initial` |
| `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`.

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.

## Validation

Declared limits such as `required`, `min`, and `max` are checked first. A field's `validate` function runs next and returns a message to show, or `undefined` to accept:

```ts
const name = {
kind: 'text',
name: 'name',
label: 'Project name',
required: true,
validate: (value: string) =>
/^[a-z0-9-]+$/.test(value) ? undefined : 'Use lowercase letters, digits, and dashes.',
} as const;
```

`validate` may return a promise, for example to check a token against an API. The prompt shows `Checking…` until it settles, and a rejected promise is shown as the error message.

## Typed answers

Wrap a field list in `defineFields` to keep its names and option values, and `askAll` returns typed answers:

```ts
const fields = defineFields([
{kind: 'text', name: 'name', label: 'Name'},
{
kind: 'select',
name: 'pm',
label: 'Package manager',
options: [{value: 'npm'}, {value: 'pnpm'}],
},
]);
const answers = await askAll(fields);
// answers: {name: string; pm: 'npm' | 'pnpm'}
```

`askAll(fields, {initialValues})` starts fields from earlier answers, such as values already in a configuration file.

## Masked values

A `text` field with `mask: true` shows `•` for each character while typing and at most twelve once answered, so the length of a long token is not revealed. See [SECURITY.md](../SECURITY.md) for storing secrets.
23 changes: 19 additions & 4 deletions src/cli.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
import {renderToString} from 'ink';
import {helpText, parseCliOptions} from './cli/options.js';
import {DemoForm} from './demo/index.js';
import {FormCancelledError, runForm} from './run.js';
import {relayFormsVersion} from './version.js';

const main = (): void => {
const main = async (): Promise<void> => {
let options;
try {
options = parseCliOptions(process.argv.slice(2));
Expand All @@ -23,8 +24,22 @@ const main = (): void => {
process.exitCode = 2;
return;
}
const columns = process.stdout.columns || 80;
process.stdout.write(`${renderToString(<DemoForm />, {columns})}\n`);
if (options.snapshot || !process.stdout.isTTY || !process.stdin.isTTY) {
const columns = process.stdout.columns || 80;
process.stdout.write(`${renderToString(<DemoForm interactive={false} />, {columns})}\n`);
return;
}

try {
const answers = await runForm<Record<string, unknown>>(({submit, cancel}) => (
<DemoForm onSubmit={submit} onCancel={cancel} />
));
process.stdout.write(`\n${JSON.stringify(answers, undefined, 2)}\n`);
} catch (error) {
if (!(error instanceof FormCancelledError)) throw error;
process.stderr.write('Cancelled.\n');
process.exitCode = 130;
}
};

main();
await main();
93 changes: 71 additions & 22 deletions src/demo/DemoForm.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,75 @@
import {Box, Text} from 'ink';
import {useState} from 'react';
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';

const rows: ReadonlyArray<readonly [string, string]> = [
['Project name', 'relay-demo'],
['Package manager', 'npm'],
['Features', 'tests, lint'],
];
export const demoFields = defineFields([
{
kind: 'text',
name: 'name',
label: 'Project name',
placeholder: 'relay-demo',
required: true,
validate: (value) =>
/^[a-z0-9-]+$/.test(value) ? undefined : 'Use lowercase letters, digits, and dashes.',
},
{
kind: 'select',
name: 'packageManager',
label: 'Package manager',
options: [
{value: 'npm', hint: 'bundled with Node.js'},
{value: 'pnpm'},
{value: 'yarn', disabled: 'not installed'},
],
},
{
kind: 'multiselect',
name: 'features',
label: 'Features',
options: [
{value: 'tests', label: 'Tests'},
{value: 'lint', label: 'Linting'},
{value: 'ci', label: 'GitHub Actions'},
],
initial: ['tests', 'lint'],
},
{kind: 'number', name: 'port', label: 'Development port', initial: 3000, min: 1024, max: 65535},
{kind: 'text', name: 'token', label: 'Registry token', mask: true, hint: 'Leave empty to skip.'},
{kind: 'confirm', name: 'git', label: 'Initialize a git repository?', initial: true},
]);

/** A static preview of a finished setup flow. */
export const DemoForm = () => (
<FormFrame
title="Relay Forms"
subtitle="setup preview"
footer={<Text color={relayTheme.muted}>enter continue · esc back · ctrl+c cancel</Text>}
>
{rows.map(([label, value]) => (
<Box key={label} gap={1}>
<Text color={relayTheme.success}>✔</Text>
<Text color={relayTheme.text}>{label}</Text>
<Text color={relayTheme.muted}>{value}</Text>
</Box>
))}
</FormFrame>
);
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) => {
const [current, setCurrent] = useState<Field | undefined>(demoFields[0]);
return (
<FormFrame
title="Relay Forms"
subtitle="new project"
footer={
current === undefined ? undefined : (
<Text color={relayTheme.muted}>{keyHints(current)} · ctrl+c quit</Text>
)
}
>
<PromptList
fields={demoFields}
isActive={interactive}
onSubmit={(answers) => {
setCurrent(undefined);
onSubmit?.(answers);
}}
onAnswer={(_field, _value, next) => setCurrent(next)}
{...(onCancel === undefined ? {} : {onCancel})}
/>
</FormFrame>
);
};
2 changes: 1 addition & 1 deletion src/demo/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export {DemoForm} from './DemoForm.js';
export {DemoForm, demoFields, type DemoFormProps} from './DemoForm.js';
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './frame/index.js';
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';
Loading
Loading