Skip to content

Conversation

@AlexChung1995
Copy link
Contributor

For my use case, I needed to know when page.select succeeded to alter the form vs when page.select failed. A case where page.select fails is when the option is not present. I think this use case may be common and it could be a good addition to puppeteer! This solution may be too case specific though as page.select may fail under other conditions.

@googlebot
Copy link

Thanks for your pull request. It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

📝 Please visit https://cla.developers.google.com/ to sign.

Once you've signed, please reply here (e.g. I signed it!) and we'll verify. Thanks.


  • If you've already signed a CLA, it's possible we don't have your GitHub username or you're using a different email address. Check your existing CLA data and verify that your email is set on your git commits.
  • If your company signed a CLA, they designated a Point of Contact who decides which employees are authorized to participate. You may need to contact the Point of Contact for your company and ask to be added to the group of authorized contributors. If you don't know who your Point of Contact is, direct the project maintainer to go/cla#troubleshoot.
  • In order to pass this check, please resolve this problem and have the pull request author add another comment and the bot will run again.

@AlexChung1995
Copy link
Contributor Author

I signed it!
Build errors follow expected behaviour of the altered code but do not follow expected behaviour of the original.

@googlebot
Copy link

CLAs look good, thanks!

@aslushnikov
Copy link
Contributor

aslushnikov commented Oct 20, 2017

Could you please add a test?

@alixaxel, any concerns about this PR?

@alixaxel
Copy link
Contributor

@aslushnikov since we can handle the error based on the message, I don't see any downsides. I would probably replace indexOf with includes since Chromium supports it, but other than that looks good. 👍

@AlexChung1995
Copy link
Contributor Author

I added a test @aslushnikov . I'm unfamiliar with writing tests so you should probably go over what I wrote, but it passed. I did not delete the original failing tests with my most recent commit.

Copy link
Contributor

@alixaxel alixaxel left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@AlexChung1995 The logic is reversed and needs to be fixed, also some linting is in order.

Check out CONTRIBUTING.md, you will want to run:

npm run lint
npm run doc

You should also update the API docs, to document the newly added error.

https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md#pageselectselector-values

lib/Page.js Outdated
throw new Error('Option does not exist');
}
if (!options.some(function(v) {return values.indexOf(v.value) >= 0;})) throw new Error('Option does not exist.');

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@AlexChung1995 You need to get rid of these trailing spaces.

lib/Page.js Outdated
if (!options.some(function (v) {return values.indexOf(v.value) >= 0;})){
throw new Error('Option does not exist');
}
if (!options.some(function(v) {return values.indexOf(v.value) >= 0;})) throw new Error('Option does not exist.');
Copy link
Contributor

@alixaxel alixaxel Oct 21, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could write a more concise/readable version using arrow functions and Array.includes:

if (!options.some(option => values.includes(option.value)))
  throw new Error('Option does not exist.');

But I'm still not sure of the correctness of this logic, I will comment shortly.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct me if I'm wrong, but I read this code as follows:

For every <select> option, try to find a corresponding entry in provided `values`.
If any of the options can't be matched, throw.

This is fundamentally different from what it should be doing:

For every provided `values`, try to find a corresponding entry in <select> options.
If any of the `values` can't be matched, throw.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the logic is equivalent. The code just attempts to match some value to some option in both cases. I think for readability, your version makes more sense with the expected error output so I will change it.

}));



Copy link
Contributor

@alixaxel alixaxel Oct 21, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Getting rid of these 2 extra empty lines would be nice.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, should I remove the old tests that fail now?

… removed tests that do not fit the new behaviour
Copy link
Contributor

@alixaxel alixaxel left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@AlexChung1995 I have a question: now that we enforce values to exist on populated options, how are we able to deselect potentially previously selected options? Before, sending any non-existing value would have this effect, but now that functionality is gone.

Do you have any suggestions? I'm thinking that having special semantics for null or false would be a way to achieve the same.

lib/Page.js Outdated

const options = Array.from(element.options);

if (!values.some(value => options.some(option => option.value === value)))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this needs to use values.every() instead of values.some() to handle variadic inputs.

Maybe also an additional test would be helpful, since it's not so trivial to reason about.

Copy link
Contributor Author

@AlexChung1995 AlexChung1995 Oct 22, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suppose it would depend on the intended behaviour. I was more concerned about whether at least one change is made to the select element. With values.every(), the behaviour becomes concerned about whether all intended changes are made. If we use values.every() we would be required, I think, to include a list of options that were not selected with the error message.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with having a special semantic for null would be a good way to include the old functionality of deselecting options. If I figure out a better way I will implement it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm of the opinion that, if we are doing these checks, then they should have the same semantics for one or several elements. Or better yet, follow the existing heuristic: if the select has the multiple attribute set, check all provided values exists within the options, otherwise only check the first.

If your goal is to only know how many options were changed/selected, I would prefer a different approach where we don't throw and instead just return an int for the total number of selections made.

Perhaps @aslushnikov can weight in on this, since it's quite subjective.

Copy link
Contributor Author

@AlexChung1995 AlexChung1995 Oct 22, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the function can be considered to fail if it does not change the select element so would throw an error. That was why I thought throwing an error might be a good idea.

A return value is also a good solution.If the function was to return a value, perhaps an array of all selected elements would be the best. For the deselected functionality, the function could then return an empty array.

I find the heuristic of, on a select element without the multiple property set to true, assigning the element the first value in the provided array kind of counter intuitive. I could see two cases where a developer might provide multiple values to a select element that takes on only one value.
1.) the developer believes the select element can actually take on multiple values. If you believe this case is how the function will be used, I think it should throw an error because the developers intended usage is not what will actually happen.
2.) the developer provides multiple values, some of which may not be options, and wants the select element to set the first value that is an option.

What do you think? @aslushnikov @alixaxel

Copy link
Contributor

@aslushnikov aslushnikov left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @AlexChung1995 @alixaxel, I like the API.

Is there any way I can deselect all options with this API?

docs/api.md Outdated

Triggers a `change` and `input` event once all the provided options have been selected.
If there's no `<select>` element matching `selector`, the method throws an error.
If no value in the provided values array matches any `<option>` element, the method throws an error.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do i understand correctly, that you now return all the selected options instead of throwing an error? can we add this to the doc?

*/
async select(selector, ...values) {
await this.$eval(selector, (element, values) => {
return await this.$eval(selector, (element, values) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: let's update jsdoc's return

lib/Page.js Outdated
}
element.dispatchEvent(new Event('input', { 'bubbles': true }));
element.dispatchEvent(new Event('change', { 'bubbles': true }));
return Promise.all(filteredValues);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Promise.all doesn't make sense to me here: there are no promises inside the array

lib/Page.js Outdated
option.selected = values.includes(option.value);
} else {
element.value = values.shift();
filteredValues.splice(1);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if we want to return values that are selected, i'd prefer generating them from options.

// ... 
return options.filter(option => option.selected).map(option => option.value);

test/test.js Outdated
}));

it('should work with no options', SX(async function() {
it('should throw', SX(async function() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: please elaborate when exactly it should throw

@AlexChung1995
Copy link
Contributor Author

@aslushnikov @alixaxel I updated the docs, added some tests, and made some small changes. This solution retains the old functionality where you can clear selected values by inputting nothing to the values parameter.

lib/Page.js Outdated
if (element.multiple) {
for (const option of options)
option.selected = values.includes(option.value);
option.selected = filteredValues.includes(option.value);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand why you need filteredValues. This line can be rewritten as the following:

option.selected = values.includes(option.value);

lib/Page.js Outdated
option.selected = filteredValues.includes(option.value);
} else {
element.value = values.shift();
element.value = filteredValues[0];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

... and this line can be just

// explicitly assign `undefined` to state that passing in empty array resets selection.
element.value = values.length ? values[0] : undefined;

Copy link
Contributor Author

@AlexChung1995 AlexChung1995 Oct 27, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought it would be nice if the function would select the first value in values that also exists in options. I can do it in one loop like this if youd prefer.

element.value = undefined;
for (var i = values.length - 1; i>=0; i--){
    for (var option of options){
        if (option.value === values[i]){
            option.selected = true;
            break;
        }
    }
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought it would be nice if the function would select the first value in values that also exists in options. I can do it in one loop like this if youd prefer.

Can you please clarify why do you want this? I don't think this is of much importance, so I'd rather have code as simple as possible.

Copy link
Contributor Author

@AlexChung1995 AlexChung1995 Oct 27, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought that was the only way it would make sense to pass multiple values to this function when the found select element only takes one value. You could create an array of values, order them by priority, and the function would select the value that exists with highest priority.

Otherwise, I think passing multiple values into this function when the select element only takes one value is a logical flaw. When someone just sets the select element to be the first value, if there is no such value in options it deselects everything. So you would have asked the function to select multiple options, but it actually deselects every option.

It does increase code complexity, so I understand if you disagree.

  async select(selector, ...values) {
    return await this.$eval(selector, (element, values) => {
      if (element.nodeName.toLowerCase() !== 'select')
        throw new Error('Element is not a <select> element.');
      const options = Array.from(element.options);
      element.value = undefined;
      for (let i = values.length - 1; i >= 0; i--){
          for (const option of options){
              if (option.value === values[i]){
                  option.selected = true;
                  break;
              }
          }
      }
      element.dispatchEvent(new Event('input', { 'bubbles': true }));
      element.dispatchEvent(new Event('change', { 'bubbles': true }));
      return options.filter(option => option.selected).map(option => option.value);
    }, values);
  }

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@AlexChung1995 Wouldn't the following achieve the same effect you're proposing?

async select(selector, ...values) {
  return await this.$eval(selector, (element, values) => {
    if (element.nodeName.toLowerCase() !== 'select')
      throw new Error('Element is not a <select> element.');

    const options = Array.from(element.options);

    for (const option of options) {
      option.selected = values.includes(option.value);

      if ((option.selected === true) && (element.multiple === false)) {
        break;
      }
    }

    element.dispatchEvent(new Event('input', { 'bubbles': true }));
    element.dispatchEvent(new Event('change', { 'bubbles': true }));

    return options.filter(option => option.selected).map(option => option.value);
  }, values);
}

Copy link
Contributor Author

@AlexChung1995 AlexChung1995 Oct 28, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried that implementation and realized that it prioritizes by the order of the options and not the order of the values. I tried iterating from the start of the values array but thought the code was ugly. For example:

element.value = undefined;
for (const value of values) {
    const option = options.find(option => option.value === value);
    if (option){
        option.selected = true;
        if (!element.multiple)
            break;
    }
}

If you let me know which of the 3 versions you would prefer (original, prioritize by value, prioritize by option), Ill update the code and commit so we can wrap up this pull request!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@AlexChung1995 thanks, I understand now your intentions here.

Otherwise, I think passing multiple values into this function when the select element only takes one value is a logical flaw.

I agree. We shouldn't encourage this, so I'd avoid giving any special treatment to the multiple values passed in for a non-multiple select.

Given this, I like the second suggestion with dropped if-statement:

async select(selector, ...values) {
  return await this.$eval(selector, (element, values) => {
    if (element.nodeName.toLowerCase() !== 'select')
      throw new Error('Element is not a <select> element.');

    const options = Array.from(element.options);
    for (const option of options) 
      option.selected = values.includes(option.value);

    element.dispatchEvent(new Event('input', { 'bubbles': true }));
    element.dispatchEvent(new Event('change', { 'bubbles': true }));
    return options.filter(option => option.selected).map(option => option.value);
  }, values);
}

It's dummy, which is nice.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, Ill do another commit. Since this is your solution @alixaxel I assume you're ok with it?
Thanks @alixaxel @aslushnikov for your help.

Copy link
Contributor Author

@AlexChung1995 AlexChung1995 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Build failed but not due to my changes. I guess my version of puppeteer and the tests is not completely up to date.

} else {
element.value = values.shift();
}
element.value = undefined;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: do we need this?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, it fails tests otherwise.

*/
async select(selector, ...values) {
await this.$eval(selector, (element, values) => {
return await this.$eval(selector, (element, values) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: can you please update jsdoc on line 775? it should have the "return" statement

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Like this?
- returns: <[Promise]<[Array]<[string]>>> Returns an array of option values that have been successfully selected.

I put that in there but its on line 928. Line 775 in my api.md file refers to Page.goForward().

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean the jsdoc for this function (that block comment with @param tags inside)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh i see

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

where can i find documentation about the !, ? and | modifiers for jsdoc? I dont know what they do.

Copy link
Contributor

@aslushnikov aslushnikov Oct 31, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@AlexChung1995 indeed. Briefly speaking, the "!" is a non-nullable type, the "?" is a nullable type. More info could be found here: https://github.com/google/closure-compiler/wiki/Annotating-JavaScript-for-the-Closure-Compiler

@aslushnikov
Copy link
Contributor

aslushnikov commented Nov 1, 2017

@AlexChung1995 thanks! I've addressed a nit here, I hope you don't mind.

I'll land this as soon as CI cycles.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants