Skip to content

Commit

Permalink
Feat (checkbox): Support validate config (#1319)
Browse files Browse the repository at this point in the history
---------

Co-authored-by: Simon Boudrias <admin@simonboudrias.com>
  • Loading branch information
Srenky and SBoudrias committed Nov 7, 2023
1 parent ee56951 commit a0cdb89
Show file tree
Hide file tree
Showing 3 changed files with 65 additions and 34 deletions.
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 @@ type Config<Value> = PromptConfig<{
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 @@ export default createPrompt(
loop = true,
choices,
required,
validate = () => true,
} = config;
const [status, setStatus] = useState('pending');
const [items, setItems] = useState<ReadonlyArray<Item<Value>>>(
Expand All @@ -106,13 +110,17 @@ export default createPrompt(
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

0 comments on commit a0cdb89

Please sign in to comment.