From ce5d3b6ab903b43581c34f8069a6b4f108002eb1 Mon Sep 17 00:00:00 2001 From: Simon Boudrias Date: Sun, 25 Aug 2024 15:27:35 -0400 Subject: [PATCH] Feat(@inquirer/search): Add autocomplete capacities to the search prompt --- packages/demo/demos/search.mjs | 57 ++++++++++++ packages/search/README.md | 25 +++-- packages/search/search.test.mts | 157 ++++++++++++++++++++++++++++++++ packages/search/src/index.mts | 33 ++++++- 4 files changed, 261 insertions(+), 11 deletions(-) diff --git a/packages/demo/demos/search.mjs b/packages/demo/demos/search.mjs index db285b1bd..9be13fda2 100644 --- a/packages/demo/demos/search.mjs +++ b/packages/demo/demos/search.mjs @@ -1,9 +1,30 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; import * as url from 'node:url'; import { search } from '@inquirer/prompts'; +async function fileExists(filepath) { + return fs.access(filepath).then( + () => true, + () => false, + ); +} + +async function isDirectory(path) { + if (await fileExists(path)) { + const stats = await fs.stat(path); + return stats.isDirectory(); + } + + return false; +} + +const root = path.dirname(path.join(url.fileURLToPath(import.meta.url), '../../..')); + const demo = async () => { let answer; + // Demo: Search results from an API answer = await search({ message: 'Select an npm package', source: async (input = 'inquirer', { signal }) => { @@ -22,6 +43,42 @@ const demo = async () => { }, }); console.log('Answer:', answer); + + // Demo: Using the search prompt as an autocomplete tool. + answer = await search({ + message: 'Select a file', + source: async (term = '') => { + let dirPath = path.join(root, term); + while (!(await isDirectory(dirPath)) && dirPath !== root) { + dirPath = path.dirname(dirPath); + } + + const files = await fs.readdir(dirPath, { withFileTypes: true }); + return files + .sort((a, b) => { + if (a.isDirectory() === b.isDirectory()) { + return a.name.localeCompare(b.name); + } + + // Sort dir first + return a.isDirectory() ? -1 : 1; + }) + .map((file) => ({ + name: + path.relative(root, path.join(dirPath, file.name)) + + (file.isDirectory() ? '/' : ''), + value: path.join(file.parentPath, file.name), + })) + .filter(({ value }) => value.includes(term)); + }, + validate: async (filePath) => { + if (!(await fileExists(filePath)) || (await isDirectory(filePath))) { + return 'You must select a file'; + } + return true; + }, + }); + console.log('Answer:', answer); }; if (import.meta.url.startsWith('file:')) { diff --git a/packages/search/README.md b/packages/search/README.md index 5d44a7f8c..b98016e64 100644 --- a/packages/search/README.md +++ b/packages/search/README.md @@ -76,12 +76,13 @@ const answer = await search({ ## Options -| Property | Type | Required | Description | -| -------- | --------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -| message | `string` | yes | The question to ask | -| source | `(term: string \| void) => Promise` | yes | This function returns the choices relevant to the search term. | -| 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. | -| theme | [See Theming](#Theming) | no | Customize look of the prompt. | +| Property | Type | Required | Description | +| -------- | ---------------------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| message | `string` | yes | The question to ask | +| source | `(term: string \| void) => Promise` | yes | This function returns the choices relevant to the search term. | +| 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. | +| validate | `Value => boolean \| string \| Promise` | no | On submit, validate the answer. 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. | +| theme | [See Theming](#Theming) | no | Customize look of the prompt. | ### `source` function @@ -123,6 +124,18 @@ Here's each property: - `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`. - `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. +### Validation & autocomplete interaction + +The validation within the search prompt acts as a signal for the autocomplete feature. + +When a list value is submitted and fail validation, the prompt will compare it to the search term. If they're the same, the prompt display the error. If they're not the same, we'll autocomplete the search term to match the value. Doing this will trigger a new search. + +You can rely on this behavior to implement progressive autocomplete searches. Where you want the user to narrow the search in a progressive manner. + +Pressing `tab` also triggers the term autocomplete. + +You can see this behavior in action in [our search demo](https://github.com/SBoudrias/Inquirer.js/blob/main/packages/demo/demos/search.mjs). + ## 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. diff --git a/packages/search/search.test.mts b/packages/search/search.test.mts index c544087c9..7146d226e 100644 --- a/packages/search/search.test.mts +++ b/packages/search/search.test.mts @@ -330,4 +330,161 @@ describe('search prompt', () => { answer.cancel(); await expect(answer).rejects.toThrow(); }); + + it('Autocomplete with tab', async () => { + const { answer, events, getScreen } = await render(search, { + message: 'Select a Canadian province', + source: getListSearch(PROVINCES), + }); + + expect(getScreen()).toMatchInlineSnapshot(` + "? Select a Canadian province + ❯ Alberta + British Columbia + Manitoba + New Brunswick + Newfoundland and Labrador + Nova Scotia + Ontario + (Use arrow keys to reveal more choices)" + `); + + events.type('New'); + + await Promise.resolve(); + expect(getScreen()).toMatchInlineSnapshot(` + "? Select a Canadian province New + ❯ New Brunswick + Newfoundland and Labrador + (Use arrow keys)" + `); + + events.keypress('tab'); + expect(getScreen()).toMatchInlineSnapshot(` + "? Select a Canadian province New Brunswick + ❯ New Brunswick + Newfoundland and Labrador + (Use arrow keys)" + `); + + events.keypress('enter'); + await expect(answer).resolves.toEqual('NB'); + }); + + it('Autocomplete when pressing enter fail validation', async () => { + const FOLDERS = ['src', 'dist']; + const FILES = ['src/index.mts', 'dist/index.js']; + + const { answer, events, getScreen } = await render(search, { + message: 'Select a file', + source: (term?: string) => { + if (term && FOLDERS.includes(term)) { + return FILES.filter((file) => file.includes(term)).map((file) => ({ + name: file, + value: file, + })); + } + + return FOLDERS.filter((folder) => !term || folder.includes(term)).map((file) => ({ + name: file, + value: file, + })); + }, + validate: (value: string) => { + return FILES.includes(value) ? true : 'Invalid file'; + }, + }); + + expect(getScreen()).toMatchInlineSnapshot(` + "? Select a file + ❯ src + dist + (Use arrow keys)" + `); + + events.type('di'); + await Promise.resolve(); + expect(getScreen()).toMatchInlineSnapshot(` + "? Select a file di + ❯ dist" + `); + + events.keypress('enter'); + await Promise.resolve(); + await Promise.resolve(); + expect(getScreen()).toMatchInlineSnapshot(` + "? Select a file dist + ❯ dist/index.js" + `); + + events.keypress('enter'); + await expect(answer).resolves.toEqual('dist/index.js'); + }); + + it('handles validation errors', async () => { + const { answer, events, getScreen } = await render(search, { + message: 'Select a Canadian province', + source: getListSearch(PROVINCES), + validate: (value: string) => { + if (value === 'NB') return 'New Brunswick is unavailable at the moment.'; + if (value === 'AB') return false; // Test default error + return true; + }, + }); + + expect(getScreen()).toMatchInlineSnapshot(` + "? Select a Canadian province + ❯ Alberta + British Columbia + Manitoba + New Brunswick + Newfoundland and Labrador + Nova Scotia + Ontario + (Use arrow keys to reveal more choices)" + `); + + events.keypress('enter'); + await Promise.resolve(); + await Promise.resolve(); + expect(getScreen()).toMatchInlineSnapshot(` + "? Select a Canadian province Alberta + ❯ Alberta" + `); + + events.keypress('enter'); + await Promise.resolve(); + expect(getScreen()).toMatchInlineSnapshot(` + "? Select a Canadian province Alberta + > You must provide a valid value" + `); + + events.keypress({ name: 'backspace', ctrl: true }); + events.type('New Brun'); + await Promise.resolve(); + expect(getScreen()).toMatchInlineSnapshot(` + "? Select a Canadian province New Brun + ❯ New Brunswick" + `); + + events.keypress('enter'); + await Promise.resolve(); + expect(getScreen()).toMatchInlineSnapshot(` + "? Select a Canadian province New Brunswick + ❯ New Brunswick" + `); + + events.keypress('enter'); + await Promise.resolve(); + expect(getScreen()).toMatchInlineSnapshot(` + "? Select a Canadian province New Brunswick + > New Brunswick is unavailable at the moment." + `); + + events.keypress({ name: 'backspace', ctrl: true }); + events.type('Quebec'); + await Promise.resolve(); + events.keypress('enter'); + await expect(answer).resolves.toEqual('QC'); + }); }); diff --git a/packages/search/src/index.mts b/packages/search/src/index.mts index f32bd8f7d..4bece4e47 100644 --- a/packages/search/src/index.mts +++ b/packages/search/src/index.mts @@ -53,6 +53,7 @@ type SearchConfig = { ) => | ReadonlyArray | Separator> | Promise | Separator>>; + validate?: (value: Value) => boolean | string | Promise; pageSize?: number; theme?: PartialDeep>; }; @@ -69,7 +70,7 @@ function stringifyChoice(choice: Choice): string { export default createPrompt( (config: SearchConfig, done: (value: Value) => void) => { - const { pageSize = 7 } = config; + const { pageSize = 7, validate = () => true } = config; const theme = makeTheme(searchTheme, config.theme); const firstRender = useRef(true); const [status, setStatus] = useState('searching'); @@ -126,10 +127,32 @@ export default createPrompt( // Safe to assume the cursor position never points to a Separator. const selectedChoice = searchResults[active] as Choice | void; - useKeypress((key, rl) => { - if (isEnterKey(key) && selectedChoice) { - setStatus('done'); - done(selectedChoice.value); + useKeypress(async (key, rl) => { + if (isEnterKey(key)) { + if (selectedChoice) { + setStatus('loading'); + const isValid = await validate(selectedChoice.value); + setStatus('pending'); + + if (isValid === true) { + setStatus('done'); + done(selectedChoice.value); + } else if (selectedChoice.name === searchTerm) { + setSearchError(isValid || 'You must provide a valid value'); + } else { + // Reset line with new search term + rl.write(stringifyChoice(selectedChoice)); + setSearchTerm(stringifyChoice(selectedChoice)); + } + } else { + // Reset the readline line value to the previous value. On line event, the value + // get cleared, forcing the user to re-enter the value instead of fixing it. + rl.write(searchTerm); + } + } else if (key.name === 'tab' && selectedChoice) { + rl.clearLine(0); // Remove the tab character. + rl.write(stringifyChoice(selectedChoice)); + setSearchTerm(stringifyChoice(selectedChoice)); } else if (status !== 'searching' && (key.name === 'up' || key.name === 'down')) { rl.clearLine(0); if (