Skip to content

Commit

Permalink
Feat(@inquirer/search): Add autocomplete capacities to the search prompt
Browse files Browse the repository at this point in the history
  • Loading branch information
SBoudrias committed Aug 30, 2024
1 parent ae0c84e commit ce5d3b6
Show file tree
Hide file tree
Showing 4 changed files with 261 additions and 11 deletions.
57 changes: 57 additions & 0 deletions packages/demo/demos/search.mjs
Original file line number Diff line number Diff line change
@@ -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 }) => {
Expand All @@ -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:')) {
Expand Down
25 changes: 19 additions & 6 deletions packages/search/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Choice[]>` | 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<Choice[]>` | 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<boolean \| string>` | 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

Expand Down Expand Up @@ -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.
Expand Down
157 changes: 157 additions & 0 deletions packages/search/search.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
33 changes: 28 additions & 5 deletions packages/search/src/index.mts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ type SearchConfig<Value> = {
) =>
| ReadonlyArray<Choice<Value> | Separator>
| Promise<ReadonlyArray<Choice<Value> | Separator>>;
validate?: (value: Value) => boolean | string | Promise<string | boolean>;
pageSize?: number;
theme?: PartialDeep<Theme<SearchTheme>>;
};
Expand All @@ -69,7 +70,7 @@ function stringifyChoice(choice: Choice<unknown>): string {

export default createPrompt(
<Value,>(config: SearchConfig<Value>, done: (value: Value) => void) => {
const { pageSize = 7 } = config;
const { pageSize = 7, validate = () => true } = config;
const theme = makeTheme<SearchTheme>(searchTheme, config.theme);
const firstRender = useRef(true);
const [status, setStatus] = useState<string>('searching');
Expand Down Expand Up @@ -126,10 +127,32 @@ export default createPrompt(
// Safe to assume the cursor position never points to a Separator.
const selectedChoice = searchResults[active] as Choice<Value> | 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 (
Expand Down

0 comments on commit ce5d3b6

Please sign in to comment.