Skip to content

[Mappings editor] Search fields#54241

Merged
sebelga merged 52 commits intoelastic:feature/mappings-editorfrom
sebelga:mappings-editor/search-fields
Jan 14, 2020
Merged

[Mappings editor] Search fields#54241
sebelga merged 52 commits intoelastic:feature/mappings-editorfrom
sebelga:mappings-editor/search-fields

Conversation

@sebelga
Copy link
Contributor

@sebelga sebelga commented Jan 8, 2020

This PR adds the "search field" capability to the mappings editor.

Goal

The search functionality goal is for a user to be able to find a field and either "Edit" it or "Remove" it.
It is not to add children or expand the field and see its children. Not that we could not add those features later, but this was not the goal of this first iteration.

Performance

I made some performance tests and rendering +500 results was very slow (for example searching for "long" in our huge metricbeat mappings) so I decided to use a VirtualList that only render a few elements in the DOM in the visible area. And the result is an extremely fast search result list.

It is not yet "perfect" but I think good enough (and also I wanted people's feedback as quickly as possible 😊) until I keep improving it with the following outstanding items:

  • Request good copy text
  • Render the tree representation of a parent inside a tooltip
  • Optimize the RegEx expression for search
  • Optimize the "highlight" (bold) of the search term in the result
  • See if it's possible to have a "View in editor" button on the search item that links back to the tree, with all the parents expanded and the correct page scroll position

I contemplated using an in-memory search engine (https://github.com/nextapps-de/flexsearch) instead of writing our own search algorithm. As a first version, I think we should be able to get around without it but I am curious to see what improvement we could get out of it.

Screen Shot 2020-01-08 at 16 55 21

Screen Shot 2020-01-08 at 16 55 39

@sebelga sebelga added Feature:Mappings Editor Index mappings editor UI release_note:skip Skip the PR/issue when compiling release notes Team:Kibana Management Dev Tools, Index Management, Upgrade Assistant, ILM, Ingest Node Pipelines, and more t// labels Jan 8, 2020
@elasticmachine
Copy link
Contributor

Pinging @elastic/es-ui (Team:Elasticsearch UI)

Copy link
Contributor

@jloleysens jloleysens left a comment

Choose a reason for hiding this comment

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

Great work so far @sebelga ! This is going to be a super useful improvement!! 🙂

Your initial take was different from what I envisioned for the search so I tried to expand on my thoughts a bit in the comments I left.

Additionally, a different approach to the UX could be to render only the search paths in a special kind of "EuiComboBox" (does not have to be this component specifically) which we only render when the user starts typing. Once the user finds the key they were looking for we can take them to that item in the tree. Wdyt of this kind of alternative? I understand this poses some other UX challenges like:

  • when we navigate into the tree do we collapse everything else and expand only the required path?
  • how do we get the user back to the search bar at the top of the current page if the content is really long?

But these are probably solvable in different ways (I think).

A merit of the current approach is that we are treating the current rendered elements on the page as the main search result but this has the added complexity make the tree need to know quite a bit about search. The alternative I'm thinking of would have search as a separate component and have the mappings editor only be able to respond to be "controlled" in its expanded/collapsed states.

Let me know what you think!

const matchStartOfPath = fieldData.path.startsWith(term);
const fullyMatchPath = term === fieldData.path;
const matchType = fieldData.type.includes(typeToCompare);
const fullyMatchType = typeToCompare === fieldData.type;
Copy link
Contributor

Choose a reason for hiding this comment

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

Given that this is called n times (the number of times this is called grows with the tree's nodes). This checking logic can be condensed a bit using String.prototype.indexOf

From the result we can figure out:

  • whether the term matches at all
  • whether the term matches the start
  • whether the term matches the entire string (length of term and data)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Not sure I follow, it would be replacing this:

const matchStartOfPath = fieldData.path.startsWith(term);

with this

const indexMatch = fieldData.path.indexOf(term);
const matchStartOfPath = indexMatch === 0;

And then repeat for fieldData.type. Not sure if there is any gain (apart adding lines). For the "whether the term matches the entire string" I just compare both strings. Am I missing something?

Copy link
Contributor

@jloleysens jloleysens Jan 9, 2020

Choose a reason for hiding this comment

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

So it's this line:

term === fieldData.path

That I think potentially does more work than if we used String.prototype.indexOf and just checked for indexMatch === 0.

So not massive gains, it's this that I am thinking of: https://stackoverflow.com/a/26554820

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Cool, thanks. I will look into it 👍

};

const getRegexFromArray = (array: string[]): RegExp[] => {
const termsRegexArray = array.map(value => new RegExp(value, 'i'));
Copy link
Contributor

Choose a reason for hiding this comment

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

We may want to consider applying something like https://www.npmjs.com/package/escape-string-regexp here

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Cool, I'll keep it in mind if we start having more regex string to escape. 👍 For now, we only escape 2 chars in the file so it might be overkill.

const getRegexFromArray = (array: string[]): RegExp[] => {
const termsRegexArray = array.map(value => new RegExp(value, 'i'));
const fuzzyJoinChar = '[_\\.-\\s]?';
const fuzzySearchRegexArray = getSubArrays(array).map(
Copy link
Contributor

Choose a reason for hiding this comment

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

It looks like what this section of the search does is:

Take inputs like [ "some", "terms", "here" ] then returns [ [ "some", "terms" ], [ "terms", "here" ] ]. These are converted into regex like /some[_\.-\s]?terms/ but this does not preserve the fuzzy search behaviour of implementations like in VSCode x-file search (activated, on mac, with the cmd+p shortcut). Where one can enter someterms and it would match a file name + path like /test/data/some/another/terms.

In the example of this data:

https://gist.githubusercontent.com/cjcenizal/09837195a7b1f2ba1447aa4d67859497/raw/c8a9e541d3797adea2a6e89327f860a8263be5ab/metricbeat_index_template

I would have expected a term like aerospiken to match

Screenshot 2020-01-08 at 15 22 29

whereas, in the current implementation, it does not match anything. I think building an ever long, single, RegExp as the user enters data will exponentially increase the specificity of the term provided (for each char added) and so dramatically decrease the size of the search pool.

For example, change L125 to:

    const result = fieldData.path.match(new RegExp(term.split('').join('.*?')));
    if (result) {
      stringMatch = result[0];
    }

yielded this behaviour (sorting is totally wrong of course 😂)

Screenshot 2020-01-08 at 15 31 48

In combination with this, I would say that the search term box should specifically only match path and then we can filter by type in a separate area (since all types are well enumerated).

Copy link
Contributor

@cjcenizal cjcenizal Jan 8, 2020

Choose a reason for hiding this comment

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

++ I really like these ideas.

Copy link
Contributor Author

@sebelga sebelga Jan 9, 2020

Choose a reason for hiding this comment

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

Thanks for the suggestion @jloleysens !

Although this super fuzzy regex brings its own problems too 😊 as you can see here

Screen Shot 2020-01-09 at 15 19 02

This is the result with the current algorithm, which IMO is more accurate

Screen Shot 2020-01-09 at 15 23 25

Welcome to Elasticsearch engineers problems! 😄

Like I mentioned in the PR description, I will try to improve the RegEx to get the best result possible. But we might hit a wall and if we need the precision of a VSCode, use a (tiny) library like flexsearch (especially to not re-invent the wheel).

I am not too worried about your "aerospiken" example as, if the user has written (not copy/paste) "aerospike" he will realize that adding the last "n" empties the results.

Copy link
Contributor

Choose a reason for hiding this comment

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

Although this super fuzzy regex brings its own problems too 😊 as you can see here

I think once we sort results shortest to longest (in path length) we will get a UX very close to what VS Code has :) .

I'm not convinced we need a more sophisticated search tool here (like a library). I think we will very quickly see diminishing returns for more sophisticated tools for search. An imperative version of the algo I have in mind something like this:

https://github.com/talmobi/node-fzf/blob/4ee7fbc1d90ef61e7d04b423dff4faac2bd969a5/src/index.js#L214

...will realize that adding the last "n" empties the results

Hmm, I think this is probably true in the current implementation. I guess we need to decide which experience would be more/less effort for users to learn/intuit and how we present the search box. Because if they tried aerospiken and got no results we should not present this as fuzzy search IMO.

Copy link
Contributor

@cjcenizal cjcenizal left a comment

Choose a reason for hiding this comment

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

Good work @sebelga! I left a few comment but nothing major. I think it's more important to refine the search behavior and UX (thoughts below).

Search behavior

Currently, matching a field with descendants returns the descendants as results. This feels strange to me, because I've found the field I was searching for, but it's obscured by all of its descendants and I have to hunt around for it.

image

Tangentially, it looks like if I enter a greater-than, this is treated as a match, but it shouldn't be.

image

UX

I really like @jloleysens's suggestion:

Additionally, a different approach to the UX could be to render only the search paths in a special kind of "EuiComboBox" (does not have to be this component specifically) which we only render when the user starts typing. Once the user finds the key they were looking for we can take them to that item in the tree.

This mirrors the search behavior of SublimeText, where selecting a search result opens the file and highlights its position in the file navigator (expanding all its ancestors). I especially like this idea because it addresses all of my concerns from #53863 (comment). It will also complement the search behavior I propose above, because you can click on an object field that matches your search to see it in the tree, and then easily expand it to inspect and manipulate its descendants.

I think the actual presentation of the search results (e.g. inside a combo box or not) is less important than the interaction of clicking on a result and being taken to its position in the tree. I would be fine with leaving everything pretty much as it currently is in the search UI if we could implement this behavior.

}

export const CodeBlock = ({ children, padding = 'normal' }: Props) => (
<div className="euiCodeBlock euiCodeBlock--fontSmall euiCodeBlock--paddingLarge">
Copy link
Contributor

Choose a reason for hiding this comment

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

Even though the CSS that EUI exports is technically a contract, we don't have a mechanism in place for detecting a breaking change except through vigilant observance of the EUI release notes, which is very prone to human error. Can we use the EuiCodeBlock component instead and request changes to the component for any new functionality we require?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

In its current state, the EuiCode throws an error if we pass anything different than a string. We indeed need to open a PR in EUI to add the functionality, but for the time constraint that we have, this is the temporary solution to get the PR working and merged (we also use it to display our fields Tree component when deleting a field with warning).

What do you think about tackling all those (TODOs in our code) during our last cycle week?

isValid,
validate,
submitForm: form.submit,
},
Copy link
Contributor

Choose a reason for hiding this comment

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

I appreciate the improved clarity of being explicit here!

placeholder="Search fields"
value={searchValue}
onChange={e => onSearchChange(e.target.value)}
aria-label="Search mapped fields"
Copy link
Contributor

Choose a reason for hiding this comment

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

This needs i18n.

<EuiFlexItem grow={false}>
<EuiFieldSearch
style={{ minWidth: '350px' }}
placeholder="Search fields"
Copy link
Contributor

Choose a reason for hiding this comment

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

This needs i18n.

{field.path.map((path, i) =>
i < field.path.length - 1 ? (
<span key={i}>
{path} <EuiIcon type="sortRight" />{' '}
Copy link
Contributor

@cjcenizal cjcenizal Jan 8, 2020

Choose a reason for hiding this comment

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

This change doesn't feel like it's within the scope of the search feature. Could we remove it and discuss it separately? I have similar concerns as JL.

EDIT: OK I see this is taken from #54146. Confusing!

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is the complexity of working in parallel PR that depend on one another. I needed the changes of #54146 to continue my work here so I created 1 commit out of it and cherry-picked here.

This article https://jg.gg/2018/09/29/stacked-diffs-versus-pull-requests/ has been waiting for a year in my Chrome tabs that I get some time to give StackedDiff a try and maybe remove the confusion you had if it's too cool to be true 😊

</p>
}
actions={
<EuiButtonEmpty onClick={clearSearch}>
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 we use a regular EuiButton here, to give it an outline and make it more like a button and less like a link?

<EuiEmptyPrompt
iconType="search"
title={
<h1>
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 should be an h3 since it falls beneath the "Mappings (optional)" title in terms of hierarchy, and that's an h2.


const loadJson = () => {
const isValidJson = jsonContent.current!.validate();
if (jsonContent.current === undefined) {
Copy link
Contributor

Choose a reason for hiding this comment

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

What do the changes in this file do? Are they germane to search?

Copy link
Contributor Author

@sebelga sebelga Jan 9, 2020

Choose a reason for hiding this comment

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

No, but I discovered a bug (Clicking "Load" in the Json modal without making any changes) and fixed it here. Thanks for pointing it out though, we might need to port this change on a separate PR.

validateIndexPattern: () => ({}),
}));

const unRelevantProps = {
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: irrelevantProps might be more accurate?

...state,
fields: updatedFields,
// If we have a search in progress, we reexecute the search to
// update or result array
Copy link
Contributor

Choose a reason for hiding this comment

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

Thanks for adding these comments! I did spot a typo here and below: or -> our

@sebelga
Copy link
Contributor Author

sebelga commented Jan 13, 2020

Thanks for the updated review @cjcenizal I will look into them tomorrow, today I put all my energy in having the ability to "reveal in editor" and a "back to result" button.

I also made the "Search field" sticky, which is a better UX if we work with very large mappings.

For the algorithm, I agree that it could be improved (I made some changes between Friday and today and I don't know if your review is based before I made those changes let me know), but at this stage, I would not make big changes in it. I do agree it could be improved and should deserve some time.

The fact that we can write "keyword" and have all the keyword field is a feature IMO and not a bug., I would call it "smart search" 😊 Write either a field name or a field type, we will find it. That's the idea behind it.

Screen Shot 2020-01-13 at 15 31 27

Screen Shot 2020-01-13 at 15 31 39

Screen Shot 2020-01-13 at 15 31 51

@sebelga
Copy link
Contributor Author

sebelga commented Jan 13, 2020

Loading JSON doesn't affect the search results

I think we should keep it simple. Whenever we load the mappings we reset the search and its result.

Loading an empty JSON object doesn't reset everything to default

It seems like a bug on the "Load JSON" functionality (not related to search). I will look into it tomorrow. You mean that if we only load a mappings "properties", the old advanced configuration are kept, is that it?

Copy link
Contributor

@jloleysens jloleysens left a comment

Choose a reason for hiding this comment

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

Re-tested and am happy to address feedback above at a later stage. There is one fix I think is worth doing here and that is the mappings editor currently breaks when entering a search term with regex groups/classes (e.g., aero[). I've opened this PR: sebelga#14 which should fix it.

<h3>
<FormattedMessage
id="xpack.idxMgmt.mappingsEditor.searchResult.emptyPromptTitle"
defaultMessage="No fields found"
Copy link
Contributor

Choose a reason for hiding this comment

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

Gail suggested we change this to "No fields match your search" and remove the body prop.

image

<EuiFlexItem grow={false}>
<EuiButtonEmpty onClick={revealInEditor} data-test-subj="revealInEditorButton">
{i18n.translate('xpack.idxMgmt.mappingsEditor.searchResult.revealInEditorButtonLabel', {
defaultMessage: 'Reveal in editor',
Copy link
Contributor

Choose a reason for hiding this comment

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

Gail requested we change this to "Show in editor".

}

.mappingsEditor__fieldsListItem__field--selected {
background-color: $euiColorLightShade;
Copy link
Contributor

@cjcenizal cjcenizal Jan 14, 2020

Choose a reason for hiding this comment

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

Can we change this to tintOrShade($euiFocusBackgroundColor, 30%, 0%) to align with the table? This component uses the $euiTableSelectedColor, but that's not available so we'll have to use its implementation instead.

image

image

@kibanamachine
Copy link
Contributor

💔 Build Failed

History

To update your PR or re-run it, just comment with:
@elasticmachine merge upstream

@sebelga
Copy link
Contributor Author

sebelga commented Jan 14, 2020

@cjcenizal As discussed over Zoom these are the changes I made on the PR

  • Revert the "sticky" behavior of the search box
  • Revert the "Reveal in editor" from the search result (and thus removed the "Back to results" button)
  • Made the copy text improvement as suggested by Gail
  • Improve the search algorithm that should address most of your recommendations (multi-terms search narrows the search, better sort results). I kept the possibility to enter a field "type" and get all the matching fields. This match always comes after a field name or path match.
  • Clear the search and its result whenever we load a new JSON

I think those address most of your concerns. I will merge the PR once CI is green. If there is anything else we can look at it in a separate PR.

@sebelga
Copy link
Contributor Author

sebelga commented Jan 14, 2020

Merging as the CI failure is not related to this PR.

@sebelga sebelga merged commit 95a1da1 into elastic:feature/mappings-editor Jan 14, 2020
@sebelga sebelga deleted the mappings-editor/search-fields branch January 14, 2020 10:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Feature:Mappings Editor Index mappings editor UI release_note:skip Skip the PR/issue when compiling release notes Team:Kibana Management Dev Tools, Index Management, Upgrade Assistant, ILM, Ingest Node Pipelines, and more t//

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants