Query composition api
We should be able to compose queries together. My concerns are on the API at the moment and not implementation. There are 2 specific problems that I want to solve: 1) easily augmenting an existing query and 2) nesting queries.
Problem 1: Augmenting existing queries
Our current query implementation requires that you specify all of the parameters when the query is created like so:
field = text_field("My Text Field", visible: false)
users = css(".user", count: 3)
grace = css(".user", count: 1, text: "Grace Hopper")
If you want to write a test that ensures that an element has been hidden you have to do something like this:
session
|> assert_has(css(".user", text: "Grace", visible: true))
|> click(button("Hide User"))
|> assert_has(css(".user", text: "Grace", visible: false))
Ideally we could extract the base "grace" query and change the visibility property of the query as needed. I think a nice api for doing this might be something like this:
grace = css(".user", text: "Grace")
session
|> assert_has(grace)
|> click(button("Hide User"))
|> assert_has(hidden(grace))
I'm not sure how I feel about extending the query api with semantically meaningful names like hidden over using something like visible(Query.t, boolean()) :: Query.t. I think the later might be less surprising and easier to maintain. But the point stands that I think we should provide a set of functions in Query that allow us to change specific attributes of the provided query. This would also allow us to define queries using pipes like so:
grace =
css(".user")
|> text("Grace")
|> visible(true)
I don't think that we need to deprecate our existing api to support this. We can discuss that if people feel strongly about it.
Problem 2: Nesting Queries
Our queries don't allow you to nest them. For instance if you want to find a text field inside a specific form you have to do that with calls to find like so:
form = css(".form")
field = text_field("User Name")
session
|> find(form, fn f -> fill_in(f, field, with: "Grace") end)
This can lead to race conditions if the form element changes. It breaks the declarative nature of the api. I propose that we should support nesting at the query level. My initial thought is to do something like this:
form = css(".form")
field = text_field("User Name")
session
|> fill_in(within(form, field), with: "Value")
I'm not sure about the within syntax. Perhaps something like child or child_of is better. I'm also not sure if flipping the arguments to something like field |> within(form) reads better. Very open to suggestions here.
Conclusion
I'd love feedback on either of these issues. Both of these are going to be important for making #378 ergonomic.
Adding my thoughts here. Hope they're helpful.
Problem 1: Augmenting existing queries
- I think the piping looks great.
css(".class") |> text("Grace") |> visible(true) - I prefer the
visible(Query.t, boolean) :: Query.tapproach to thehiddenone. I think it strikes the right balance of adding composition but also it maps 1-1 to the name of the option passed in, so it's easy to remember. - I like the existing api, and I find it useful to not always have to use the pipe operator. So I'd be in favor of not deprecating it.
Problem 2: Nesting queries
This one was harder for me. I think my conflicts between within(form, field) vs within(field, form) stem from where it is used.
If I use it inline with the fill_in function, then I prefer how within(form, field) reads,
form = css(".form")
field = text_field("User Name")
session
|> fill_in(within(form, field), with: "Value")
But if I tend to define my queries outside of the fill_in, then I think having the arguments reversed reads better because of the pipe operator,
form = css(".form")
nested_field = text_field("User Name") |> within(form)
session
|> fill_in(nested_field, with: "Value")
Ultimately, both within(form, field) and within(field, form) read a little strange to me depending on where they're used. But I do like the word within over child or child_of.
An alternative?
An alternative I was thinking of (but not sure if it's any better) is to have the form be passed as an option. This may go contrary to what types of arguments should be passed as options, so it might not be the best idea.
form = css(".form")
field = text_field("User Name", within: form)
session
|> fill_in(field, with: "Value")
And if you combine this with the augmenting queries of part 1, you could also write it like this,
form = css(".form")
field = text_field("User Name") |> within(form)
session
|> fill_in(field, with: "Value")
which ends up looking identical to the within(field, form) when using the pipe operator, but it also allows for inlining in the fill_in function more nicely,
form = css(".form")
session
|> fill_in(text_field("User Name", within: form), with: "Value")
Hope those help!
My main concern with within is that it'll be impossible to remember the order. I need to think about the idea of passing a within option. I wonder if we should come up with some other name like nested_under since it would explain the order that the arguments need to be passed in.
I've split off everything in problem 1 since its unrelated to the composition or nesting of queries. We can work on nesting as a separate task.
Yeah, I think you bring a good point that within makes the order of the arguments ambiguous. I have been mulling over the nested_under, and it's been growing on me. I especially like the nested part since I think it's closer to the language people use when talking about html structure.
I also think it helps with the ambiguity but it doesn't completely remove it. At least, it's not super clear to me whether you would expect it to be nested_under(form, field) or nested_under(field, form). My guess is that it would be the latter, since one could write it as field |> nested_under(form)?
I've been thinking about this more. I think we want to use something like nested_under or nested_in or child_of. The arguments should be nested_under(my_query, the_parent). The reason is because if we were to do nested_under(the_parent, my_query) then this would 1) not chain correctly and 2) increase the "depth" of the query. Let me see if I can illustrate what I mean:
Lets say that we have text field inside a form. If the composition accepts the parent first and the child second then we end up with something like this:
tf =
form
|> parent_of(text_field("name"))
Besides being a bit more annoying to read I think this is bad because its conceptually difficult to understand whats being returned from parent_of. We start with a form and get a text field out of the other end. I find this pretty counter-intuitive and other parts of wallaby's api have suffered from this in the past.
If we flip the ordering then the conceptual model makes sense:
name_field =
text_field("Name")
|> nested_under(form)
If we want to continue chaining other modifiers to this then we're safe to do that without any cognitive overhead:
name_field =
text_field("Name")
|> nested_under(form)
|> visible(true)
|> count(1)
I think we still need to pick a good name. Maybe nested_under is the right way to go. But I like this property of the api.
Yeah, I'm 100% with you in terms of that property of the api. I really like how well that last bit that you wrote composes,
name_field =
text_field("Name")
|> nested_under(form)
|> visible(true)
|> count(1)
As for the actual name nested_under, I too am unsure. I tried thinking of other names, but the only things I can come up with (aside from nested_in) are contained_in and included_in. I'm not sure those are better, but I'm putting them here in case you like one of them.
After thinking about it a bit I think we should go with child_of. I think thats the most semantically correct word. It communicates what the relationship is between these two queries.
My only concern with child_of is that it might be confusing since a "child" css selector is the immediate descendant, not just any descendant. I know the query struct is not tied to the "child" css selector, but since it's sort of in the same domain, I wonder if it might convey that meaning. That's not the aim of this query though, is it?
Ah I see what you're saying...ok, nested_under is the best alternative that I've come up with so far. I suppose we could make it more explicit by saying nested(selector, under: parent) which would help indicate the arguments to people.
Sorry I didn't come back here to respond sooner!
I think nested_under is pretty good, though I do like the notion of child or descendant, since that's how the W3 spec talks about the content model.
A normative description of what content must be included as children and descendants of the element.
Since child has the issues of being the immediate descendant, maybe one of the following?
# 1
descendant_of(selector, parent)
selector
|> descendant_of(parent)
# or 2
descendant(selector, of: parent)
selector
|> descendant(of: parent)
They're more verbose, but maybe that's fine?