feat(filtered_search): Implement filtered search
This MR is for pajamasbuild part of the filtered search.
Target branch will be set to master
when !981 (merged) will be closed
Related issue: gitlab-org/gitlab-services/design.gitlab.com#272 (closed)
Road signs through this description:
-
⚠ This behavior is different from current non-Vue implementation -
⚙ Implementation details -
⏭ Things to be addressed in follow-up requests
Glossary:
- term - free-text value within filter (becomes input with suggestions when activated), used for creating other tokens
- token - current tokens, consisting of "kind" and "value" used in existing filtered searches
Overview
This MR introduces multiple elements:
-
filtered_search
component for token-agnostic filtering -
filtered_search_term
component for text input + creating other tokens via suggestions dropdown -
filtered_search_binary_token
component for implementing existing "two-parts" tokens - Suggestions module - helper components for suggestions implementation
-
filtered_search_suggestion_list
- wrapper component, responsible for handling suggestions logic (moving to next/previous suggestion, highlighting suggestions, registering and unregistering suggestion elements) -
filtered_search_suggestion
- wrapper aroundgl-dropdown-item
, which registers search suggestion in top-levelsuggestion_list
-
filtered_search_suggestions_control_mixin
- helper mixin to abstract suggestions logic and reuse it for tokens implementation
-
filtered_search_suggestion
theoretically can transparently wrap any element, but this requires abstract: true
, which is an undocumented feature in Vue 2.x
autocomplete
component (neither bootstrap nor bootstrap-vue do not provide one, but this is out of the scope of this MR)
Requirements
There are no design specs for filters for now, so these requirements were gathered based on existing filtered search implementations (used in issues):
General requirements
- User can type free text in the search field. This input is used to filter possible filter suggestions of possible tokens.
- Creating token is possible either by pressing it in suggestion list or selecting it using arrow keys + clicking Enter
- Some tokens (like "confidential: yes/no") should not appear in filters more than once, some tokens are ok and allowed to appear more than once (for example label token, allowing us to filter issues by more than one label simultaneously)
-
⏭ Component should be easily extended to support "not" filtering and other possible "complex" tokens in future - Any time there should be at least X px of extra space to the right (highlighted in pink), allowing user to add new tokens any time
- For existing tokens user should be able to edit token by clicking it
- For existing tokens user should be able to delete token by clicking the close icon
- When the user hits
Backspace
when existing token value is empty - it should be converted to term with the title of the current token
Space
handling
Honestly, this is an ugliest part of "requirements" and I really feel UX needs to be revised in these parts. This section basically documenting how our current filter work, so don't kill the messenger
- Tokens can't contain spaces,
Space
is used to proceed to new token - If token has unclosed quote
"
- space is allowed in token, till matching quote is found -
⚠ PressingSpace
at the beginning of any token has no effect and is ignored -
⚠ PressingSpace
in the middle of existing token value "completes" token with the value before space and creates new term with second word. This word is immediately activated -
⚠ Pasting a string containing spaces applies same logic - each word will be converted to term
Public API
<gl-filtered-search>
usage
Original intent is to allow people to use filtered tokens in the following way:
const tokens = [
{ type: 'label', icon: 'label', title: 'static:token', token: labelToken },
// ...
{ type: 'private', icon: 'rocket', title: 'dynamic:~token', token: privacyToken },
];
and in Vue template:
<gl-filtered-search v-model="value" :available-tokens="tokens" />
v-model
of filtered search is updated in real-time. This allows us to dynamically calculate available tokens, so we can implement complex logic, for example:
- ensure specific token is used no more than once
- adds the possibility to add "mutually exclusive tokens" - for example, either first or second should be available
submit
event provides slightly different normalized tokens structure 0 for example multiple terms in a row are merged, trailing empty term is removed.
Each token mentioned in token
field inside available-tokens
array is Vue component, which will be rendered inside filtered search. For now, we introduce two types of tokens:
- term token - just a plain input with autocomplete, used for creating other tokens
- binary-token token - current tokens, consisting of "kind" and "value" used in existing filtered searches
Tokens implementation
Each token is unaware of other tokens and is fully responsible for:
- rendering it's unactive state
- rendering input and focusing it when the token becomes
active
- rendering suggestions and navigating through them (with help of provided mixin, if needed)
- handling any token-specific logic (for example
label
token might remove labels, already selected in filters from suggestions - requesting self-destruction or "replacement" (for example
binary-token
when it has empty value andBackspace
is pressed replaces itself withterm
token with value ofbinary-token
title)
Implementation nuances
Portal
We want each token to be solely responsible for its suggestions
Token (yellow) wants to render suggestions (red). Unfortunately, tokens should be located inside the scrollable container (green) with overflow-x
, which makes us unable to render it properly (theoretically, we can use fixed
, but that creates even more problems). Bootstrap-vue
also uses portals underneath (portals will be part of Vue3 API), so actually we're not adding something really new to our code.
So filtered-search
provides <portal-target>
allowing any its descendant to "push" content into suggestions. Name of this <portal-target>
is published via context (see next section), aside with function alignSuggestions
in order to position suggestions correctly
1.x
and I feel like a huge overkill to introduce popper.js
with complex logic just to align item on one axis.
Context usage
bootstrap-vue
relies solely on CSS selectors and native browser focus + :focus
to display focused state and implement keyboard navigation for b-dropdown
. Unfortunately, our focus needs to remain inside the input, so we need to use a different approach.
One approach is just to pass list items as data and make <suggestions-list>
component to render them
<gl-filtered-search-suggestions :items="items">
template for each item
</gl-filtered-search-suggestions>
Problems of such approach:
- requires hacks to display, for example, for separators between different suggestion groups
- quickly bloats
suggestions
component for common use cases, for example some of the suggestions should always be available, some of them are loaded dynamically based on user input - requires hacks if certain list items require different styling (example: None, and Any in Assignee dropdown do not have avatars in existing solution)
Context API (provide
/inject
) is used instead. By using this API, we can nest specially crafted gl-filtered-search-suggestion
any deep inside gl-filtered-search-suggestion-list
. Since we're gathering available suggestions based on created
/beforeDestroy
hooks we can easily ensure that keyboard navigation will be up-to-date with token state
Known issues
-
⏭ a11y of this component is very bad. We need at least allow user either tab-jump from input to suggestions and select suggestion via keyboard or usearia-autocomplete
in a proper way