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
46 changes: 46 additions & 0 deletions packages/checkbox/checkbox.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ describe('checkbox prompt', () => {
`);

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

await expect(answer).resolves.toEqual([2, 3]);
Expand Down Expand Up @@ -94,6 +95,7 @@ describe('checkbox prompt', () => {
`);

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

await expect(answer).resolves.toEqual([1]);
Expand Down Expand Up @@ -134,6 +136,7 @@ describe('checkbox prompt', () => {
`);

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

await expect(answer).resolves.toEqual([1]);
SBoudrias marked this conversation as resolved.
Show resolved Hide resolved
Expand Down Expand Up @@ -175,6 +178,7 @@ describe('checkbox prompt', () => {
`);

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

await expect(answer).resolves.toEqual([12]);
Expand Down Expand Up @@ -216,6 +220,7 @@ describe('checkbox prompt', () => {
`);

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

await expect(answer).resolves.toEqual([12]);
Expand All @@ -242,6 +247,7 @@ describe('checkbox prompt', () => {
`);

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

await expect(answer).resolves.toEqual([4]);
Expand Down Expand Up @@ -351,6 +357,7 @@ describe('checkbox prompt', () => {
`);

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

await expect(answer).resolves.toEqual(['pepperoni']);
Expand Down Expand Up @@ -384,6 +391,7 @@ describe('checkbox prompt', () => {
`);

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

await expect(answer).resolves.toEqual([]);
Expand Down Expand Up @@ -417,6 +425,7 @@ describe('checkbox prompt', () => {
`);

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

await expect(answer).resolves.toEqual(['pepperoni']);
Expand Down Expand Up @@ -450,6 +459,7 @@ describe('checkbox prompt', () => {
`);

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

await expect(answer).resolves.toEqual([]);
Expand Down Expand Up @@ -582,6 +592,7 @@ describe('checkbox prompt', () => {
`);

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

await expect(answer).resolves.toEqual([]);
Expand Down Expand Up @@ -609,6 +620,7 @@ describe('checkbox prompt', () => {
`);

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

await expect(answer).resolves.toEqual([]);
Expand All @@ -633,6 +645,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 +676,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.filter((item: any) => item.checked).length === 1) {
return true;
}
return 'Please select only one choice';
},
});

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]);
});
});
11 changes: 9 additions & 2 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,16 @@
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 isValid = await validate(items);
SBoudrias marked this conversation as resolved.
Show resolved Hide resolved
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));
} else {
setError(isValid || 'You must select a valid value');
}
} else if (isUpKey(key) || isDownKey(key)) {
if (
Expand Down