[cli] Improve UX for text input validation (#11388)

Originally, I was focused on fixing this visual bug:
<img width="565" alt="Screenshot 2024-04-04 at 9 50 56 AM" src="https://github.com/vercel/vercel/assets/8485687/eb526fae-69d4-4636-ac8b-4cf6a3475c58">

When digging into this issue, I realized there was an opportunity to improve text input validation UX and remove hand-rolled validation code.

## Before:

https://github.com/vercel/vercel/assets/8485687/5e264696-8b60-4863-8dc2-1a797f508074

https://github.com/vercel/vercel/assets/8485687/2104603a-331e-4679-9ec2-a4235fad252e

## After:

https://github.com/vercel/vercel/assets/8485687/72c51831-2b94-4cbe-b1f6-a5822bf9f1a8

https://github.com/vercel/vercel/assets/8485687/a371c7be-f7f5-4d3a-9451-58e7f572d25b

## Additional considerations

[input-root-directory](https://github.com/vercel/vercel/blob/main/packages/cli/src/util/input/input-root-directory.ts#L17) remains an instance of hand-rolled validation since it's trickier to force into inquirer's validation pattern. Also, we have a [hand-rolled text input function](https://github.com/vercel/vercel/blob/main/packages/cli/src/util/input/text.ts#L43) that will get similar benefits when replaced with `client.input.text`.
This commit is contained in:
Austin Merrick
2024-04-04 15:20:38 -07:00
committed by GitHub
parent 5660dab2ae
commit a56ab4ded9
5 changed files with 54 additions and 94 deletions

View File

@@ -0,0 +1,5 @@
---
'vercel': minor
---
improve UX for text input validation

View File

@@ -45,10 +45,16 @@ export default async function bisect(client: Client): Promise<number> {
let bad = let bad =
argv['--bad'] || argv['--bad'] ||
(await prompt(client, `Specify a URL where the bug occurs:`)); (await client.input.text({
message: `Specify a URL where the bug occurs:`,
validate: val => (val ? true : 'A URL must be provided'),
}));
let good = let good =
argv['--good'] || argv['--good'] ||
(await prompt(client, `Specify a URL where the bug does not occur:`)); (await client.input.text({
message: `Specify a URL where the bug does not occur:`,
validate: val => (val ? true : 'A URL must be provided'),
}));
let subpath = argv['--path'] || ''; let subpath = argv['--path'] || '';
let run = argv['--run'] || ''; let run = argv['--run'] || '';
const openEnabled = argv['--open'] || false; const openEnabled = argv['--open'] || false;
@@ -97,10 +103,10 @@ export default async function bisect(client: Client): Promise<number> {
} }
if (!subpath) { if (!subpath) {
subpath = await prompt( subpath = await client.input.text({
client, message: `Specify the URL subpath where the bug occurs:`,
`Specify the URL subpath where the bug occurs:` validate: val => (val ? true : 'A subpath must be provided'),
); });
} }
output.spinner('Retrieving deployments…'); output.spinner('Retrieving deployments…');
@@ -335,15 +341,3 @@ function getCommit(deployment: Deployment) {
deployment.meta?.bitbucketCommitMessage; deployment.meta?.bitbucketCommitMessage;
return { sha, message }; return { sha, message };
} }
async function prompt(client: Client, message: string): Promise<string> {
// eslint-disable-next-line no-constant-condition
while (true) {
const val = await client.input.text({ message });
if (val) {
return val;
} else {
client.output.error('A value must be specified');
}
}
}

View File

@@ -63,14 +63,11 @@ export default async function add(
envTargets.push(envTargetArg); envTargets.push(envTargetArg);
} }
while (!envName) { if (!envName) {
envName = await client.input.text({ envName = await client.input.text({
message: `Whats the name of the variable?`, message: `Whats the name of the variable?`,
validate: val => (val ? true : 'Name cannot be empty'),
}); });
if (!envName) {
output.error('Name cannot be empty');
}
} }
const { envs } = await getEnvRecords( const { envs } = await getEnvRecords(
@@ -100,11 +97,9 @@ export default async function add(
if (stdInput) { if (stdInput) {
envValue = stdInput; envValue = stdInput;
} else { } else {
const inputValue = await client.input.text({ envValue = await client.input.text({
message: `Whats the value of ${envName}?`, message: `Whats the value of ${envName}?`,
}); });
envValue = inputValue || '';
} }
while (envTargets.length === 0) { while (envTargets.length === 0) {
@@ -124,10 +119,9 @@ export default async function add(
envTargets.length === 1 && envTargets.length === 1 &&
envTargets[0] === 'preview' envTargets[0] === 'preview'
) { ) {
const inputValue = await client.input.text({ envGitBranch = await client.input.text({
message: `Add ${envName} to which Git branch? (leave empty for all Preview branches)?`, message: `Add ${envName} to which Git branch? (leave empty for all Preview branches)?`,
}); });
envGitBranch = inputValue || '';
} }
const type = opts['--sensitive'] ? 'sensitive' : 'encrypted'; const type = opts['--sensitive'] ? 'sensitive' : 'encrypted';

View File

@@ -40,17 +40,11 @@ export default async function rm(
let [envName, envTarget, envGitBranch] = args; let [envName, envTarget, envGitBranch] = args;
while (!envName) { if (!envName) {
const inputName = await client.input.text({ envName = await client.input.text({
message: `Whats the name of the variable?`, message: `Whats the name of the variable?`,
validate: val => (val ? true : 'Name cannot be empty'),
}); });
if (!inputName) {
output.error(`Name cannot be empty`);
continue;
}
envName = inputName;
} }
if (!isValidEnvTarget(envTarget)) { if (!isValidEnvTarget(envTarget)) {

View File

@@ -75,64 +75,37 @@ export default async function inputProject(
if (shouldLinkProject) { if (shouldLinkProject) {
// user wants to link a project // user wants to link a project
let project: Project | ProjectNotFound | null = null; let toLink: Project;
await client.input.text({
while (!project || project instanceof ProjectNotFound) {
const projectName = await client.input.text({
message: 'Whats the name of your existing project?', message: 'Whats the name of your existing project?',
}); validate: async val => {
if (!val) {
if (!projectName) { return 'Project name cannot be empty';
output.error(`Project name cannot be empty`);
continue;
} }
const project = await getProjectByIdOrName(client, val, org.id);
output.spinner('Verifying project name…', 1000);
try {
project = await getProjectByIdOrName(client, projectName, org.id);
} finally {
output.stopSpinner();
}
if (project instanceof ProjectNotFound) { if (project instanceof ProjectNotFound) {
output.error(`Project not found`); return 'Project not found';
} }
} toLink = project;
return true;
return project; },
});
return toLink!;
} }
// user wants to create a new project // user wants to create a new project
let newProjectName: string | null = null; return await client.input.text({
while (!newProjectName) {
newProjectName = await client.input.text({
message: `Whats your projects name?`, message: `Whats your projects name?`,
default: !detectedProject ? slugifiedName : undefined, default: !detectedProject ? slugifiedName : undefined,
validate: async val => {
if (!val) {
return 'Project name cannot be empty';
}
const project = await getProjectByIdOrName(client, val, org.id);
if (!(project instanceof ProjectNotFound)) {
return 'Project already exists';
}
return true;
},
}); });
if (!newProjectName) {
output.error(`Project name cannot be empty`);
continue;
}
output.spinner('Verifying project name…', 1000);
let existingProject: Project | ProjectNotFound;
try {
existingProject = await getProjectByIdOrName(
client,
newProjectName,
org.id
);
} finally {
output.stopSpinner();
}
if (existingProject && !(existingProject instanceof ProjectNotFound)) {
output.print(`Project already exists`);
newProjectName = null;
}
}
return newProjectName;
} }