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: prompts pick by value #8253

Merged
merged 1 commit into from
Oct 21, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 51 additions & 7 deletions packages/amplify-prompts/src/__tests__/prompter.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { prompter } from '../prompter';
import { byValue, byValues, prompter } from '../prompter';
import { prompt } from 'enquirer';
import * as flags from '../flags';

Expand Down Expand Up @@ -77,9 +77,10 @@ describe('input', () => {

it('transforms each input part separately when "many" specified', async () => {
prompt_mock.mockResolvedValueOnce({ result: ['10', '20'] });
expect(
await prompter.input<'many'>('test message', { returnSize: 'many', transform: input => `${input}suffix` }),
).toEqual(['10suffix', '20suffix']);
expect(await prompter.input<'many'>('test message', { returnSize: 'many', transform: input => `${input}suffix` })).toEqual([
'10suffix',
'20suffix',
]);
});
});

Expand All @@ -104,6 +105,19 @@ describe('pick', () => {
expect(prompt_mock.mock.calls.length).toBe(0);
});

it('computes selection index using selection function', async () => {
prompt_mock.mockResolvedValueOnce({ result: 'opt2' });
await prompter.pick('test message', ['opt1', 'opt2', 'opt3'], { initial: byValue('opt2') });
expect((prompt_mock.mock.calls[0][0] as any).initial).toBe(1);
});

it('returns initial selection using selection function when yes flag is set', async () => {
flags_mock.isYes = true;
const result = await prompter.pick('test message', ['opt1', 'opt2', 'opt3'], { initial: byValue('opt2') });
expect(result).toBe('opt2');
expect(prompt_mock.mock.calls.length).toBe(0);
});

it('throws if no choices provided', async () => {
expect(() => prompter.pick('test message', [])).rejects.toThrowErrorMatchingInlineSnapshot(
`"No choices provided for prompt [test message]"`,
Expand All @@ -123,8 +137,38 @@ describe('pick', () => {
it('returns selected items when multiSelect', async () => {
const mockResult = ['val1', 'val3'];
prompt_mock.mockResolvedValueOnce({ result: mockResult });
expect(
await prompter.pick<'many'>('test message', ['val1', 'val2', 'val3'], { returnSize: 'many' }),
).toEqual(mockResult);
expect(await prompter.pick<'many'>('test message', ['val1', 'val2', 'val3'], { returnSize: 'many' })).toEqual(mockResult);
});
});

describe('byValue', () => {
it('defaults to === when no equals function specified', () => {
expect(byValue('fox')(['the', 'quick', 'fox', 'jumped', 'over', 'the', 'lazy', 'dog'])).toBe(2);
});

it('returns the index of the first match if multiple present', () => {
expect(byValue('the')(['the', 'quick', 'fox', 'jumped', 'over', 'the', 'lazy', 'dog'])).toBe(0);
});

it('returns undefined when no match found', () => {
expect(byValue('dne')(['the', 'quick', 'fox', 'jumped', 'over', 'the', 'lazy', 'dog'])).toBeUndefined();
});

it('uses the equals function if specified', () => {
expect(byValue('four', (a, b) => a.length === b.length)(['the', 'quick', 'fox', 'jumped', 'over', 'the', 'lazy', 'dog'])).toBe(4);
});
});

describe('byValues', () => {
it('defaults to === when no equals function specified', () => {
expect(byValues(['fox', 'the'])(['the', 'quick', 'fox', 'jumped', 'over', 'lazy', 'dog']).sort()).toEqual([0, 2]);
});

it('returns [] when no matches found', () => {
expect(byValues(['dne'])(['the', 'quick', 'fox', 'jumped', 'over', 'the', 'lazy', 'dog'])).toEqual([]);
});

it('uses the equals function if specified', () => {
expect(byValues(['a', 'aa'], (a, b) => a.length === b.length)(['bbbb', 'bb', 'bbb', 'b']).sort()).toEqual([1, 3]);
});
});
5 changes: 4 additions & 1 deletion packages/amplify-prompts/src/demo/demo.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { printer } from '../printer';
import { prompter } from '../prompter';
import { byValue, prompter } from '../prompter';
import { alphanumeric, and, integer, minLength } from '../validators';

const printResult = (result: any) => console.log(`Prommpt result was [${result}]`);
Expand Down Expand Up @@ -117,6 +117,9 @@ const demo = async () => {
printer.info('When multiSelect is on, an array of initial indexes can be specified');
printResult(await prompter.pick<'many', number>('Pick your favorite colors', choices2, { returnSize: 'many', initial: [1, 2] }));

printer.info('Choices can also be selected by value using the provided helper function "byValue" (or "byValues" for multi-select)');
printResult(await prompter.pick<'one', number>('Pick your favorite color', choices2, { initial: byValue(4) }));

printer.info('Individual choices can be disabled or have hint text next to them');
(choices2[1] as any).hint = 'definitely the best';
(choices2[2] as any).disabled = true;
Expand Down
74 changes: 63 additions & 11 deletions packages/amplify-prompts/src/prompter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,11 +105,14 @@ class AmplifyPrompter implements Prompter {
* @param choices The selection set to choose from
* @param options Control prompt settings. options.multiSelect = true is required if PickType = 'many'
* @returns The item(s) selected. If PickType = 'one' this is a single value. If PickType = 'many', this is an array
*
* Note: due to this TS issue https://github.com/microsoft/TypeScript/issues/30611 type T cannot be an enum.
* If using an enum as the value type for a selection use T = string and assert the return type as the enum type.
*/
pick = async <RS extends ReturnSize = 'one', T = string>(
message: string,
choices: Choices<T>,
...options: MaybeOptionalPickOptions<RS>
...options: MaybeOptionalPickOptions<RS, T>
): Promise<PromptReturn<RS, T>> => {
// some choices must be provided
if (choices?.length === 0) {
Expand All @@ -124,6 +127,11 @@ class AmplifyPrompter implements Prompter {
? ((choices as string[]).map(choice => ({ name: choice, value: choice })) as unknown as GenericChoice<T>[]) // this assertion is safe because the choice array can only be a string[] if the generic type is a string
: (choices as GenericChoice<T>[]);

const initialIndexes = initialOptsToIndexes(
genericChoices.map(choice => choice.value),
opts?.initial,
);

// enquirer requires all choice values be strings, so set up a mapping of string => T
// and format choices to conform to enquirer's interface
const choiceValueMap = new Map<string, T>();
Expand All @@ -139,13 +147,13 @@ class AmplifyPrompter implements Prompter {
if (choices?.length === 1) {
this.print.info(`Only one option for [${message}]. Selecting [${result}].`);
} else if (isYes) {
if (opts?.initial === undefined || (Array.isArray(opts?.initial) && opts?.initial.length === 0)) {
if (initialIndexes === undefined || (Array.isArray(initialIndexes) && initialIndexes.length === 0)) {
throw new Error(`Cannot prompt for [${message}] when '--yes' flag is set`);
}
if (typeof opts?.initial === 'number') {
result = genericChoices[opts?.initial].name;
if (typeof initialIndexes === 'number') {
result = genericChoices[initialIndexes].name;
} else {
result = opts?.initial.map(idx => genericChoices[idx].name);
result = initialIndexes.map(idx => genericChoices[idx].name);
}
} else {
// enquirer does not clear the stdout buffer on TSTP (Ctrl + Z) so this listener maps it to process.exit() which will clear the buffer
Expand All @@ -165,7 +173,7 @@ class AmplifyPrompter implements Prompter {
name: 'result',
message,
hint: '(Use arrow keys or type to filter)',
initial: opts?.initial,
initial: initialIndexes,
// there is a typo in the .d.ts file for this field -- muliple -> multiple
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
Expand All @@ -191,6 +199,30 @@ class AmplifyPrompter implements Prompter {

export const prompter: Prompter = new AmplifyPrompter();

/**
* Helper function to generate a function that will return the indices of a selection set from a list
* @param selection The list of values to select from a list
* @param equals An optional function to determine if two elements are equal. If not specified, === is used
* Note that choices are assumed to be unique by the equals function definition
*/
export const byValues =
<T>(selection: T[], equals: EqualsFunction<T> = defaultEquals): MultiFilterFunction<T> =>
(choices: T[]) =>
selection.map(sel => choices.findIndex(choice => equals(choice, sel))).filter(idx => idx >= 0);

/**
* Helper function to generate a function that will return an index of a single selection from a list
* @param selection The single selection to find in the list
* @param equals An optional function to determine if two elements are equal. If not specified, === is used
* Note that choices are assumed to be unique by the equals function definition
*/
export const byValue =
<T>(selection: T, equals: EqualsFunction<T> = defaultEquals): SingleFilterFunction<T> =>
(choices: T[]) => {
const idx = choices.findIndex(choice => equals(choice, selection));
return idx < 0 ? undefined : idx;
};

const validateEachWith = (validator?: Validator) => async (input: string[]) => {
if (!validator) {
return true;
Expand All @@ -203,6 +235,20 @@ const validateEachWith = (validator?: Validator) => async (input: string[]) => {
return true;
};

const initialOptsToIndexes = <RS extends ReturnSize, T>(
values: T[],
initial: InitialSelectionOption<RS, T>['initial'],
): number | number[] | undefined => {
if (initial === undefined || typeof initial === 'number' || Array.isArray(initial)) {
return initial;
}
return initial(values);
};

type EqualsFunction<T> = (a: T, b: T) => boolean;

const defaultEquals = <T>(a: T, b: T) => a === b;

type Prompter = {
confirmContinue: (message?: string) => Promise<boolean>;
yesOrNo: (message: string, initial?: boolean) => Promise<boolean>;
Expand All @@ -215,7 +261,7 @@ type Prompter = {
message: string,
choices: Choices<T>,
// options is typed using spread because it's the only way to make it required if RS is 'many' but optional if RS is 'one'
...options: MaybeOptionalPickOptions<RS>
...options: MaybeOptionalPickOptions<RS, T>
) => Promise<PromptReturn<RS, T>>;
};

Expand All @@ -228,10 +274,16 @@ type MaybeAvailableHiddenInputOption<RS extends ReturnSize> = RS extends 'many'
hidden?: boolean;
};

type InitialSelectionOption<RS extends ReturnSize> = {
initial?: RS extends 'one' ? number : number[];
// The initial selection for a pick prompt can be specified either by index or a selection function that generates indexes.
// See byValues and byValue above
type InitialSelectionOption<RS extends ReturnSize, T> = {
initial?: RS extends 'one' ? number | SingleFilterFunction<T> : number[] | MultiFilterFunction<T>;
};

type SingleFilterFunction<T> = (arr: T[]) => number | undefined;

type MultiFilterFunction<T> = (arr: T[]) => number[];

type InitialValueOption<T> = {
initial?: T;
};
Expand Down Expand Up @@ -273,12 +325,12 @@ type MaybeOptionalInputOptions<RS extends ReturnSize, T> = RS extends 'many'
? [InputOptions<RS, T>?]
: [InputOptions<RS, T>];

type MaybeOptionalPickOptions<RS extends ReturnSize> = RS extends 'many' ? [PickOptions<RS>] : [PickOptions<RS>?];
type MaybeOptionalPickOptions<RS extends ReturnSize, T> = RS extends 'many' ? [PickOptions<RS, T>] : [PickOptions<RS, T>?];

type PromptReturn<RS extends ReturnSize, T> = RS extends 'many' ? T[] : T;

// the following types are the method input types
type PickOptions<RS extends ReturnSize> = ReturnSizeOption<RS> & InitialSelectionOption<RS>;
type PickOptions<RS extends ReturnSize, T> = ReturnSizeOption<RS> & InitialSelectionOption<RS, T>;

type InputOptions<RS extends ReturnSize, T> = ReturnSizeOption<RS> &
ValidateValueOption &
Expand Down