-
Notifications
You must be signed in to change notification settings - Fork 8.3k
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
[indexPatterns/create] refactor time field options #11996
[indexPatterns/create] refactor time field options #11996
Conversation
In elastic#11409 we added two new possible options to the "Time Filter field name" drop down. These options display a message that "there are no time fields" or "I don't want to use the time filter". These new options were added to the `controller.timeFields` array, which is where the other options were, but they don't actually represent time fields. This change updates `controller.timeFields` to instead be a list of options, specifically created to populate the select box in the view. They are now stored at `controller.timeFieldOptions` and each option has a `display` property (which will be rendered in the view) and optionally a `fieldName` property. The selected option, `controller.formValues.timeFieldOption` is checked for a `fieldName` property when the Index Pattern is created.
…s/time-field-options
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks like a bit of a bug here... not sure what to suggest as a solution, though. Looks like this branch also needs a rebase.
let fetchFieldsError; | ||
let dateFields; | ||
const getTimeFieldOptions = () => { | ||
this.timeFieldOptions = null; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
f3f827d
to
df95118
Compare
b4d98a3
to
887e451
Compare
ffcaf46
to
e3eb3bc
Compare
Doing this prevents the time field from rendering as invalid when there is no matching index pattern (which has it's own error display) and when the fields are loading
e3eb3bc
to
8d1df86
Compare
@cjcenizal @ycombinator I fixed the bugs, cleaned up the rest of the renaming, and isolated the side effects in |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Awesome! Functionality looks like it's still the same as what currently exists, and the code is much clearer. I especially love the new comments. Just had some questions and small polish-level suggestions.
return; | ||
} | ||
|
||
const restoredOption = findTimeFieldOption(prevOption); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we add a brief comment to describe these 3 lines?
// Persist the selected state in the UI.
} | ||
return this.timeFieldOptions.find(option => ( | ||
// comparison is not done with _.isEqual() because options get a unique | ||
// `$$hashKey` tag attached to them by ng-repeat |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
❤️ this comment
const pickDefaultTimeFieldOption = () => { | ||
const noOptions = this.timeFieldOptions.length === 0; | ||
const fieldOptions = this.timeFieldOptions.filter(option => !!option.fieldName); | ||
const infoOptions = this.timeFieldOptions.filter(option => !option.fieldName); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What are infoOptions? Maybe it's better to describe these as dummyOptions
or nonSelectableOptions
to make their role clearer in this context?
And instead of deriving this state based on fieldName
, can we just add a isSelectable: true
property to the options on line 82? This would make the logic clearer:
const selectableOptions = this.timeFieldOptions.filter(option => option.isSelectable);
const nonSelectableOptions = this.timeFieldOptions.filter(option => !option.isSelectable);
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Well, they're all selectable, they are really just "non-field" options. I could use that as the description rather than "info" if you think that's better.
const noOptions = this.timeFieldOptions.length === 0; | ||
const fieldOptions = this.timeFieldOptions.filter(option => !!option.fieldName); | ||
const infoOptions = this.timeFieldOptions.filter(option => !option.fieldName); | ||
const tooManyOptions = fieldOptions.length > 1 || infoOptions.length > 1; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What does it mean for there to be too many options? Too many to do what? Wondering if there's a name that can communicate the intent more clearly.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Too many options to choose the default. If there are multiple field or nonField options then we can't pick the default, the user has to choose something... open to suggestions for better names.
@cjcenizal I tried to clear up the intention behind the variables in |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks great overall! Just some minor questions/suggestions!
this.timeFieldOptions = []; | ||
this.timeFieldOptionsError = null; | ||
this.formValues.timeFieldOption = null; | ||
getTimeFieldOptions() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there a reason we aren't opting for the async/await
pattern here and using promises directly?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unfortunately, async
/await
don't work with angular for the same reason we can't use the global Promise
contrustor :/ See #11632 (comment)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Interesting. Why doesn't a simple $scope.$apply()
not work in those scenarios? Or is that not recommended for other reasons?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Manually triggering digest cycles is really a last resort option and can lead to all sorts of issues even in simple apps.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's interesting. I'd love to hear more about this. Do you have any resources and/or past github issues around this?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm very interested in understanding the concerns around explicit
$scope.$apply()
. I've always considered it a valid option and the best option when dealing with async operations outside of angular.
I agree that sometimes $scope.$apply()
is the only way to inter-op between angular and non-angular async contexts. My concern is that using $scope.$apply()
when not absolutely necessary leads to trouble. We did this in Kibana 3 and ended up with more than a couple PR's that just converted a $scope.$apply()
to if (!$scope.$$phase) $scope.$apply()
because things started to get out of hand. I'm just saying that if we can avoid using $scope.$apply()
(like by using native angular promises) then I think we should.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree, we should avoid using $apply where possible. I think the way to do that is to follow a few simple rules:
- Prefer Angular DOM event handlers like
ng-click
over event handlers (like$(element).on('click', () => {})
andelement.addEventListener('click', () => {})
.' - Prefer Angular wrappers around global objects like
$document
and$window
. - Prefer Angular wrappers around async APIs like
$q
,$timeout
, and$interval
.
There are probably a few others we can dig up by Googling but the general theme is to just use whatever tools Angular makes available which will tie into the $digest cycle under the hood. These are situations where $apply is inappropriate.
This leaves us with the situations where $apply is appropriate: anywhere these tools can't be used. I think if we can confidently assess a call stack and state that it will never touch any of these tools, and therefore never trigger a $digest cycle on its own, then we can safely use $apply. Thoughts @spalger and @chrisronline ?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@cjcenizal by that logic there are no function in this PR that qualify unless we reimplement some core services. All of the asynchronicity in this controller is introduced by the indexPattern.mapper
, savedObjects
, and esAdmin
services, all of which use angular async/http libraries.
If you're suggesting that we write more code that is completely disconnected from angular, that only inter-ops with angular at the last minute using something like $scope.$apply()
or wrapping a non angular promise with $q.resolve()
then I misunderstood you because I am totally on board with that. Unfortunately we have to adapt a lot of services to not use angular async/http first, or do something (module, app, component) greenfield and use non-angular libraries for everything from async to http.
My stance in this discussion so far was against using angular services in a non-angular context (like an async function() {}
that uses the indexPattern.mapper
, or esAdmin
).
This specific pr is even worse in my opinion, converting any of the helpers to async function(){}
s would be creating non-angular contexts between angular services and other angular contexts (Angular controller -> non-angular async function() {}
-> angular services). As you stated, this is something we should probably avoid.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
All of the asynchronicity in this controller is introduced by the indexPattern.mapper, savedObjects, and esAdmin services, all of which use angular async/http libraries.
Then I guess we can't use $apply. :)
My stance in this discussion so far was against using angular services in a non-angular context (like an async function() {} that uses the indexPattern.mapper, or esAdmin).
I see, sorry, I didn't pick up on that earlier. I thought we were just talking about how to use $apply correctly.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@spalger I think that's an excellent point and something I wasn't considering fully. You're right in that I'm advising to add a layer from angular that needs to go back into angular and that's probably not a good idea unless there is a really good reason. I'm happy with the discussion around this and I 100% agree that we should try and decouple our business logic away from our angular implementation. With that said, I'm fine with the code as-is and appreciate the thoughts!
this.timeFieldOptionsError = null; | ||
this.formValues.timeFieldOption = null; | ||
getTimeFieldOptions() | ||
.then(results => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Feels like a good place for object destructuring: .then({ options, error } =>
this.formValues.nameIsPattern | ||
? this.formValues.nameInterval.name | ||
: undefined; | ||
const id = name; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe we can just rename the variable in the above destructing assignment instead?
const {
name: id,
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, I had it like that at one point, but found it easier to read when all of the values passed to sendCreateIndexPatternRequest()
were defined in the same way (rather than using destructuring-rename for one and not others)
|
||
const notExpandable = | ||
!this.formValues.expandable && this.canExpandIndices() | ||
const timeFieldName = timeFieldOption && timeFieldOption.fieldName; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This variable name makes me think it's a string but the code indicates it's a boolean?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It ends up being the field name or false, which isn't what I want... good catch
// Only event-time-based index patterns set an intervalName. | ||
const intervalName = (nameIsPattern && nameInterval) | ||
? nameInterval.name | ||
: undefined; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why undefined
instead of false
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In my eyes false
represents "no", and undefined
/null
represent "no value" (with slightly different semantics depending on who you ask).
This is trying to represent "no intervalName", so I don't think false
is appropriate.
const updateFieldListAndSetTimeField = (results, timeFieldName) => { | ||
updateFieldList(results); | ||
const pickDefaultTimeFieldOption = () => { | ||
const noOptions = this.timeFieldOptions.length === 0; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we should try and use pure functions as much as possible, so in this case, can we simply use an argument timeFieldOptions
instead of relying on this.
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is another thing I tried and reverted, mostly because of the ambiguity caused by the existence of multiple timeFieldOptions
in scope, and it's strong dependence on the structure of the shape of timeFieldOptions
. If we moved this function out of the controller module along with the rest of the timeFieldOptions
methods I don't think it would be ambiguous, but that seems like a much larger change than necessary.
That said, this function intentionally doesn't produce side effects so that it's pure by some definitions.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd actually be in favor of that decision (to move it outside of the controller). Yes it doesn't produce side effects and that might be the more important aspect of pure functions but it still does rely on scoped state when it doesn't need to. I have no desire to hold up the review for this particular topic, as I'm just trying to understand how everyone feels about this
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
strong dependence on the structure of the shape of timeFieldOptions
By this I mean to say that every time this.timeFieldOptions
is modified in the future this function and its logic needs to be considered. Unfortunately, passing the timeFieldOptions
in as an argument doesn't change that but, IMO, creates an illusion of decoupling.
Example:
Say I wanted to add a property to each timeFieldOption
I would probably track down where they are created, then where they are stored, and then where they are used. In order to know that this function uses the this.timeFieldOptions
array I would first have to find the location that reads this.timeFieldOption
and then passes it to this function because pickDefaultTimeFieldOption()
's dependence on this.timeFieldOptions
is essentially hidden. If instead I leave this function accessing this.timeFieldOption
directly it's obvious that it is dependent.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yea that makes sense, and I don't think there is any decoupling happening here. There is and will always be a hard dependence, but I don't think the changes I'm suggesting make that coupling any less obvious, as when you're doing a search for this.timeFieldOptions
, you'd see it passed into the function.
But as a pure function, you can:
- Easily unit test as you're not dependent on any scoped state, or instance data to setup or tear down
- Easily refactor the internals with zero risk of side effects
- Easily migrate to a new file
- Easily refactor/rename outside scoped/instanced variables without worrying about them affecting the internals of the function (you just might need to update the name of the provided parameter)
- Easily document/explain the purpose of the function in the context of input and output
IMO, it seems like an easy win to strive for pure as much as possible. It's very possible that I'm not seeing a part of this that makes it harder or undesired and open to listening to other perspectives but the more codebases that I've worked in and the longer I've been writing code, the more I'm thinking that pure functions are a win/win across the board.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree with @chrisronline's reasoning here. I did some similar extraction as part of https://github.com/elastic/kibana/pull/11285/files#diff-053c72f0b419832d5edb39bd81422e06R1 and it made the controller logic easier to follow because it was higher level.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm okay with this not going in right now. I think I'm going to open a discussion issue about this in general to get some general thoughts and best practices that others have learned. I do think it's an important conversation but it doesn't have to happen in this PR
}); | ||
}) | ||
.then( | ||
fields => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there a reason this is on a newline and not:
})
.then(fields =>
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So it lines up with the errorback below. With this version of the implementation it's not necessary to use a single .then()
, so I'll switch it to .then().catch()
{ | ||
display: $translate.instant('KIBANA-NO_DATE_FIELD_DESIRED') | ||
}, | ||
...dateFields.map(field => ({ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
❤️
@chrisronline @cjcenizal thanks for all the awesome feedback, ready for round three! 💃 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🏄 LGTM! I think it's worth exploring @chrisronline's suggestions about pure-function-extraction and async/await + $apply.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM!
* [indexPatterns/create] timeFields -> timeFieldOptions In #11409 we added two new possible options to the "Time Filter field name" drop down. These options display a message that "there are no time fields" or "I don't want to use the time filter". These new options were added to the `controller.timeFields` array, which is where the other options were, but they don't actually represent time fields. This change updates `controller.timeFields` to instead be a list of options, specifically created to populate the select box in the view. They are now stored at `controller.timeFieldOptions` and each option has a `display` property (which will be rendered in the view) and optionally a `fieldName` property. The selected option, `controller.formValues.timeFieldOption` is checked for a `fieldName` property when the Index Pattern is created. * [indexPatterns/create] populate `this.timeFieldOptions` * [indexPatterns/create] unify fetching and errors for timeFieldOptions * [indexPatterns/create] refreshFieldList() -> refreshTimeFieldOptions() * [indexPatterns/create] unify "No time fields available" messages * [indexPatterns/create] disable the timeField select when loading * [indexPatterns/create] only require a time field is there are options Doing this prevents the time field from rendering as invalid when there is no matching index pattern (which has it's own error display) and when the fields are loading * [indexPatterns/create] isolate side-effects * [indexPatterns/create] let refreshTimeFieldOptions() control model * [indexPatterns/create] attempt to make default field selection clearer * [indexPatterns/create] explain why we set timeFieldOption * [indexPatterns/create] make timeFieldName `undefined` when not set * [indexPatterns/create] Don't pass two args to .then() unless needed * 💅 (cherry picked from commit 801549c)
* [indexPatterns/create] timeFields -> timeFieldOptions In elastic#11409 we added two new possible options to the "Time Filter field name" drop down. These options display a message that "there are no time fields" or "I don't want to use the time filter". These new options were added to the `controller.timeFields` array, which is where the other options were, but they don't actually represent time fields. This change updates `controller.timeFields` to instead be a list of options, specifically created to populate the select box in the view. They are now stored at `controller.timeFieldOptions` and each option has a `display` property (which will be rendered in the view) and optionally a `fieldName` property. The selected option, `controller.formValues.timeFieldOption` is checked for a `fieldName` property when the Index Pattern is created. * [indexPatterns/create] populate `this.timeFieldOptions` * [indexPatterns/create] unify fetching and errors for timeFieldOptions * [indexPatterns/create] refreshFieldList() -> refreshTimeFieldOptions() * [indexPatterns/create] unify "No time fields available" messages * [indexPatterns/create] disable the timeField select when loading * [indexPatterns/create] only require a time field is there are options Doing this prevents the time field from rendering as invalid when there is no matching index pattern (which has it's own error display) and when the fields are loading * [indexPatterns/create] isolate side-effects * [indexPatterns/create] let refreshTimeFieldOptions() control model * [indexPatterns/create] attempt to make default field selection clearer * [indexPatterns/create] explain why we set timeFieldOption * [indexPatterns/create] make timeFieldName `undefined` when not set * [indexPatterns/create] Don't pass two args to .then() unless needed * 💅
In #11409 we added two new possible options to the "Time Filter field name" drop down. These options display a message that "there are no time fields" or "I don't want to use the time filter". These new options were added to the
controller.timeFields
array, which is where the other options were, but they don't actually represent time fields.Summary of updates:
controller.timeFields
is now a list of options, specifically created to populate the select box in the view. They are now stored atcontroller.timeFieldOptions
and each option has adisplay
property (which will be rendered in the view) and optionally afieldName
property. The selected option,controller.formValues.timeFieldOption
is checked for afieldName
property when the Index Pattern is created.controller.fetchFieldsError
is nowcontroller.timeFieldOptionsError
controller.refreshFieldList()
is nowcontroller.refreshTimeFieldOptions()