From f4000cff72e415e3946c2e66e972ac1e99e397d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20=C5=A0renkel?= Date: Sun, 8 Oct 2023 21:39:14 +0200 Subject: [PATCH 01/13] added validation in case no choice is selected --- packages/checkbox/src/index.mts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/checkbox/src/index.mts b/packages/checkbox/src/index.mts index b222182d9..d4500edc1 100644 --- a/packages/checkbox/src/index.mts +++ b/packages/checkbox/src/index.mts @@ -96,9 +96,14 @@ export default createPrompt( const [active, setActive] = useState(bounds.first); const [showHelpTip, setShowHelpTip] = useState(true); + const [showValidationMessage, setShowValidationMessage] = useState(false); useKeypress((key) => { if (isEnterKey(key)) { + if (items.filter(isChecked).length === 0) { + setShowValidationMessage(true); + return; + } setStatus('done'); done(items.filter(isChecked).map((choice) => choice.value)); } else if (isUpKey(key) || isDownKey(key)) { @@ -111,6 +116,7 @@ export default createPrompt( } while (!isSelectable(items[next]!)); setActive(next); } else if (isSpaceKey(key)) { + setShowValidationMessage(false); setShowHelpTip(false); setItems(items.map((choice, i) => (i === active ? toggle(choice) : choice))); } else if (key.name === 'a') { @@ -162,7 +168,12 @@ export default createPrompt( } } - return `${prefix} ${message}${helpTip}\n${page}${ansiEscapes.cursorHide}`; + let validationMessage = ''; + if (showValidationMessage) { + validationMessage = `${chalk.red('!')} You need to select at least one choice`; + } + + return `${prefix} ${message}${helpTip}\n${page}\n${validationMessage}${ansiEscapes.cursorHide}`; }, ); From 3b4e0e74bcc76ced4ca54949c91b5d797cd9b6e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20=C5=A0renkel?= Date: Sun, 8 Oct 2023 21:40:18 +0200 Subject: [PATCH 02/13] added unit test for validation logic, edited existing unit tests since they were failing because of new validation logic --- packages/checkbox/checkbox.test.mts | 67 ++++++++++++++++++++++++----- 1 file changed, 56 insertions(+), 11 deletions(-) diff --git a/packages/checkbox/checkbox.test.mts b/packages/checkbox/checkbox.test.mts index d0818e429..c66c50baf 100644 --- a/packages/checkbox/checkbox.test.mts +++ b/packages/checkbox/checkbox.test.mts @@ -262,8 +262,9 @@ describe('checkbox prompt', () => { (Use arrow keys to reveal more choices)" `); + events.keypress('space'); events.keypress('enter'); - await expect(answer).resolves.toEqual([]); + await expect(answer).resolves.toEqual([1]); }); it('allow setting a bigger page size', async () => { @@ -289,8 +290,9 @@ describe('checkbox prompt', () => { (Use arrow keys to reveal more choices)" `); + events.keypress('space'); events.keypress('enter'); - await expect(answer).resolves.toEqual([]); + await expect(answer).resolves.toEqual([1]); }); it('cycles through options', async () => { @@ -383,10 +385,11 @@ describe('checkbox prompt', () => { ◯ Pepperoni" `); + events.keypress('space'); events.keypress('enter'); - expect(getScreen()).toMatchInlineSnapshot('"? Select a topping"'); + expect(getScreen()).toMatchInlineSnapshot('"? Select a topping Ham"'); - await expect(answer).resolves.toEqual([]); + await expect(answer).resolves.toEqual(['ham']); }); it('skip separator by arrow keys', async () => { @@ -449,10 +452,11 @@ describe('checkbox prompt', () => { ◯ Pepperoni" `); + events.keypress('space'); events.keypress('enter'); - expect(getScreen()).toMatchInlineSnapshot('"? Select a topping"'); + expect(getScreen()).toMatchInlineSnapshot('"? Select a topping Ham"'); - await expect(answer).resolves.toEqual([]); + await expect(answer).resolves.toEqual(['ham']); }); it('allow select all', async () => { @@ -530,8 +534,9 @@ describe('checkbox prompt', () => { events.keypress('a'); events.keypress('a'); + events.keypress('space'); events.keypress('enter'); - await expect(answer).resolves.toEqual([]); + await expect(answer).resolves.toEqual([4]); }); it('allow inverting selection', async () => { @@ -581,10 +586,11 @@ describe('checkbox prompt', () => { (Use arrow keys to reveal more choices)" `); + events.keypress('space'); events.keypress('enter'); - expect(getScreen()).toMatchInlineSnapshot('"? Select a number"'); + expect(getScreen()).toMatchInlineSnapshot('"? Select a number 1"'); - await expect(answer).resolves.toEqual([]); + await expect(answer).resolves.toEqual([1]); }); it('allow customizing help tip', async () => { @@ -608,10 +614,11 @@ describe('checkbox prompt', () => { (Use arrow keys to reveal more choices)" `); + events.keypress('space'); events.keypress('enter'); - expect(getScreen()).toMatchInlineSnapshot('"? Select a number"'); + expect(getScreen()).toMatchInlineSnapshot('"? Select a number 1"'); - await expect(answer).resolves.toEqual([]); + await expect(answer).resolves.toEqual([1]); }); it('throws if all choices are disabled', async () => { @@ -624,4 +631,42 @@ describe('checkbox prompt', () => { '"[checkbox prompt] No selectable choices. All choices are disabled."', ); }); + + it('shows validation message if user did not select any choice', async () => { + const { answer, events, getScreen } = await render(checkbox, { + message: 'Select a number', + choices: numberedChoices, + }); + + events.keypress('enter'); + expect(getScreen()).toMatchInlineSnapshot(` + "? Select a number (Press to select, to toggle all, to invert + selection, and to proceed) + ❯◯ 1 + ◯ 2 + ◯ 3 + ◯ 4 + ◯ 5 + ◯ 6 + ◯ 7 + (Use arrow keys to reveal more choices) + ! You need to select at least one choice" + `); + + events.keypress('space'); + expect(getScreen()).toMatchInlineSnapshot(` + "? Select a number + ❯◉ 1 + ◯ 2 + ◯ 3 + ◯ 4 + ◯ 5 + ◯ 6 + ◯ 7 + (Use arrow keys to reveal more choices)" + `); + + events.keypress('enter'); + await expect(answer).resolves.toEqual([1]); + }); }); From 09c8deb9fcb516d64838cf9a3ef81d948d4fcc0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20=C5=A0renkel?= Date: Tue, 10 Oct 2023 21:53:41 +0200 Subject: [PATCH 03/13] Revert "added unit test for validation logic, edited existing unit tests since they were failing because of new validation logic" This reverts commit 3b4e0e74bcc76ced4ca54949c91b5d797cd9b6e9. --- packages/checkbox/checkbox.test.mts | 67 +++++------------------------ 1 file changed, 11 insertions(+), 56 deletions(-) diff --git a/packages/checkbox/checkbox.test.mts b/packages/checkbox/checkbox.test.mts index c66c50baf..d0818e429 100644 --- a/packages/checkbox/checkbox.test.mts +++ b/packages/checkbox/checkbox.test.mts @@ -262,9 +262,8 @@ describe('checkbox prompt', () => { (Use arrow keys to reveal more choices)" `); - events.keypress('space'); events.keypress('enter'); - await expect(answer).resolves.toEqual([1]); + await expect(answer).resolves.toEqual([]); }); it('allow setting a bigger page size', async () => { @@ -290,9 +289,8 @@ describe('checkbox prompt', () => { (Use arrow keys to reveal more choices)" `); - events.keypress('space'); events.keypress('enter'); - await expect(answer).resolves.toEqual([1]); + await expect(answer).resolves.toEqual([]); }); it('cycles through options', async () => { @@ -385,11 +383,10 @@ describe('checkbox prompt', () => { ◯ Pepperoni" `); - events.keypress('space'); events.keypress('enter'); - expect(getScreen()).toMatchInlineSnapshot('"? Select a topping Ham"'); + expect(getScreen()).toMatchInlineSnapshot('"? Select a topping"'); - await expect(answer).resolves.toEqual(['ham']); + await expect(answer).resolves.toEqual([]); }); it('skip separator by arrow keys', async () => { @@ -452,11 +449,10 @@ describe('checkbox prompt', () => { ◯ Pepperoni" `); - events.keypress('space'); events.keypress('enter'); - expect(getScreen()).toMatchInlineSnapshot('"? Select a topping Ham"'); + expect(getScreen()).toMatchInlineSnapshot('"? Select a topping"'); - await expect(answer).resolves.toEqual(['ham']); + await expect(answer).resolves.toEqual([]); }); it('allow select all', async () => { @@ -534,9 +530,8 @@ describe('checkbox prompt', () => { events.keypress('a'); events.keypress('a'); - events.keypress('space'); events.keypress('enter'); - await expect(answer).resolves.toEqual([4]); + await expect(answer).resolves.toEqual([]); }); it('allow inverting selection', async () => { @@ -586,11 +581,10 @@ describe('checkbox prompt', () => { (Use arrow keys to reveal more choices)" `); - events.keypress('space'); events.keypress('enter'); - expect(getScreen()).toMatchInlineSnapshot('"? Select a number 1"'); + expect(getScreen()).toMatchInlineSnapshot('"? Select a number"'); - await expect(answer).resolves.toEqual([1]); + await expect(answer).resolves.toEqual([]); }); it('allow customizing help tip', async () => { @@ -614,11 +608,10 @@ describe('checkbox prompt', () => { (Use arrow keys to reveal more choices)" `); - events.keypress('space'); events.keypress('enter'); - expect(getScreen()).toMatchInlineSnapshot('"? Select a number 1"'); + expect(getScreen()).toMatchInlineSnapshot('"? Select a number"'); - await expect(answer).resolves.toEqual([1]); + await expect(answer).resolves.toEqual([]); }); it('throws if all choices are disabled', async () => { @@ -631,42 +624,4 @@ describe('checkbox prompt', () => { '"[checkbox prompt] No selectable choices. All choices are disabled."', ); }); - - it('shows validation message if user did not select any choice', async () => { - const { answer, events, getScreen } = await render(checkbox, { - message: 'Select a number', - choices: numberedChoices, - }); - - events.keypress('enter'); - expect(getScreen()).toMatchInlineSnapshot(` - "? Select a number (Press to select, to toggle all, to invert - selection, and to proceed) - ❯◯ 1 - ◯ 2 - ◯ 3 - ◯ 4 - ◯ 5 - ◯ 6 - ◯ 7 - (Use arrow keys to reveal more choices) - ! You need to select at least one choice" - `); - - events.keypress('space'); - expect(getScreen()).toMatchInlineSnapshot(` - "? Select a number - ❯◉ 1 - ◯ 2 - ◯ 3 - ◯ 4 - ◯ 5 - ◯ 6 - ◯ 7 - (Use arrow keys to reveal more choices)" - `); - - events.keypress('enter'); - await expect(answer).resolves.toEqual([1]); - }); }); From b80bee9ce82a15b722274bc2ca06424d82c732f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20=C5=A0renkel?= Date: Tue, 10 Oct 2023 22:21:48 +0200 Subject: [PATCH 04/13] added 'required' prop, changed validation logic so that its more flexible, fixed unit tests --- packages/checkbox/checkbox.test.mts | 39 +++++++++++++++++++++++++++++ packages/checkbox/src/index.mts | 32 ++++++++++++++--------- 2 files changed, 59 insertions(+), 12 deletions(-) diff --git a/packages/checkbox/checkbox.test.mts b/packages/checkbox/checkbox.test.mts index d0818e429..9ba19f1fd 100644 --- a/packages/checkbox/checkbox.test.mts +++ b/packages/checkbox/checkbox.test.mts @@ -624,4 +624,43 @@ describe('checkbox prompt', () => { '"[checkbox prompt] No selectable choices. All choices are disabled."', ); }); + + it('shows validation message if user did not select any choice', async () => { + const { answer, events, getScreen } = await render(checkbox, { + message: 'Select a number', + choices: numberedChoices, + required: true, + }); + + events.keypress('enter'); + expect(getScreen()).toMatchInlineSnapshot(` + "? Select a number (Press to select, to toggle all, to invert + selection, and to proceed) + ❯◯ 1 + ◯ 2 + ◯ 3 + ◯ 4 + ◯ 5 + ◯ 6 + ◯ 7 + (Use arrow keys to reveal more choices) + > At least one choice must be selected" + `); + + events.keypress('space'); + expect(getScreen()).toMatchInlineSnapshot(` + "? Select a number + ❯◉ 1 + ◯ 2 + ◯ 3 + ◯ 4 + ◯ 5 + ◯ 6 + ◯ 7 + (Use arrow keys to reveal more choices)" + `); + + events.keypress('enter'); + await expect(answer).resolves.toEqual([1]); + }); }); diff --git a/packages/checkbox/src/index.mts b/packages/checkbox/src/index.mts index d4500edc1..23856a6ac 100644 --- a/packages/checkbox/src/index.mts +++ b/packages/checkbox/src/index.mts @@ -32,6 +32,7 @@ type Config = PromptConfig<{ instructions?: string | boolean; choices: ReadonlyArray | Separator>; loop?: boolean; + required?: string | boolean; }>; type Item = Separator | Choice; @@ -74,7 +75,14 @@ function renderItem({ item, isActive }: { item: Item; isActive: bo export default createPrompt( (config: Config, done: (value: Array) => void) => { - const { prefix = usePrefix(), instructions, pageSize, loop = true, choices } = config; + const { + prefix = usePrefix(), + instructions, + pageSize, + loop = true, + choices, + required, + } = config; const [status, setStatus] = useState('pending'); const [items, setItems] = useState>>( choices.map((choice) => ({ ...choice })), @@ -96,16 +104,16 @@ export default createPrompt( const [active, setActive] = useState(bounds.first); const [showHelpTip, setShowHelpTip] = useState(true); - const [showValidationMessage, setShowValidationMessage] = useState(false); + const [errorMsg, setError] = useState(undefined); useKeypress((key) => { if (isEnterKey(key)) { - if (items.filter(isChecked).length === 0) { - setShowValidationMessage(true); - return; + if (required && items.filter(isChecked).length === 0) { + setError('At least one choice must be selected'); + } else { + setStatus('done'); + done(items.filter(isChecked).map((choice) => choice.value)); } - setStatus('done'); - done(items.filter(isChecked).map((choice) => choice.value)); } else if (isUpKey(key) || isDownKey(key)) { if (!loop && active === bounds.first && isUpKey(key)) return; if (!loop && active === bounds.last && isDownKey(key)) return; @@ -116,7 +124,7 @@ export default createPrompt( } while (!isSelectable(items[next]!)); setActive(next); } else if (isSpaceKey(key)) { - setShowValidationMessage(false); + setError(undefined); setShowHelpTip(false); setItems(items.map((choice, i) => (i === active ? toggle(choice) : choice))); } else if (key.name === 'a') { @@ -168,12 +176,12 @@ export default createPrompt( } } - let validationMessage = ''; - if (showValidationMessage) { - validationMessage = `${chalk.red('!')} You need to select at least one choice`; + let error = ''; + if (errorMsg) { + error = chalk.red(`> ${errorMsg}`); } - return `${prefix} ${message}${helpTip}\n${page}\n${validationMessage}${ansiEscapes.cursorHide}`; + return `${prefix} ${message}${helpTip}\n${page}\n${error}${ansiEscapes.cursorHide}`; }, ); From 4c390f51e9a0ef12393d2f5a1769df72e0d67c19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20=C5=A0renkel?= Date: Wed, 11 Oct 2023 13:05:53 +0200 Subject: [PATCH 05/13] updated readme, fixed type of required --- packages/checkbox/README.md | 1 + packages/checkbox/src/index.mts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/checkbox/README.md b/packages/checkbox/README.md index 4394758ca..a8ff806b8 100644 --- a/packages/checkbox/README.md +++ b/packages/checkbox/README.md @@ -41,6 +41,7 @@ const answer = await checkbox({ | 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. | 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. diff --git a/packages/checkbox/src/index.mts b/packages/checkbox/src/index.mts index 23856a6ac..2fa3592f5 100644 --- a/packages/checkbox/src/index.mts +++ b/packages/checkbox/src/index.mts @@ -32,7 +32,7 @@ type Config = PromptConfig<{ instructions?: string | boolean; choices: ReadonlyArray | Separator>; loop?: boolean; - required?: string | boolean; + required?: boolean; }>; type Item = Separator | Choice; From ebd18126df3d5e99733c9da1ede054f0a53be594 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20=C5=A0renkel?= Date: Wed, 18 Oct 2023 15:51:35 +0200 Subject: [PATCH 06/13] extended config with min and max choices --- packages/checkbox/src/index.mts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/checkbox/src/index.mts b/packages/checkbox/src/index.mts index 2fa3592f5..390d626b5 100644 --- a/packages/checkbox/src/index.mts +++ b/packages/checkbox/src/index.mts @@ -33,6 +33,8 @@ type Config = PromptConfig<{ choices: ReadonlyArray | Separator>; loop?: boolean; required?: boolean; + minChoices?: number; + maxChoices?: number; }>; type Item = Separator | Choice; @@ -82,6 +84,8 @@ export default createPrompt( loop = true, choices, required, + minChoices, + maxChoices, } = config; const [status, setStatus] = useState('pending'); const [items, setItems] = useState>>( @@ -108,8 +112,13 @@ export default createPrompt( useKeypress((key) => { if (isEnterKey(key)) { - if (required && items.filter(isChecked).length === 0) { - setError('At least one choice must be selected'); + const selectedChoices = items.filter(isChecked).length; + if ((required || (minChoices && minChoices === 1)) && selectedChoices === 0) { + setError('At least 1 choice must be selected'); + } else if (minChoices && selectedChoices < minChoices) { + setError(`At least ${minChoices} choices must be selected`); + } else if (maxChoices && selectedChoices > maxChoices) { + setError(`At most ${maxChoices} choices must be selected`); } else { setStatus('done'); done(items.filter(isChecked).map((choice) => choice.value)); From 47003eef7ab1aea349c406446c8e0d6374333647 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20=C5=A0renkel?= Date: Wed, 18 Oct 2023 15:51:44 +0200 Subject: [PATCH 07/13] added tests --- packages/checkbox/checkbox.test.mts | 86 ++++++++++++++++++++++++++++- 1 file changed, 85 insertions(+), 1 deletion(-) diff --git a/packages/checkbox/checkbox.test.mts b/packages/checkbox/checkbox.test.mts index 9ba19f1fd..e8ec055c3 100644 --- a/packages/checkbox/checkbox.test.mts +++ b/packages/checkbox/checkbox.test.mts @@ -644,7 +644,7 @@ describe('checkbox prompt', () => { ◯ 6 ◯ 7 (Use arrow keys to reveal more choices) - > At least one choice must be selected" + > At least 1 choice must be selected" `); events.keypress('space'); @@ -663,4 +663,88 @@ describe('checkbox prompt', () => { events.keypress('enter'); await expect(answer).resolves.toEqual([1]); }); + + it('shows validation message if not enough choices are selected ', async () => { + const { answer, events, getScreen } = await render(checkbox, { + message: 'Select a number', + choices: numberedChoices, + minChoices: 2, + }); + + events.keypress('enter'); + expect(getScreen()).toMatchInlineSnapshot(` + "? Select a number (Press to select, to toggle all, to invert + selection, and to proceed) + ❯◯ 1 + ◯ 2 + ◯ 3 + ◯ 4 + ◯ 5 + ◯ 6 + ◯ 7 + (Use arrow keys to reveal more choices) + > At least 2 choices must be selected" + `); + + events.keypress('space'); + events.keypress('down'); + events.keypress('space'); + expect(getScreen()).toMatchInlineSnapshot(` + "? Select a number + ◉ 1 + ❯◉ 2 + ◯ 3 + ◯ 4 + ◯ 5 + ◯ 6 + ◯ 7 + (Use arrow keys to reveal more choices)" + `); + + events.keypress('enter'); + await expect(answer).resolves.toEqual([1, 2]); + }); + + it('shows validation message if too many choices are selected ', async () => { + const { answer, events, getScreen } = await render(checkbox, { + message: 'Select a number', + choices: numberedChoices, + maxChoices: 2, + }); + + events.keypress('space'); + events.keypress('down'); + events.keypress('space'); + events.keypress('down'); + events.keypress('space'); + events.keypress('enter'); + expect(getScreen()).toMatchInlineSnapshot(` + "? Select a number + ◉ 1 + ◉ 2 + ❯◉ 3 + ◯ 4 + ◯ 5 + ◯ 6 + ◯ 7 + (Use arrow keys to reveal more choices) + > At most 2 choices must be selected" + `); + + events.keypress('space'); + expect(getScreen()).toMatchInlineSnapshot(` + "? Select a number + ◉ 1 + ◉ 2 + ❯◯ 3 + ◯ 4 + ◯ 5 + ◯ 6 + ◯ 7 + (Use arrow keys to reveal more choices)" + `); + + events.keypress('enter'); + await expect(answer).resolves.toEqual([1, 2]); + }); }); From 47aeaf4f1f985afb6a76e871c01679efb9e7770e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20=C5=A0renkel?= Date: Wed, 18 Oct 2023 15:51:53 +0200 Subject: [PATCH 08/13] updated readme --- packages/checkbox/README.md | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/checkbox/README.md b/packages/checkbox/README.md index a8ff806b8..325c41594 100644 --- a/packages/checkbox/README.md +++ b/packages/checkbox/README.md @@ -35,13 +35,15 @@ 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. | +| minChoices | `number` | no | Minimum required number of choices that should be selected. | +| maxChoices | `number` | no | Maximum required number of choices that should be selected. | 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. From c2ee393f3b45a054b61d0afdd742e57c38fcdd03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20=C5=A0renkel?= Date: Wed, 18 Oct 2023 15:57:54 +0200 Subject: [PATCH 09/13] small fixes --- packages/checkbox/checkbox.test.mts | 2 +- packages/checkbox/src/index.mts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/checkbox/checkbox.test.mts b/packages/checkbox/checkbox.test.mts index e8ec055c3..48b85ab0d 100644 --- a/packages/checkbox/checkbox.test.mts +++ b/packages/checkbox/checkbox.test.mts @@ -644,7 +644,7 @@ describe('checkbox prompt', () => { ◯ 6 ◯ 7 (Use arrow keys to reveal more choices) - > At least 1 choice must be selected" + > At least one choice must be selected" `); events.keypress('space'); diff --git a/packages/checkbox/src/index.mts b/packages/checkbox/src/index.mts index f09aaa867..93a25ba98 100644 --- a/packages/checkbox/src/index.mts +++ b/packages/checkbox/src/index.mts @@ -114,7 +114,7 @@ export default createPrompt( if (isEnterKey(key)) { const selectedChoices = items.filter(isChecked).length; if ((required || (minChoices && minChoices === 1)) && selectedChoices === 0) { - setError('At least 1 choice must be selected'); + setError('At least one choice must be selected'); } else if (minChoices && selectedChoices < minChoices) { setError(`At least ${minChoices} choices must be selected`); } else if (maxChoices && selectedChoices > maxChoices) { From 821804d376b034a25ade81652823e411c073d5eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20=C5=A0renkel?= Date: Thu, 2 Nov 2023 22:02:51 +0100 Subject: [PATCH 10/13] moved to a more versatile 'validate' prop --- packages/checkbox/README.md | 17 +++--- packages/checkbox/checkbox.test.mts | 84 ++++++++--------------------- packages/checkbox/src/index.mts | 22 ++++---- 3 files changed, 41 insertions(+), 82 deletions(-) diff --git a/packages/checkbox/README.md b/packages/checkbox/README.md index 325c41594..f2150610d 100644 --- a/packages/checkbox/README.md +++ b/packages/checkbox/README.md @@ -35,15 +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. | -| minChoices | `number` | no | Minimum required number of choices that should be selected. | -| maxChoices | `number` | no | Maximum required number of choices that should 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` | 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. diff --git a/packages/checkbox/checkbox.test.mts b/packages/checkbox/checkbox.test.mts index 48b85ab0d..ac9de1878 100644 --- a/packages/checkbox/checkbox.test.mts +++ b/packages/checkbox/checkbox.test.mts @@ -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]); @@ -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]); @@ -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]); @@ -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]); @@ -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]); @@ -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]); @@ -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']); @@ -384,6 +391,7 @@ describe('checkbox prompt', () => { `); events.keypress('enter'); + await Promise.resolve(); expect(getScreen()).toMatchInlineSnapshot('"? Select a topping"'); await expect(answer).resolves.toEqual([]); @@ -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']); @@ -450,6 +459,7 @@ describe('checkbox prompt', () => { `); events.keypress('enter'); + await Promise.resolve(); expect(getScreen()).toMatchInlineSnapshot('"? Select a topping"'); await expect(answer).resolves.toEqual([]); @@ -582,6 +592,7 @@ describe('checkbox prompt', () => { `); events.keypress('enter'); + await Promise.resolve(); expect(getScreen()).toMatchInlineSnapshot('"? Select a number"'); await expect(answer).resolves.toEqual([]); @@ -609,6 +620,7 @@ describe('checkbox prompt', () => { `); events.keypress('enter'); + await Promise.resolve(); expect(getScreen()).toMatchInlineSnapshot('"? Select a number"'); await expect(answer).resolves.toEqual([]); @@ -633,6 +645,7 @@ describe('checkbox prompt', () => { }); events.keypress('enter'); + await Promise.resolve(); expect(getScreen()).toMatchInlineSnapshot(` "? Select a number (Press to select, to toggle all, to invert selection, and to proceed) @@ -664,14 +677,20 @@ describe('checkbox prompt', () => { await expect(answer).resolves.toEqual([1]); }); - it('shows validation message if not enough choices are selected ', async () => { + it('uses custom validation', async () => { const { answer, events, getScreen } = await render(checkbox, { message: 'Select a number', choices: numberedChoices, - minChoices: 2, + 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 to select, to toggle all, to invert selection, and to proceed) @@ -683,68 +702,11 @@ describe('checkbox prompt', () => { ◯ 6 ◯ 7 (Use arrow keys to reveal more choices) - > At least 2 choices must be selected" + > Please select only one choice" `); - events.keypress('space'); - events.keypress('down'); - events.keypress('space'); - expect(getScreen()).toMatchInlineSnapshot(` - "? Select a number - ◉ 1 - ❯◉ 2 - ◯ 3 - ◯ 4 - ◯ 5 - ◯ 6 - ◯ 7 - (Use arrow keys to reveal more choices)" - `); - - events.keypress('enter'); - await expect(answer).resolves.toEqual([1, 2]); - }); - - it('shows validation message if too many choices are selected ', async () => { - const { answer, events, getScreen } = await render(checkbox, { - message: 'Select a number', - choices: numberedChoices, - maxChoices: 2, - }); - - events.keypress('space'); - events.keypress('down'); - events.keypress('space'); - events.keypress('down'); events.keypress('space'); events.keypress('enter'); - expect(getScreen()).toMatchInlineSnapshot(` - "? Select a number - ◉ 1 - ◉ 2 - ❯◉ 3 - ◯ 4 - ◯ 5 - ◯ 6 - ◯ 7 - (Use arrow keys to reveal more choices) - > At most 2 choices must be selected" - `); - - events.keypress('space'); - expect(getScreen()).toMatchInlineSnapshot(` - "? Select a number - ◉ 1 - ◉ 2 - ❯◯ 3 - ◯ 4 - ◯ 5 - ◯ 6 - ◯ 7 - (Use arrow keys to reveal more choices)" - `); - - events.keypress('enter'); - await expect(answer).resolves.toEqual([1, 2]); + await expect(answer).resolves.toEqual([1]); }); }); diff --git a/packages/checkbox/src/index.mts b/packages/checkbox/src/index.mts index 93a25ba98..042096faf 100644 --- a/packages/checkbox/src/index.mts +++ b/packages/checkbox/src/index.mts @@ -33,8 +33,9 @@ type Config = PromptConfig<{ choices: ReadonlyArray | Separator>; loop?: boolean; required?: boolean; - minChoices?: number; - maxChoices?: number; + validate?: ( + items: ReadonlyArray>, + ) => boolean | string | Promise; }>; type Item = Separator | Choice; @@ -84,8 +85,7 @@ export default createPrompt( loop = true, choices, required, - minChoices, - maxChoices, + validate = () => true, } = config; const [status, setStatus] = useState('pending'); const [items, setItems] = useState>>( @@ -110,18 +110,16 @@ export default createPrompt( const [showHelpTip, setShowHelpTip] = useState(true); const [errorMsg, setError] = useState(undefined); - useKeypress((key) => { + useKeypress(async (key) => { if (isEnterKey(key)) { - const selectedChoices = items.filter(isChecked).length; - if ((required || (minChoices && minChoices === 1)) && selectedChoices === 0) { + const isValid = await validate(items); + if (required && !items.some(isChecked)) { setError('At least one choice must be selected'); - } else if (minChoices && selectedChoices < minChoices) { - setError(`At least ${minChoices} choices must be selected`); - } else if (maxChoices && selectedChoices > maxChoices) { - setError(`At most ${maxChoices} choices 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 ( From b6e10990ee24f922de195aecb3db01d7bb9a7cbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20=C5=A0renkel?= Date: Thu, 2 Nov 2023 22:07:52 +0100 Subject: [PATCH 11/13] fixed upset eslint --- packages/checkbox/checkbox.test.mts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/checkbox/checkbox.test.mts b/packages/checkbox/checkbox.test.mts index ac9de1878..5c94bae62 100644 --- a/packages/checkbox/checkbox.test.mts +++ b/packages/checkbox/checkbox.test.mts @@ -681,7 +681,7 @@ describe('checkbox prompt', () => { const { answer, events, getScreen } = await render(checkbox, { message: 'Select a number', choices: numberedChoices, - validate: (items: any) => { + validate(items: any) { if (items.filter((item: any) => item.checked).length === 1) { return true; } From 7018aebdcd906de0f539e1ba729f1bd7987ed14c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20=C5=A0renkel?= Date: Mon, 6 Nov 2023 21:07:20 +0100 Subject: [PATCH 12/13] swapped assertions where possible --- packages/checkbox/checkbox.test.mts | 48 ++++++++--------------------- 1 file changed, 12 insertions(+), 36 deletions(-) diff --git a/packages/checkbox/checkbox.test.mts b/packages/checkbox/checkbox.test.mts index 5c94bae62..dfe5fc186 100644 --- a/packages/checkbox/checkbox.test.mts +++ b/packages/checkbox/checkbox.test.mts @@ -54,10 +54,8 @@ describe('checkbox prompt', () => { `); events.keypress('enter'); - await Promise.resolve(); - 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 () => { @@ -95,10 +93,8 @@ describe('checkbox prompt', () => { `); events.keypress('enter'); - await Promise.resolve(); - 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 () => { @@ -136,10 +132,8 @@ describe('checkbox prompt', () => { `); events.keypress('enter'); - await Promise.resolve(); - 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 () => { @@ -178,10 +172,8 @@ describe('checkbox prompt', () => { `); events.keypress('enter'); - await Promise.resolve(); - 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 () => { @@ -220,10 +212,8 @@ describe('checkbox prompt', () => { `); events.keypress('enter'); - await Promise.resolve(); - 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 () => { @@ -247,10 +237,8 @@ describe('checkbox prompt', () => { `); events.keypress('enter'); - await Promise.resolve(); - 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 () => { @@ -357,10 +345,8 @@ describe('checkbox prompt', () => { `); events.keypress('enter'); - await Promise.resolve(); - 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 () => { @@ -391,10 +377,8 @@ describe('checkbox prompt', () => { `); events.keypress('enter'); - await Promise.resolve(); - expect(getScreen()).toMatchInlineSnapshot('"? Select a topping"'); - await expect(answer).resolves.toEqual([]); + expect(getScreen()).toMatchInlineSnapshot('"? Select a topping"'); }); it('skip separator by arrow keys', async () => { @@ -425,10 +409,8 @@ describe('checkbox prompt', () => { `); events.keypress('enter'); - await Promise.resolve(); - 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 () => { @@ -459,10 +441,8 @@ describe('checkbox prompt', () => { `); events.keypress('enter'); - await Promise.resolve(); - expect(getScreen()).toMatchInlineSnapshot('"? Select a topping"'); - await expect(answer).resolves.toEqual([]); + expect(getScreen()).toMatchInlineSnapshot('"? Select a topping"'); }); it('allow select all', async () => { @@ -592,10 +572,8 @@ describe('checkbox prompt', () => { `); events.keypress('enter'); - await Promise.resolve(); - expect(getScreen()).toMatchInlineSnapshot('"? Select a number"'); - await expect(answer).resolves.toEqual([]); + expect(getScreen()).toMatchInlineSnapshot('"? Select a number"'); }); it('allow customizing help tip', async () => { @@ -620,10 +598,8 @@ describe('checkbox prompt', () => { `); events.keypress('enter'); - await Promise.resolve(); - 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 () => { From dbc40b457a8051a1892155bead03a305e866f953 Mon Sep 17 00:00:00 2001 From: Simon Boudrias Date: Tue, 7 Nov 2023 17:14:01 -0500 Subject: [PATCH 13/13] Checkbox: Only pass checked items to validate --- packages/checkbox/checkbox.test.mts | 6 +++--- packages/checkbox/src/index.mts | 5 +++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/checkbox/checkbox.test.mts b/packages/checkbox/checkbox.test.mts index dfe5fc186..1c3ab6715 100644 --- a/packages/checkbox/checkbox.test.mts +++ b/packages/checkbox/checkbox.test.mts @@ -658,10 +658,10 @@ describe('checkbox prompt', () => { message: 'Select a number', choices: numberedChoices, validate(items: any) { - if (items.filter((item: any) => item.checked).length === 1) { - return true; + if (items.length !== 1) { + return 'Please select only one choice'; } - return 'Please select only one choice'; + return true; }, }); diff --git a/packages/checkbox/src/index.mts b/packages/checkbox/src/index.mts index 042096faf..3b0eebe32 100644 --- a/packages/checkbox/src/index.mts +++ b/packages/checkbox/src/index.mts @@ -112,12 +112,13 @@ export default createPrompt( useKeypress(async (key) => { if (isEnterKey(key)) { - const isValid = await validate(items); + const selection = items.filter(isChecked); + const isValid = await validate([...selection]); if (required && !items.some(isChecked)) { setError('At least one choice must be selected'); } 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'); }