Skip to content

Commit

Permalink
Feat(editor): Add option to control behavior on validation failure. Fix
Browse files Browse the repository at this point in the history
  • Loading branch information
SBoudrias committed Dec 7, 2024
1 parent 2942263 commit b3032b8
Show file tree
Hide file tree
Showing 3 changed files with 131 additions and 10 deletions.
3 changes: 3 additions & 0 deletions packages/editor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,12 @@ type Theme = {
help: (text: string) => string;
key: (text: string) => string;
};
validationFailureMode: 'keep' | 'clear';
};
```

`validationFailureMode` defines the behavior of the prompt when the value submitted is invalid. By default, we'll keep the value allowing the user to edit it. When the theme option is set to `clear`, we'll remove and reset to the default value or empty string.

# License

Copyright (c) 2023 Simon Boudrias (twitter: [@vaxilart](https://twitter.com/Vaxilart))<br/>
Expand Down
117 changes: 111 additions & 6 deletions packages/editor/editor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@ describe('editor prompt', () => {
expect(editAsync).not.toHaveBeenCalled();

events.keypress('enter');
expect(editAsync).toHaveBeenCalledWith('', expect.any(Function), { postfix: '.txt' });
expect(editAsync).toHaveBeenLastCalledWith('', expect.any(Function), {
postfix: '.txt',
});

await editorAction(undefined, 'value from editor');

Expand All @@ -45,7 +47,9 @@ describe('editor prompt', () => {
message: 'Add a description',
waitForUseInput: false,
});
expect(editAsync).toHaveBeenCalledWith('', expect.any(Function), { postfix: '.txt' });
expect(editAsync).toHaveBeenLastCalledWith('', expect.any(Function), {
postfix: '.txt',
});

await editorAction(undefined, 'value from editor');

Expand All @@ -63,9 +67,13 @@ describe('editor prompt', () => {
expect(editAsync).not.toHaveBeenCalled();

events.keypress('enter');
expect(editAsync).toHaveBeenCalledWith('default description', expect.any(Function), {
postfix: '.md',
});
expect(editAsync).toHaveBeenLastCalledWith(
'default description',
expect.any(Function),
{
postfix: '.md',
},
);

await editorAction(undefined, 'value from editor');

Expand All @@ -85,7 +93,7 @@ describe('editor prompt', () => {
expect(editAsync).not.toHaveBeenCalled();

events.keypress('enter');
expect(editAsync).toHaveBeenCalledWith('', expect.any(Function), {
expect(editAsync).toHaveBeenLastCalledWith('', expect.any(Function), {
postfix: '.md',
dir: '/tmp',
});
Expand Down Expand Up @@ -129,6 +137,10 @@ describe('editor prompt', () => {
expect(editAsync).toHaveBeenCalledOnce();
events.keypress('enter');
expect(editAsync).toHaveBeenCalledTimes(2);
// Previous answer is passed in the second time for editing
expect(editAsync).toHaveBeenLastCalledWith('3', expect.any(Function), {
postfix: '.txt',
});

// Test user defined error message
await editorAction(undefined, '2');
Expand All @@ -145,6 +157,99 @@ describe('editor prompt', () => {
expect(getScreen()).toMatchInlineSnapshot(`"✔ Add a description"`);
});

it('clear value on failed validation', async () => {
const { answer, events, getScreen } = await render(editor, {
message: 'Add a description',
validate: (value: string) => {
switch (value) {
case '1': {
return true;
}
case '2': {
return '"2" is not an allowed value';
}
default: {
return false;
}
}
},
theme: {
validationFailureMode: 'clear',
},
});

expect(editAsync).not.toHaveBeenCalled();
events.keypress('enter');

expect(editAsync).toHaveBeenCalledOnce();
expect(editAsync).toHaveBeenLastCalledWith('', expect.any(Function), {
postfix: '.txt',
});
await editorAction(undefined, 'foo bar');
expect(getScreen()).toMatchInlineSnapshot(`
"? Add a description Press <enter> to launch your preferred editor.
> You must provide a valid value"
`);

events.keypress('enter');
expect(editAsync).toHaveBeenCalledTimes(2);
// Because we clear, the second call goes back to an empty string
expect(editAsync).toHaveBeenLastCalledWith('', expect.any(Function), {
postfix: '.txt',
});

await editorAction(undefined, '1');
await expect(answer).resolves.toEqual('1');
expect(getScreen()).toMatchInlineSnapshot(`"✔ Add a description"`);
});

it('goes back to default value on failed validation', async () => {
const { answer, events, getScreen } = await render(editor, {
message: 'Add a description',
default: 'default value',
validate: (value: string) => {
switch (value) {
case '1': {
return true;
}
case '2': {
return '"2" is not an allowed value';
}
default: {
return false;
}
}
},
theme: {
validationFailureMode: 'clear',
},
});

expect(editAsync).not.toHaveBeenCalled();
events.keypress('enter');

expect(editAsync).toHaveBeenCalledOnce();
expect(editAsync).toHaveBeenLastCalledWith('default value', expect.any(Function), {
postfix: '.txt',
});
await editorAction(undefined, 'foo bar');
expect(getScreen()).toMatchInlineSnapshot(`
"? Add a description Press <enter> to launch your preferred editor.
> You must provide a valid value"
`);

events.keypress('enter');
expect(editAsync).toHaveBeenCalledTimes(2);
// Because we clear, the second call goes back to the default value
expect(editAsync).toHaveBeenLastCalledWith('default value', expect.any(Function), {
postfix: '.txt',
});

await editorAction(undefined, '1');
await expect(answer).resolves.toEqual('1');
expect(getScreen()).toMatchInlineSnapshot(`"✔ Add a description"`);
});

it('surfaces external-editor errors', async () => {
const { answer, events, getScreen } = await render(editor, {
message: 'Add a description',
Expand Down
21 changes: 17 additions & 4 deletions packages/editor/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,22 @@ import {
} from '@inquirer/core';
import type { PartialDeep, InquirerReadline } from '@inquirer/type';

type EditorTheme = {
validationFailureMode: 'keep' | 'clear';
};

const editorTheme: EditorTheme = {
validationFailureMode: 'keep',
};

type EditorConfig = {
message: string;
default?: string;
postfix?: string;
waitForUseInput?: boolean;
validate?: (value: string) => boolean | string | Promise<string | boolean>;
file?: IFileOptions;
theme?: PartialDeep<Theme>;
theme?: PartialDeep<Theme<EditorTheme>>;
};

export default createPrompt<string, EditorConfig>((config, done) => {
Expand All @@ -29,10 +37,10 @@ export default createPrompt<string, EditorConfig>((config, done) => {
file: { postfix = config.postfix ?? '.txt', ...fileProps } = {},
validate = () => true,
} = config;
const theme = makeTheme(config.theme);
const theme = makeTheme<EditorTheme>(editorTheme, config.theme);

const [status, setStatus] = useState<Status>('idle');
const [value, setValue] = useState<string>(config.default || '');
const [value = '', setValue] = useState<string | undefined>(config.default);
const [errorMsg, setError] = useState<string>();

const prefix = usePrefix({ status, theme });
Expand All @@ -54,7 +62,12 @@ export default createPrompt<string, EditorConfig>((config, done) => {
setStatus('done');
done(answer);
} else {
setValue(answer);
if (theme.validationFailureMode === 'clear') {
setValue(config.default);
} else {
setValue(answer);
}

setError(isValid || 'You must provide a valid value');
setStatus('idle');
}
Expand Down

0 comments on commit b3032b8

Please sign in to comment.