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

Feat (checkbox): Support validate config #1319

Merged
Merged
15 changes: 8 additions & 7 deletions packages/checkbox/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,14 @@ const answer = await checkbox({

## Options

| Property | Type | Required | Description |
| -------- | ------------------------------------------------------------------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| message | `string` | yes | The question to ask |
| choices | `Array<{ value: string, name?: string, disabled?: boolean \| string, checked?: boolean } \| Separator>` | yes | List of the available choices. The `value` will be returned as the answer, and used as display if no `name` is defined. Choices who're `disabled` will be displayed, but not selectable. |
| pageSize | `number` | no | By default, lists of choice longer than 7 will be paginated. Use this option to control how many choices will appear on the screen at once. |
| loop | `boolean` | no | Defaults to `true`. When set to `false`, the cursor will be constrained to the top and bottom of the choice list without looping. |
| required | `boolean` | no | When set to `true`, ensures at least one choice must be selected. |
| Property | Type | Required | Description |
| -------- | ------------------------------------------------------------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| message | `string` | yes | The question to ask |
| choices | `Array<{ value: string, name?: string, disabled?: boolean \| string, checked?: boolean } \| Separator>` | yes | List of the available choices. The `value` will be returned as the answer, and used as display if no `name` is defined. Choices who're `disabled` will be displayed, but not selectable. |
| pageSize | `number` | no | By default, lists of choice longer than 7 will be paginated. Use this option to control how many choices will appear on the screen at once. |
| loop | `boolean` | no | Defaults to `true`. When set to `false`, the cursor will be constrained to the top and bottom of the choice list without looping. |
| required | `boolean` | no | When set to `true`, ensures at least one choice must be selected. |
| validate | `string => boolean \| string \| Promise<string \| boolean>` | no | On submit, validate the choices. When returning a string, it'll be used as the error message displayed to the user. Note: returning a rejected promise, we'll assume a code error happened and crash. |

The `Separator` object can be used to render non-selectable lines in the choice list. By default it'll render a line, but you can provide the text as argument (`new Separator('-- Dependencies --')`). This option is often used to add labels to groups within long list of options.

Expand Down
70 changes: 46 additions & 24 deletions packages/checkbox/checkbox.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,8 @@ describe('checkbox prompt', () => {
`);

events.keypress('enter');
expect(getScreen()).toMatchInlineSnapshot('"? Select a number 2, 3"');

await expect(answer).resolves.toEqual([2, 3]);
expect(getScreen()).toMatchInlineSnapshot('"? Select a number 2, 3"');
});

it('does not scroll up beyond first item when not looping', async () => {
Expand Down Expand Up @@ -94,9 +93,8 @@ describe('checkbox prompt', () => {
`);

events.keypress('enter');
expect(getScreen()).toMatchInlineSnapshot('"? Select a number 1"');

await expect(answer).resolves.toEqual([1]);
expect(getScreen()).toMatchInlineSnapshot('"? Select a number 1"');
});

it('does not scroll up beyond first selectable item when not looping', async () => {
Expand Down Expand Up @@ -134,9 +132,8 @@ describe('checkbox prompt', () => {
`);

events.keypress('enter');
expect(getScreen()).toMatchInlineSnapshot('"? Select a number 1"');

await expect(answer).resolves.toEqual([1]);
expect(getScreen()).toMatchInlineSnapshot('"? Select a number 1"');
});

it('does not scroll down beyond last option when not looping', async () => {
Expand Down Expand Up @@ -175,9 +172,8 @@ describe('checkbox prompt', () => {
`);

events.keypress('enter');
expect(getScreen()).toMatchInlineSnapshot('"? Select a number 12"');

await expect(answer).resolves.toEqual([12]);
expect(getScreen()).toMatchInlineSnapshot('"? Select a number 12"');
});

it('does not scroll down beyond last selectable option when not looping', async () => {
Expand Down Expand Up @@ -216,9 +212,8 @@ describe('checkbox prompt', () => {
`);

events.keypress('enter');
expect(getScreen()).toMatchInlineSnapshot('"? Select a number 12"');

await expect(answer).resolves.toEqual([12]);
expect(getScreen()).toMatchInlineSnapshot('"? Select a number 12"');
});

it('use number key to select an option', async () => {
Expand All @@ -242,9 +237,8 @@ describe('checkbox prompt', () => {
`);

events.keypress('enter');
expect(getScreen()).toMatchInlineSnapshot('"? Select a number 4"');

await expect(answer).resolves.toEqual([4]);
expect(getScreen()).toMatchInlineSnapshot('"? Select a number 4"');
});

it('allow setting a smaller page size', async () => {
Expand Down Expand Up @@ -351,9 +345,8 @@ describe('checkbox prompt', () => {
`);

events.keypress('enter');
expect(getScreen()).toMatchInlineSnapshot('"? Select a topping Pepperoni"');

await expect(answer).resolves.toEqual(['pepperoni']);
expect(getScreen()).toMatchInlineSnapshot('"? Select a topping Pepperoni"');
});

it('skip disabled options by number key', async () => {
Expand Down Expand Up @@ -384,9 +377,8 @@ describe('checkbox prompt', () => {
`);

events.keypress('enter');
expect(getScreen()).toMatchInlineSnapshot('"? Select a topping"');

await expect(answer).resolves.toEqual([]);
expect(getScreen()).toMatchInlineSnapshot('"? Select a topping"');
});

it('skip separator by arrow keys', async () => {
Expand Down Expand Up @@ -417,9 +409,8 @@ describe('checkbox prompt', () => {
`);

events.keypress('enter');
expect(getScreen()).toMatchInlineSnapshot('"? Select a topping Pepperoni"');

await expect(answer).resolves.toEqual(['pepperoni']);
expect(getScreen()).toMatchInlineSnapshot('"? Select a topping Pepperoni"');
});

it('skip separator by number key', async () => {
Expand Down Expand Up @@ -450,9 +441,8 @@ describe('checkbox prompt', () => {
`);

events.keypress('enter');
expect(getScreen()).toMatchInlineSnapshot('"? Select a topping"');

await expect(answer).resolves.toEqual([]);
expect(getScreen()).toMatchInlineSnapshot('"? Select a topping"');
});

it('allow select all', async () => {
Expand Down Expand Up @@ -582,9 +572,8 @@ describe('checkbox prompt', () => {
`);

events.keypress('enter');
expect(getScreen()).toMatchInlineSnapshot('"? Select a number"');

await expect(answer).resolves.toEqual([]);
expect(getScreen()).toMatchInlineSnapshot('"? Select a number"');
});

it('allow customizing help tip', async () => {
Expand All @@ -609,9 +598,8 @@ describe('checkbox prompt', () => {
`);

events.keypress('enter');
expect(getScreen()).toMatchInlineSnapshot('"? Select a number"');

await expect(answer).resolves.toEqual([]);
expect(getScreen()).toMatchInlineSnapshot('"? Select a number"');
});

it('throws if all choices are disabled', async () => {
Expand All @@ -633,6 +621,7 @@ describe('checkbox prompt', () => {
});

events.keypress('enter');
await Promise.resolve();
expect(getScreen()).toMatchInlineSnapshot(`
"? Select a number (Press <space> to select, <a> to toggle all, <i> to invert
selection, and <enter> to proceed)
Expand Down Expand Up @@ -663,4 +652,37 @@ describe('checkbox prompt', () => {
events.keypress('enter');
await expect(answer).resolves.toEqual([1]);
});

it('uses custom validation', async () => {
const { answer, events, getScreen } = await render(checkbox, {
message: 'Select a number',
choices: numberedChoices,
validate(items: any) {
if (items.length !== 1) {
return 'Please select only one choice';
}
return true;
},
});

events.keypress('enter');
await Promise.resolve();
expect(getScreen()).toMatchInlineSnapshot(`
"? Select a number (Press <space> to select, <a> to toggle all, <i> to invert
selection, and <enter> to proceed)
❯◯ 1
◯ 2
◯ 3
◯ 4
◯ 5
◯ 6
◯ 7
(Use arrow keys to reveal more choices)
> Please select only one choice"
`);

events.keypress('space');
events.keypress('enter');
await expect(answer).resolves.toEqual([1]);
});
});
14 changes: 11 additions & 3 deletions packages/checkbox/src/index.mts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@
choices: ReadonlyArray<Choice<Value> | Separator>;
loop?: boolean;
required?: boolean;
validate?: (
items: ReadonlyArray<Item<Value>>,
) => boolean | string | Promise<string | boolean>;
}>;

type Item<Value> = Separator | Choice<Value>;
Expand Down Expand Up @@ -82,6 +85,7 @@
loop = true,
choices,
required,
validate = () => true,
} = config;
const [status, setStatus] = useState('pending');
const [items, setItems] = useState<ReadonlyArray<Item<Value>>>(
Expand All @@ -90,7 +94,7 @@

const bounds = useMemo(() => {
const first = items.findIndex(isSelectable);
// TODO: Replace with `findLastIndex` when it's available.

Check warning on line 97 in packages/checkbox/src/index.mts

View workflow job for this annotation

GitHub Actions / Linting

Unexpected 'todo' comment: 'TODO: Replace with `findLastIndex` when...'
const last = items.length - 1 - [...items].reverse().findIndex(isSelectable);

if (first < 0) {
Expand All @@ -106,13 +110,17 @@
const [showHelpTip, setShowHelpTip] = useState(true);
const [errorMsg, setError] = useState<string | undefined>(undefined);

useKeypress((key) => {
useKeypress(async (key) => {

Check warning on line 113 in packages/checkbox/src/index.mts

View workflow job for this annotation

GitHub Actions / Linting

Async arrow function has a complexity of 21. Maximum allowed is 20
if (isEnterKey(key)) {
const selection = items.filter(isChecked);
const isValid = await validate([...selection]);
if (required && !items.some(isChecked)) {
setError('At least one choice must be selected');
} else {
} else if (isValid === true) {
setStatus('done');
done(items.filter(isChecked).map((choice) => choice.value));
done(selection.map((choice) => choice.value));
} else {
setError(isValid || 'You must select a valid value');
}
} else if (isUpKey(key) || isDownKey(key)) {
if (
Expand Down