Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix(inquirer): Allow string choices (backward compat with v9 & prior) #1530

Merged
merged 7 commits into from
Sep 1, 2024
4 changes: 3 additions & 1 deletion packages/checkbox/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,14 +101,16 @@ type Choice<Value> = {

Here's each property:

- `value`: The value is what will be returned by `await select()`.
- `value`: The value is what will be returned by `await checkbox()`.
- `name`: This is the string displayed in the choice list.
- `short`: Once the prompt is done (press enter), we'll use `short` if defined to render next to the question. By default we'll use `name`.
- `checked`: If `true`, the option will be checked by default.
- `disabled`: Disallow the option from being selected. If `disabled` is a string, it'll be used as a help tip explaining why the choice isn't available.

Also note the `choices` array can contain `Separator`s to help organize long lists.

`choices` can also be an array of string, in which case the string will be used both as the `value` and the `name`.

## Theming

You can theme a prompt by passing a `theme` object option. The theme object only need to includes the keys you wish to modify, we'll fallback on the defaults for the rest.
Expand Down
28 changes: 28 additions & 0 deletions packages/checkbox/checkbox.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,34 @@ describe('checkbox prompt', () => {
expect(getScreen()).toMatchInlineSnapshot('"? Select a number 2, 3"');
});

it('works with string choices', async () => {
const { answer, events, getScreen } = await render(checkbox, {
message: 'Select a number',
choices: ['Option A', 'Option B', 'Option C'],
});

expect(getScreen()).toMatchInlineSnapshot(`
"? Select a number (Press <space> to select, <a> to toggle all, <i> to invert
selection, and <enter> to proceed)
❯◯ Option A
◯ Option B
◯ Option C"
`);

events.keypress('down');
events.keypress('space');
expect(getScreen()).toMatchInlineSnapshot(`
"? Select a number
◯ Option A
❯◉ Option B
◯ Option C"
`);

events.keypress('enter');
await expect(answer).resolves.toEqual(['Option B']);
expect(getScreen()).toMatchInlineSnapshot(`"? Select a number Option B"`);
});

it('does not scroll up beyond first item when not looping', async () => {
const { answer, events, getScreen } = await render(checkbox, {
message: 'Select a number',
Expand Down
76 changes: 57 additions & 19 deletions packages/checkbox/src/index.mts
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ type CheckboxTheme = {
style: {
disabledChoice: (text: string) => string;
renderSelectedChoices: <T>(
selectedChoices: ReadonlyArray<Choice<T>>,
allChoices: ReadonlyArray<Choice<T> | Separator>,
selectedChoices: ReadonlyArray<NormalizedChoice<T>>,
allChoices: ReadonlyArray<NormalizedChoice<T> | Separator>,
) => string;
};
helpMode: 'always' | 'never' | 'auto';
Expand All @@ -46,28 +46,41 @@ const checkboxTheme: CheckboxTheme = {
style: {
disabledChoice: (text: string) => colors.dim(`- ${text}`),
renderSelectedChoices: (selectedChoices) =>
selectedChoices
.map((choice) => choice.short ?? choice.name ?? choice.value)
.join(', '),
selectedChoices.map((choice) => choice.short).join(', '),
},
helpMode: 'auto',
};

type Choice<Value> = {
name?: string;
value: Value;
name?: string;
short?: string;
disabled?: boolean | string;
checked?: boolean;
type?: never;
};

type Config<Value> = {
type NormalizedChoice<Value> = {
value: Value;
name: string;
short: string;
disabled: boolean | string;
checked: boolean;
};

type CheckboxConfig<
Value,
ChoicesObject =
| ReadonlyArray<string | Separator>
| ReadonlyArray<Choice<Value> | Separator>,
> = {
message: string;
prefix?: string;
pageSize?: number;
instructions?: string | boolean;
choices: ReadonlyArray<Choice<Value> | Separator>;
choices: ChoicesObject extends ReadonlyArray<string | Separator>
? ChoicesObject
: ReadonlyArray<Choice<Value> | Separator>;
loop?: boolean;
required?: boolean;
validate?: (
Expand All @@ -76,13 +89,13 @@ type Config<Value> = {
theme?: PartialDeep<Theme<CheckboxTheme>>;
};

type Item<Value> = Separator | Choice<Value>;
type Item<Value> = NormalizedChoice<Value> | Separator;

function isSelectable<Value>(item: Item<Value>): item is Choice<Value> {
function isSelectable<Value>(item: Item<Value>): item is NormalizedChoice<Value> {
return !Separator.isSeparator(item) && !item.disabled;
}

function isChecked<Value>(item: Item<Value>): item is Choice<Value> {
function isChecked<Value>(item: Item<Value>): item is NormalizedChoice<Value> {
return isSelectable(item) && Boolean(item.checked);
}

Expand All @@ -96,13 +109,39 @@ function check(checked: boolean) {
};
}

function normalizeChoices<Value>(
choices: ReadonlyArray<string | Separator> | ReadonlyArray<Choice<Value> | Separator>,
): Item<Value>[] {
return choices.map((choice) => {
if (Separator.isSeparator(choice)) return choice;

if (typeof choice === 'string') {
return {
value: choice as Value,
name: choice,
short: choice,
disabled: false,
checked: false,
};
}

const name = choice.name ?? String(choice.value);
return {
value: choice.value,
name,
short: choice.short ?? name,
disabled: choice.disabled ?? false,
checked: choice.checked ?? false,
};
});
}

export default createPrompt(
<Value,>(config: Config<Value>, done: (value: Array<Value>) => void) => {
<Value,>(config: CheckboxConfig<Value>, done: (value: Array<Value>) => void) => {
const {
instructions,
pageSize = 7,
loop = true,
choices,
required,
validate = () => true,
} = config;
Expand All @@ -111,7 +150,7 @@ export default createPrompt(
const firstRender = useRef(true);
const [status, setStatus] = useState('pending');
const [items, setItems] = useState<ReadonlyArray<Item<Value>>>(
choices.map((choice) => ({ ...choice })),
normalizeChoices(config.choices),
);

const bounds = useMemo(() => {
Expand Down Expand Up @@ -178,25 +217,24 @@ export default createPrompt(

const message = theme.style.message(config.message);

const page = usePagination<Item<Value>>({
const page = usePagination({
items,
active,
renderItem({ item, isActive }: { item: Item<Value>; isActive: boolean }) {
renderItem({ item, isActive }) {
if (Separator.isSeparator(item)) {
return ` ${item.separator}`;
}

const line = String(item.name || item.value);
if (item.disabled) {
const disabledLabel =
typeof item.disabled === 'string' ? item.disabled : '(disabled)';
return theme.style.disabledChoice(`${line} ${disabledLabel}`);
return theme.style.disabledChoice(`${item.name} ${disabledLabel}`);
}

const checkbox = item.checked ? theme.icon.checked : theme.icon.unchecked;
const color = isActive ? theme.style.highlight : (x: string) => x;
const cursor = isActive ? theme.icon.cursor : ' ';
return color(`${cursor}${checkbox} ${line}`);
return color(`${cursor}${checkbox} ${item.name}`);
},
pageSize,
loop,
Expand Down
11 changes: 7 additions & 4 deletions packages/core/src/lib/Separator.mts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,12 @@ export class Separator {
}
}

static isSeparator(
choice: undefined | Separator | Record<string, unknown>,
): choice is Separator {
return Boolean(choice && choice.type === 'separator');
static isSeparator(choice: unknown): choice is Separator {
return Boolean(
choice &&
typeof choice === 'object' &&
'type' in choice &&
choice.type === 'separator',
);
}
}
32 changes: 25 additions & 7 deletions packages/expand/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,13 +86,31 @@ const answer = await expand({

## Options

| Property | Type | Required | Description |
| -------- | ------------------------------------------------------ | -------- | ----------------------------------------------------------------------------------------- |
| message | `string` | yes | The question to ask |
| choices | `Array<{ key: string, name: string, value?: string }>` | yes | Array of the different allowed choices. The `h`/help option is always provided by default |
| default | `string` | no | Default choices to be selected. (value must be one of the choices `key`) |
| expanded | `boolean` | no | Expand the choices by default |
| theme | [See Theming](#Theming) | no | Customize look of the prompt. |
| Property | Type | Required | Description |
| -------- | ----------------------- | -------- | ----------------------------------------------------------------------------------------- |
| message | `string` | yes | The question to ask |
| choices | `Choice[]` | yes | Array of the different allowed choices. The `h`/help option is always provided by default |
| default | `string` | no | Default choices to be selected. (value must be one of the choices `key`) |
| expanded | `boolean` | no | Expand the choices by default |
| theme | [See Theming](#Theming) | no | Customize look of the prompt. |

### `Choice` object

The `Choice` object is typed as

```ts
type Choice<Value> = {
value: Value;
name?: string;
key: string;
};
```

Here's each property:

- `value`: The value is what will be returned by `await expand()`.
- `name`: The string displayed in the choice list. It'll default to the stringify `value`.
- `key`: The input the use must provide to select the choice. Must be a lowercase single alpha-numeric character string.

## Theming

Expand Down
14 changes: 7 additions & 7 deletions packages/expand/expand.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,22 @@ import expand from './src/index.mjs';

const overwriteChoices = [
{
key: 'y',
key: 'y' as const,
name: 'Overwrite',
value: 'overwrite',
},
{
key: 'a',
key: 'a' as const,
name: 'Overwrite this one and all next',
value: 'overwrite_all',
},
{
key: 'd',
key: 'd' as const,
name: 'Show diff',
value: 'diff',
},
{
key: 'x',
key: 'x' as const,
name: 'Abort',
value: 'abort',
},
Expand Down Expand Up @@ -72,7 +72,7 @@ describe('expand prompt', () => {
`);

events.keypress('enter');
expect(getScreen()).toMatchInlineSnapshot('"? Overwrite this file? abort"');
expect(getScreen()).toMatchInlineSnapshot(`"? Overwrite this file? Abort"`);

await expect(answer).resolves.toEqual('abort');
});
Expand Down Expand Up @@ -139,7 +139,7 @@ describe('expand prompt', () => {
`);

events.keypress('enter');
expect(getScreen()).toMatchInlineSnapshot('"? Overwrite this file? abort"');
expect(getScreen()).toMatchInlineSnapshot(`"? Overwrite this file? Abort"`);

await expect(answer).resolves.toEqual('abort');
});
Expand Down Expand Up @@ -245,7 +245,7 @@ describe('expand prompt', () => {
expect(getScreen()).toMatchInlineSnapshot('"? Overwrite this file? (Yadxh)"');

events.keypress('enter');
expect(getScreen()).toMatchInlineSnapshot('"? Overwrite this file? overwrite"');
expect(getScreen()).toMatchInlineSnapshot(`"? Overwrite this file? Overwrite"`);

await expect(answer).resolves.toEqual('overwrite');
});
Expand Down
Loading
Loading