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

Support multi-select in parameters #3952

Merged
merged 32 commits into from
Aug 4, 2019
Merged

Support multi-select in parameters #3952

merged 32 commits into from
Aug 4, 2019

Conversation

gabrieldutra
Copy link
Member

@gabrieldutra gabrieldutra commented Jul 4, 2019

What type of PR is this? (check all applicable)

  • Feature

Description

This one is to add an option to allow multiple values in Dropdown parameters (see preview below :)).

  • Make 'quote each value' an option (and let the user customize the separator?)
  • Apply this to Query based Dropdown parameters
  • Styling updates (select maxWidth, etc)
  • Add tests
  • Refresh Schedule

Related Tickets & Documents

Closes #3053

Mobile & Desktop Screenshots/Recordings (if there are UI changes)

Dropdown
multi-select-enum

Query Based Dropdown
multi-select-query

@gabrieldutra gabrieldutra self-assigned this Jul 4, 2019
@gabrieldutra
Copy link
Member Author

There are currently two places that invoke mustache_render, one is the ParametizedQuery, and the other is this task:

if query.options and len(query.options.get('parameters', [])) > 0:
query_params = {p['name']: p.get('value')
for p in query.options['parameters']}
query_text = mustache_render(query.query_text, query_params)
else:
query_text = query.query_text

The List logic is in the ParametizedQuery, so I was thinking about turn the above to use it instead of a direct call to mustache_render. This way the ParametizedQuery would centralize logic related to Queries with parameters 😁.

As I'm not that familiar with the backend, where would you put this resolve_parameter_values logic @arikfr or @rauchy, is the Parametized Query model a good place?

@ranbena
Copy link
Contributor

ranbena commented Jul 5, 2019

Since this component's dimensions could be as high and wide as the content it holds, we should limit it so layout doesn't break. Here are some suggestions:

CSS limits

{
  max-width: 350px;
  max-height: 69px;
  overflow: auto;
}

or Ant options (I think it's better):

<Select
  maxTagCount="3"
  maxTagTextLength="10"
  maxTagPlaceholder={num => `+${num.length} more`}
/>

@arikfr
Copy link
Member

arikfr commented Jul 5, 2019

This way the ParametizedQuery would centralize logic related to Queries with parameters 😁.

Sounds like a good idea 👍 But probably worth waiting to hear what @rauchy thinks.

or Ant options (I think it's better):

Sounds like the better option. Not sure we should change the default, btw. And we should reuse this for multi-filters as well (but in a separate PR).

Copy link
Member Author

@gabrieldutra gabrieldutra left a comment

Choose a reason for hiding this comment

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

Thanks for the comments!

@rauchy, I'll finish with the backend part + add some tests, ping you back when done :)

@gabrieldutra gabrieldutra force-pushed the multi-select-parameters branch from 80be362 to 293a83c Compare July 9, 2019 21:54
@gabrieldutra
Copy link
Member Author

gabrieldutra commented Jul 10, 2019

Taking another step and considering Serialization Settings I wanted to make it custom (separator, prefix and suffix to each value options at least backend-wise for future support to any UI). While exploring options I realized there is another case that will need to be handled:

When running adhoc queries (or running a modified unsaved existing one) there is no information about parameters (only their values), thus no information about the Serialization options. As it's a "trusted" route for queries I think I'll just replicate serialization logic in the frontend and send the value as string for such cases.

An alternative is to always serialize this on frontend and have a "reverse operation" to validate the values in backend based on settings.

Edit:
The reverse operation is actually more interesting if I can reliably recreate the array, would be sth like:

regex = "(?:{}|^)(?:{})(.*?)(?:{})(?={}|$)".format(re.escape(separator), re.escape(prefix),
                                                   re.escape(suffix), re.escape(separator))
if not re.match('^(' + regex +')*$', value):
     return False
values = re.findall(regex, value)
return set(map(unicode, values)).issubset(set(dropdown_options))

@arikfr WDYT? (I couldn't think of a simpler way to do that without regex)

@arikfr
Copy link
Member

arikfr commented Jul 11, 2019

Taking another step and considering Serialization Settings I wanted to make it custom (separator, prefix and suffix to each value options at least backend-wise for future support to any UI)

In what cases one would want different prefix and suffix?

I'm not sure if at this stage we should let the user control the separator anyway, but we can have a setting for it just in case without visible UI.

Also, if the user picks quotes they need to pick the escaping form. Most SQL implementation escape a single quote by double it (select '''' returns '). But in other cases you might want to prefix with a slash.

An alternative is to always serialize this on frontend and have a "reverse operation" to validate the values in backend based on settings.

The regex seems to work, but this is tricky and can lead to various issues down the road.

Maybe instead we can just skip validation in such case (after confirming the user has access to the data source), like we do with dropdowns in general?

@gabrieldutra
Copy link
Member Author

In what cases one would want different prefix and suffix?

I have no idea, but could be a use case at some point, my plan was to have it only on backend for now 😅. The same for the separator.

For UI I was thinking about showing quote options in a Select.

The regex seems to work, but this is tricky and can lead to various issues down the road.

That was sort of my concern too, a Regex that takes strings on it doesn't seem to be something that reliable.

Maybe instead we can just skip validation in such case (after confirming the user has access to the data source), like we do with dropdowns in general?

For such cases that you only have the parameter values the validation is already skipped the problem is that you'd only have the list of values, not information on how to serialize data.

As the validation is already skipped I think the best option will be to replicate the serialization logic on frontend and just send it as String.

@gabrieldutra
Copy link
Member Author

@arikfr, @ranbena this is what I was thinking for the Parameter Settings Dialog, you can give your comments 🙂

multi-enum


Also, if the user picks quotes they need to pick the escaping form.

Why is that 🤔? Isn't the purpose of the quotes to allow scenarios like this one:
multi-query
?

@ranbena
Copy link
Contributor

ranbena commented Jul 16, 2019

Very nice @gabrieldutra. I have a few wording suggestions.

  • None (default)
  • Single quotation mark
  • Double quotation mark

Screen Shot 2019-07-16 at 12 22 00

The hint at the bottom is essential to understanding the feature and its result.
A small change to the sentence:

Screen Shot 2019-07-16 at 12 25 01

Copy link
Member Author

@gabrieldutra gabrieldutra left a comment

Choose a reason for hiding this comment

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

Notes

  • I wanted to let options available on Backend (prefix, suffix and separator), even though not showed in UI just to handle possible future cases;
  • I tried to handle parameter issues when changing their settings, the result is a more complex Parameter Structure (query.js file), which will be even more complex after Add Dynamic Values to Date and Date Range Parameters #3904. A refactor for that is on my plans once both are merged;
  • I want to add Cypress tests here too, but it's probably better to wait Parameter “Apply Changes” button #3907 to avoid rework.

Caveats

  • Needed to replicate the logic for the List Join on frontend for cases where the Parameter schema is not present (joinListValue = true). In that case value is sent as String as in backend validation is already skipped (it's a "trusted" route).

@@ -38,7 +38,7 @@ function ParametersDirective($location) {
EditParameterSettingsDialog
.showModal({ parameter })
.result.then((updated) => {
scope.parameters[index] = extend(parameter, updated);
scope.parameters[index] = extend(parameter, updated).setValue(updated.value);
Copy link
Member Author

@gabrieldutra gabrieldutra Jul 16, 2019

Choose a reason for hiding this comment

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

This makes Parameter settings edits safer. setValue seems to be the centered place to process parameter values, so when a parameter changes it either adapts the value or sets it to empty.

Copy link
Contributor

Choose a reason for hiding this comment

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

Cool cool cool

disabled={enumOptionsArray.length === 0}
defaultValue={value}
value={value}
Copy link
Member Author

Choose a reason for hiding this comment

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

Otherwise the changes from setValue wouldn't affect here.

Other inputs remain the same as is, one need is to see the behavior after #3907.

@@ -105,7 +126,7 @@ def apply(self, parameters):
raise InvalidParameterError(invalid_parameter_names)
else:
self.parameters.update(parameters)
self.query = mustache_render(self.template, self.parameters)
self.query = mustache_render(self.template, join_parameter_list_values(parameters, self.schema))
Copy link
Member Author

Choose a reason for hiding this comment

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

Needed to provide the schema to get the prefix, suffix and separator options.

@gabrieldutra gabrieldutra changed the title WIP: Support multi-select in parameters Support multi-select in parameters Jul 16, 2019
@arikfr
Copy link
Member

arikfr commented Jul 17, 2019

And no easy solution, for each data source is a different thing. This is probably together with "make parameters safe".

The easy solution in this case is just to let the user select from a few options. For some it might be a trial and error process to find the right setting, but it's a reasonable thing.

@@ -129,14 +129,14 @@ describe('Parameter', () => {
.find('.ant-select')
.click();

cy.contains('li.ant-select-dropdown-menu-item', 'value1')
cy.contains('li.ant-select-dropdown-menu-item', 'value2')
Copy link
Member Author

Choose a reason for hiding this comment

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

Needed to change to value2 since now I make sure the first dropdown option is selected when value is invalid or empty.

Copy link
Contributor

@ranbena ranbena left a comment

Choose a reason for hiding this comment

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

Great job. Works perfect.
Few code questions and suggestions.

prefix: '',
suffix: '',
separator: ',',
} : null })}
Copy link
Contributor

Choose a reason for hiding this comment

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

Since these are already set as the defaults in query.js:Parameters, perhaps no need for it here?

const separator = get(multiValuesOptions, 'separator', ',');
const prefix = get(multiValuesOptions, 'prefix', '');
const suffix = get(multiValuesOptions, 'suffix', '');

Copy link
Member Author

@gabrieldutra gabrieldutra Jul 18, 2019

Choose a reason for hiding this comment

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

In EditParameterSettingsDialog it's used to manage the Quotation Select value. I was actually a bit "paranoid" with this, there's one in the backend as well 😆. In any case: the query.js one is only triggered for "text query" cases, where the backend is not aware of these settings and serialization logic is made by the frontend (yep, the same logic is duplicated to workaround this scenario).

Should probably add a comment in there to mention that. Edit: comment added

@@ -22,6 +23,7 @@ export class ParameterValueInput extends React.Component {
enumOptions: PropTypes.string,
queryId: PropTypes.number,
parameter: PropTypes.any, // eslint-disable-line react/forbid-prop-types
allowMultipleValues: PropTypes.bool,
Copy link
Contributor

Choose a reason for hiding this comment

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

@gabrieldutra do you also feel this file (and perhaps query.js:Parameters) would be better off with some inheritance pattern (e.g. hoc, extends, ..)?

Copy link
Member Author

Choose a reason for hiding this comment

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

I feel like everything related to Parameters should be on an inheritance pattern. This included. This is all on the "Parameters Design" thought. Once both this and the Dynamic Values are merged I have a refactoring in mind.

@@ -80,3 +80,7 @@
}
}
}

.parameter-multi-select {
min-width: 195px;
Copy link
Contributor

Choose a reason for hiding this comment

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

Can you elaborate on the reasoning for this?

Copy link
Member Author

@gabrieldutra gabrieldutra Jul 18, 2019

Choose a reason for hiding this comment

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

It gets too short when it has no selected values. I let it with the same value as Text Parameters, LMK if you have other suggestions.

(Also should add a comment here in the code 😅) Edit: comment added

if (this.props.mode === 'multiple' && isArray(this.props.value)) {
const optionValues = map(options, option => option.value);
const validValues = intersection(this.props.value, optionValues);
if (validValues.length !== this.props.value.length) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Not sure what this means, can you explain or add a comment?

Copy link
Member Author

@gabrieldutra gabrieldutra Jul 18, 2019

Choose a reason for hiding this comment

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

In this case the if is equivalent to !isEqual, I'll just change to it as it's easier to understand 😅. Overall it checks if all values are valid (if there's any that's not on the options), in case the validValues list is not the same as the value provided, it forces selection of only the valid ones.

@@ -38,7 +38,7 @@ function ParametersDirective($location) {
EditParameterSettingsDialog
.showModal({ parameter })
.result.then((updated) => {
scope.parameters[index] = extend(parameter, updated);
scope.parameters[index] = extend(parameter, updated).setValue(updated.value);
Copy link
Contributor

Choose a reason for hiding this comment

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

Cool cool cool

const isEmptyValue = isNull(value) || isUndefined(value) || (value === '');
static getValue(param, joinListValue = false) {
const { value, type, useCurrentDateTime, multiValuesOptions } = param;
const isEmptyValue = isNull(value) || isUndefined(value) || (value === '') || (isArray(value) && value.length === 0);
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 is now exactly how _.isEmpty() works 😆

Copy link
Member Author

Choose a reason for hiding this comment

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

Almost there 😂, _.isEmpty considers Numbers as empty. It may be interesting to have the "Empty Parameter" rule explicit, though.

@@ -145,7 +145,7 @@ describe('Parameter', () => {
.find('.ant-select')
.click();

cy.contains('li.ant-select-dropdown-menu-item', 'value1')
cy.contains('li.ant-select-dropdown-menu-item', 'value2')
.click();
});
});
Copy link
Contributor

Choose a reason for hiding this comment

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

No new tests?

Copy link
Member Author

Choose a reason for hiding this comment

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

I was waiting for the Apply All 😝, I will add Cypress tests today.

@gabrieldutra
Copy link
Member Author

gabrieldutra commented Jul 22, 2019

Important note I forgot 😬: I didn't code the "Refresh Schedule" part, because I couldn't get Parameterized Queries to be scheduled (Python debugger + logs to track), I suspect this is a bug, but I'm not 100% aware of how it's supposed to work ^^ (if you schedule a parameterized query on preview it won't update the results).

@shenoyroopesh
Copy link

shenoyroopesh commented Jul 28, 2019

How could this work for selecting multiple dates as well?

@gabrieldutra
Copy link
Member Author

How could this work for selecting multiple dates as well?

Hi @shenoyroopesh 🙂, this will work for the existing Dropdown and Query Based Dropdown parameters. Dates won't be supported directly, but Query Based Dropdown parameters make any type of data possible with this workaround (with actual dates and not intervals). Unfortunately, it would still not be that convenient.

@arikfr
Copy link
Member

arikfr commented Jul 29, 2019

@shenoyroopesh can you share where you might want to select multiple dates but a date range won't be applicable? (just trying to understand the use case)

@@ -193,7 +193,7 @@ def refresh_queries():
if query.options and len(query.options.get('parameters', [])) > 0:
query_params = {p['name']: p.get('value')
for p in query.options['parameters']}
query_text = mustache_render(query.query_text, query_params)
query_text = query.parameterized.apply(query_params).query
Copy link
Member Author

Choose a reason for hiding this comment

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

Refresh schedule updated. Tested that by changing the outdated_queries method locally so all queries were outdated 🙂.

@gabrieldutra
Copy link
Member Author

@arikfr this one is ready to me, but it's worth to give a check on the backend part :)

@arikfr arikfr merged commit f0576a3 into master Aug 4, 2019
@arikfr arikfr deleted the multi-select-parameters branch August 4, 2019 12:47
@arikfr
Copy link
Member

arikfr commented Aug 4, 2019

🎉

harveyrendell pushed a commit to pushpay/redash that referenced this pull request Nov 14, 2019
* Allow multiple values for enum parameter

* Allow multi-select for Query dropdown parameters

* CR + make sure list values are allowed

* Add prefix, suffix and separator

* Rename multipleValues and cast options as strings

* Replicate serialization logic on frontend

* Add Quote Option Select

* Make sure it's enum or query before join

* Add a couple of tests

* Add help to quote option

* Add min-width and normalize empty array

* Improve behavior when changing parameter settings
- Set parameter value again to pass through checks
- Add setValue check for multi values

* Validate enum values on setValue + CodeClimate

* Ran wording suggestions

* Updates after Apply Changes

* Fix failing Cypress tests

* Make sure enumOptions exists before split

* Improve propTypes for QueyBasedParameterInput

Co-Authored-By: Ran Byron <[email protected]>

* CR

* Cypress: Test for multi-select Enum

* Fix multi-selection Cypress spec

* Update Refresh Schedule
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Support for multi select in parameters
5 participants