Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Support
Keyboard shortcuts
?
Submit feedback
Contribute to GitLab
Sign in / Register
Toggle navigation
G
gitlab-ce
Project overview
Project overview
Details
Activity
Releases
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Issues
0
Issues
0
List
Boards
Labels
Milestones
Merge Requests
1
Merge Requests
1
Analytics
Analytics
Repository
Value Stream
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Create a new issue
Commits
Issue Boards
Open sidebar
nexedi
gitlab-ce
Commits
c6cdef0e
Commit
c6cdef0e
authored
Jul 31, 2020
by
Kushal Pandya
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Add Label Token to use with Filtered Search Bar
Add LabelToken component to use within Filtered Search Bar.
parent
baf8fccc
Changes
7
Hide whitespace changes
Inline
Side-by-side
Showing
7 changed files
with
376 additions
and
7 deletions
+376
-7
app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js
...ts/vue_shared/components/filtered_search_bar/constants.js
+2
-0
app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue
...mponents/filtered_search_bar/filtered_search_bar_root.vue
+17
-0
app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue
...red/components/filtered_search_bar/tokens/label_token.vue
+128
-0
locale/gitlab.pot
locale/gitlab.pot
+3
-0
spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js
...ents/filtered_search_bar/filtered_search_bar_root_spec.js
+33
-6
spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js
...nd/vue_shared/components/filtered_search_bar/mock_data.js
+22
-1
spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js
...components/filtered_search_bar/tokens/label_token_spec.js
+171
-0
No files found.
app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js
View file @
c6cdef0e
export
const
ANY_AUTHOR
=
'
Any
'
;
export
const
NO_LABEL
=
'
No label
'
;
export
const
DEBOUNCE_DELAY
=
200
;
export
const
SortDirection
=
{
...
...
app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue
View file @
c6cdef0e
...
...
@@ -184,6 +184,21 @@ export default {
this
.
recentSearches
=
resultantSearches
;
});
},
/**
* When user hits Enter/Return key while typing tokens, we emit `onFilter`
* event immediately so at that time, we don't want to keep tokens dropdown
* visible on UI so this is essentially a hack which allows us to do that
* until `GlFilteredSearch` natively supports this.
* See this discussion https://gitlab.com/gitlab-org/gitlab/-/merge_requests/36421#note_385729546
*/
blurSearchInput
()
{
const
searchInputEl
=
this
.
$refs
.
filteredSearchInput
.
$el
.
querySelector
(
'
.gl-filtered-search-token-segment-input
'
,
);
if
(
searchInputEl
)
{
searchInputEl
.
blur
();
}
},
handleSortOptionClick
(
sortBy
)
{
this
.
selectedSortOption
=
sortBy
;
this
.
$emit
(
'
onSort
'
,
sortBy
.
sortDirection
[
this
.
selectedSortDirection
]);
...
...
@@ -217,6 +232,7 @@ export default {
// https://gitlab.com/gitlab-org/gitlab-foss/issues/30821
});
}
this
.
blurSearchInput
();
this
.
$emit
(
'
onFilter
'
,
filters
);
},
},
...
...
@@ -226,6 +242,7 @@ export default {
<
template
>
<div
class=
"vue-filtered-search-bar-container d-md-flex"
>
<gl-filtered-search
ref=
"filteredSearchInput"
v-model=
"filterValue"
:placeholder=
"searchInputPlaceholder"
:available-tokens=
"tokens"
...
...
app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue
0 → 100644
View file @
c6cdef0e
<
script
>
import
{
GlToken
,
GlFilteredSearchToken
,
GlFilteredSearchSuggestion
,
GlDropdownDivider
,
GlLoadingIcon
,
}
from
'
@gitlab/ui
'
;
import
{
debounce
}
from
'
lodash
'
;
import
createFlash
from
'
~/flash
'
;
import
{
__
}
from
'
~/locale
'
;
import
{
convertObjectPropsToCamelCase
}
from
'
~/lib/utils/common_utils
'
;
import
{
NO_LABEL
,
DEBOUNCE_DELAY
}
from
'
../constants
'
;
export
default
{
noLabel
:
NO_LABEL
,
components
:
{
GlToken
,
GlFilteredSearchToken
,
GlFilteredSearchSuggestion
,
GlDropdownDivider
,
GlLoadingIcon
,
},
props
:
{
config
:
{
type
:
Object
,
required
:
true
,
},
value
:
{
type
:
Object
,
required
:
true
,
},
},
data
()
{
return
{
labels
:
this
.
config
.
initialLabels
||
[],
loading
:
true
,
};
},
computed
:
{
currentValue
()
{
return
this
.
value
.
data
.
toLowerCase
();
},
activeLabel
()
{
// Strip double quotes
const
strippedCurrentValue
=
this
.
currentValue
.
includes
(
'
'
)
?
this
.
currentValue
.
substring
(
1
,
this
.
currentValue
.
length
-
1
)
:
this
.
currentValue
;
return
this
.
labels
.
find
(
label
=>
label
.
title
.
toLowerCase
()
===
strippedCurrentValue
);
},
containerStyle
()
{
if
(
this
.
activeLabel
)
{
const
{
color
,
textColor
}
=
convertObjectPropsToCamelCase
(
this
.
activeLabel
);
return
{
backgroundColor
:
color
,
color
:
textColor
};
}
return
{};
},
},
watch
:
{
active
:
{
immediate
:
true
,
handler
(
newValue
)
{
if
(
!
newValue
&&
!
this
.
labels
.
length
)
{
this
.
fetchLabelBySearchTerm
(
this
.
value
.
data
);
}
},
},
},
methods
:
{
fetchLabelBySearchTerm
(
searchTerm
)
{
this
.
loading
=
true
;
this
.
config
.
fetchLabels
(
searchTerm
)
.
then
(
res
=>
{
// We'd want to avoid doing this check but
// labels.json and /groups/:id/labels & /projects/:id/labels
// return response differently.
this
.
labels
=
Array
.
isArray
(
res
)
?
res
:
res
.
data
;
})
.
catch
(()
=>
createFlash
(
__
(
'
There was a problem fetching labels.
'
)))
.
finally
(()
=>
{
this
.
loading
=
false
;
});
},
searchLabels
:
debounce
(
function
debouncedSearch
({
data
})
{
this
.
fetchLabelBySearchTerm
(
data
);
},
DEBOUNCE_DELAY
),
},
};
</
script
>
<
template
>
<gl-filtered-search-token
:config=
"config"
v-bind=
"
{ ...$props, ...$attrs }"
v-on="$listeners"
@input="searchLabels"
>
<template
#view-token
="
{ inputValue, cssClasses, listeners }">
<gl-token
variant=
"search-value"
:class=
"cssClasses"
:style=
"containerStyle"
v-on=
"listeners"
>
~
{{
activeLabel
?
activeLabel
.
title
:
inputValue
}}
</gl-token>
</
template
>
<
template
#suggestions
>
<gl-filtered-search-suggestion
:value=
"$options.noLabel"
>
{{
__
(
'
No label
'
)
}}
</gl-filtered-search-suggestion>
<gl-dropdown-divider
/>
<gl-loading-icon
v-if=
"loading"
/>
<template
v-else
>
<gl-filtered-search-suggestion
v-for=
"label in labels"
:key=
"label.id"
:value=
"label.title"
>
<div
class=
"gl-display-flex"
>
<span
:style=
"
{ backgroundColor: label.color }"
class="gl-display-inline-block mr-2 p-2"
>
</span>
<div>
{{
label
.
title
}}
</div>
</div>
</gl-filtered-search-suggestion>
</
template
>
</template>
</gl-filtered-search-token>
</template>
locale/gitlab.pot
View file @
c6cdef0e
...
...
@@ -24158,6 +24158,9 @@ msgstr ""
msgid "There was a problem fetching groups."
msgstr ""
msgid "There was a problem fetching labels."
msgstr ""
msgid "There was a problem fetching project branches."
msgstr ""
...
...
spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js
View file @
c6cdef0e
import
{
shallowMount
}
from
'
@vue/test-utils
'
;
import
{
shallowMount
,
mount
}
from
'
@vue/test-utils
'
;
import
{
GlFilteredSearch
,
GlButtonGroup
,
...
...
@@ -16,13 +16,16 @@ import RecentSearchesService from '~/filtered_search/services/recent_searches_se
import
{
mockAvailableTokens
,
mockSortOptions
,
mockHistoryItems
}
from
'
./mock_data
'
;
const
createComponent
=
({
shallow
=
true
,
namespace
=
'
gitlab-org/gitlab-test
'
,
recentSearchesStorageKey
=
'
requirements
'
,
tokens
=
mockAvailableTokens
,
sortOptions
=
mockSortOptions
,
searchInputPlaceholder
=
'
Filter requirements
'
,
}
=
{})
=>
shallowMount
(
FilteredSearchBarRoot
,
{
}
=
{})
=>
{
const
mountMethod
=
shallow
?
shallowMount
:
mount
;
return
mountMethod
(
FilteredSearchBarRoot
,
{
propsData
:
{
namespace
,
recentSearchesStorageKey
,
...
...
@@ -31,6 +34,7 @@ const createComponent = ({
searchInputPlaceholder
,
},
});
};
describe
(
'
FilteredSearchBarRoot
'
,
()
=>
{
let
wrapper
;
...
...
@@ -54,13 +58,13 @@ describe('FilteredSearchBarRoot', () => {
describe
(
'
computed
'
,
()
=>
{
describe
(
'
tokenSymbols
'
,
()
=>
{
it
(
'
returns a map containing type and symbols from `tokens` prop
'
,
()
=>
{
expect
(
wrapper
.
vm
.
tokenSymbols
).
toEqual
({
author_username
:
'
@
'
});
expect
(
wrapper
.
vm
.
tokenSymbols
).
toEqual
({
author_username
:
'
@
'
,
label_name
:
'
~
'
});
});
});
describe
(
'
tokenTitles
'
,
()
=>
{
it
(
'
returns a map containing type and title from `tokens` prop
'
,
()
=>
{
expect
(
wrapper
.
vm
.
tokenTitles
).
toEqual
({
author_username
:
'
Author
'
});
expect
(
wrapper
.
vm
.
tokenTitles
).
toEqual
({
author_username
:
'
Author
'
,
label_name
:
'
Label
'
});
});
});
...
...
@@ -233,6 +237,14 @@ describe('FilteredSearchBarRoot', () => {
});
});
it
(
'
calls `blurSearchInput` method to remove focus from filter input field
'
,
()
=>
{
jest
.
spyOn
(
wrapper
.
vm
,
'
blurSearchInput
'
);
wrapper
.
find
(
GlFilteredSearch
).
vm
.
$emit
(
'
submit
'
,
mockFilters
);
expect
(
wrapper
.
vm
.
blurSearchInput
).
toHaveBeenCalled
();
});
it
(
'
emits component event `onFilter` with provided filters param
'
,
()
=>
{
wrapper
.
vm
.
handleFilterSubmit
(
mockFilters
);
...
...
@@ -260,13 +272,28 @@ describe('FilteredSearchBarRoot', () => {
expect
(
glFilteredSearchEl
.
props
(
'
historyItems
'
)).
toEqual
(
mockHistoryItems
);
});
it
(
'
renders search history items dropdown with formatting done using token symbols
'
,
async
()
=>
{
const
wrapperFullMount
=
createComponent
({
shallow
:
false
});
wrapperFullMount
.
vm
.
recentSearchesStore
.
addRecentSearch
(
mockHistoryItems
[
0
]);
await
wrapperFullMount
.
vm
.
$nextTick
();
const
searchHistoryItemsEl
=
wrapperFullMount
.
findAll
(
'
.gl-search-box-by-click-menu .gl-search-box-by-click-history-item
'
,
);
expect
(
searchHistoryItemsEl
.
at
(
0
).
text
()).
toBe
(
'
Author := @tobyLabel := ~Bug"duo"
'
);
wrapperFullMount
.
destroy
();
});
it
(
'
renders sort dropdown component
'
,
()
=>
{
expect
(
wrapper
.
find
(
GlButtonGroup
).
exists
()).
toBe
(
true
);
expect
(
wrapper
.
find
(
GlDropdown
).
exists
()).
toBe
(
true
);
expect
(
wrapper
.
find
(
GlDropdown
).
props
(
'
text
'
)).
toBe
(
mockSortOptions
[
0
].
title
);
});
it
(
'
renders dropdown items
'
,
()
=>
{
it
(
'
renders
sort
dropdown items
'
,
()
=>
{
const
dropdownItemsEl
=
wrapper
.
findAll
(
GlDropdownItem
);
expect
(
dropdownItemsEl
).
toHaveLength
(
mockSortOptions
.
length
);
...
...
spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js
View file @
c6cdef0e
import
Api
from
'
~/api
'
;
import
AuthorToken
from
'
~/vue_shared/components/filtered_search_bar/tokens/author_token.vue
'
;
import
LabelToken
from
'
~/vue_shared/components/filtered_search_bar/tokens/label_token.vue
'
;
import
{
mockLabels
}
from
'
jest/vue_shared/components/sidebar/labels_select_vue/mock_data
'
;
export
const
mockAuthor1
=
{
id
:
1
,
...
...
@@ -42,7 +45,18 @@ export const mockAuthorToken = {
fetchAuthors
:
Api
.
projectUsers
.
bind
(
Api
),
};
export
const
mockAvailableTokens
=
[
mockAuthorToken
];
export
const
mockLabelToken
=
{
type
:
'
label_name
'
,
icon
:
'
labels
'
,
title
:
'
Label
'
,
unique
:
false
,
symbol
:
'
~
'
,
token
:
LabelToken
,
operators
:
[{
value
:
'
=
'
,
description
:
'
is
'
,
default
:
'
true
'
}],
fetchLabels
:
()
=>
Promise
.
resolve
(
mockLabels
),
};
export
const
mockAvailableTokens
=
[
mockAuthorToken
,
mockLabelToken
];
export
const
mockHistoryItems
=
[
[
...
...
@@ -53,6 +67,13 @@ export const mockHistoryItems = [
operator
:
'
=
'
,
},
},
{
type
:
'
label_name
'
,
value
:
{
data
:
'
Bug
'
,
operator
:
'
=
'
,
},
},
'
duo
'
,
],
[
...
...
spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js
0 → 100644
View file @
c6cdef0e
import
{
mount
}
from
'
@vue/test-utils
'
;
import
{
GlFilteredSearchToken
,
GlFilteredSearchTokenSegment
}
from
'
@gitlab/ui
'
;
import
MockAdapter
from
'
axios-mock-adapter
'
;
import
waitForPromises
from
'
helpers/wait_for_promises
'
;
import
axios
from
'
~/lib/utils/axios_utils
'
;
import
createFlash
from
'
~/flash
'
;
import
LabelToken
from
'
~/vue_shared/components/filtered_search_bar/tokens/label_token.vue
'
;
import
{
mockRegularLabel
,
mockLabels
,
}
from
'
jest/vue_shared/components/sidebar/labels_select_vue/mock_data
'
;
import
{
mockLabelToken
}
from
'
../mock_data
'
;
jest
.
mock
(
'
~/flash
'
);
const
createComponent
=
({
config
=
mockLabelToken
,
value
=
{
data
:
''
},
active
=
false
}
=
{})
=>
mount
(
LabelToken
,
{
propsData
:
{
config
,
value
,
active
,
},
provide
:
{
portalName
:
'
fake target
'
,
alignSuggestions
:
function
fakeAlignSuggestions
()
{},
},
stubs
:
{
Portal
:
{
template
:
'
<div><slot></slot></div>
'
,
},
GlFilteredSearchSuggestionList
:
{
template
:
'
<div></div>
'
,
methods
:
{
getValue
:
()
=>
'
=
'
,
},
},
},
});
describe
(
'
LabelToken
'
,
()
=>
{
let
mock
;
let
wrapper
;
beforeEach
(()
=>
{
mock
=
new
MockAdapter
(
axios
);
wrapper
=
createComponent
();
});
afterEach
(()
=>
{
mock
.
restore
();
wrapper
.
destroy
();
});
describe
(
'
computed
'
,
()
=>
{
beforeEach
(
async
()
=>
{
// Label title with spaces is always enclosed in quotations by component.
wrapper
=
createComponent
({
value
:
{
data
:
`"
${
mockRegularLabel
.
title
}
"`
}
});
wrapper
.
setData
({
labels
:
mockLabels
,
});
await
wrapper
.
vm
.
$nextTick
();
});
describe
(
'
currentValue
'
,
()
=>
{
it
(
'
returns lowercase string for `value.data`
'
,
()
=>
{
expect
(
wrapper
.
vm
.
currentValue
).
toBe
(
'
"foo label"
'
);
});
});
describe
(
'
activeLabel
'
,
()
=>
{
it
(
'
returns object for currently present `value.data`
'
,
()
=>
{
expect
(
wrapper
.
vm
.
activeLabel
).
toEqual
(
mockRegularLabel
);
});
});
describe
(
'
containerStyle
'
,
()
=>
{
it
(
'
returns object containing `backgroundColor` and `color` properties based on `activeLabel` value
'
,
()
=>
{
expect
(
wrapper
.
vm
.
containerStyle
).
toEqual
({
backgroundColor
:
mockRegularLabel
.
color
,
color
:
mockRegularLabel
.
textColor
,
});
});
it
(
'
returns empty object when `activeLabel` is not set
'
,
async
()
=>
{
wrapper
.
setData
({
labels
:
[],
});
await
wrapper
.
vm
.
$nextTick
();
expect
(
wrapper
.
vm
.
containerStyle
).
toEqual
({});
});
});
});
describe
(
'
methods
'
,
()
=>
{
describe
(
'
fetchLabelBySearchTerm
'
,
()
=>
{
it
(
'
calls `config.fetchLabels` with provided searchTerm param
'
,
()
=>
{
jest
.
spyOn
(
wrapper
.
vm
.
config
,
'
fetchLabels
'
);
wrapper
.
vm
.
fetchLabelBySearchTerm
(
'
foo
'
);
expect
(
wrapper
.
vm
.
config
.
fetchLabels
).
toHaveBeenCalledWith
(
'
foo
'
);
});
it
(
'
sets response to `labels` when request is succesful
'
,
()
=>
{
jest
.
spyOn
(
wrapper
.
vm
.
config
,
'
fetchLabels
'
).
mockResolvedValue
(
mockLabels
);
wrapper
.
vm
.
fetchLabelBySearchTerm
(
'
foo
'
);
return
waitForPromises
().
then
(()
=>
{
expect
(
wrapper
.
vm
.
labels
).
toEqual
(
mockLabels
);
});
});
it
(
'
calls `createFlash` with flash error message when request fails
'
,
()
=>
{
jest
.
spyOn
(
wrapper
.
vm
.
config
,
'
fetchLabels
'
).
mockRejectedValue
({});
wrapper
.
vm
.
fetchLabelBySearchTerm
(
'
foo
'
);
return
waitForPromises
().
then
(()
=>
{
expect
(
createFlash
).
toHaveBeenCalledWith
(
'
There was a problem fetching labels.
'
);
});
});
it
(
'
sets `loading` to false when request completes
'
,
()
=>
{
jest
.
spyOn
(
wrapper
.
vm
.
config
,
'
fetchLabels
'
).
mockRejectedValue
({});
wrapper
.
vm
.
fetchLabelBySearchTerm
(
'
foo
'
);
return
waitForPromises
().
then
(()
=>
{
expect
(
wrapper
.
vm
.
loading
).
toBe
(
false
);
});
});
});
});
describe
(
'
template
'
,
()
=>
{
beforeEach
(
async
()
=>
{
wrapper
=
createComponent
({
value
:
{
data
:
`"
${
mockRegularLabel
.
title
}
"`
}
});
wrapper
.
setData
({
labels
:
mockLabels
,
});
await
wrapper
.
vm
.
$nextTick
();
});
it
(
'
renders gl-filtered-search-token component
'
,
()
=>
{
expect
(
wrapper
.
find
(
GlFilteredSearchToken
).
exists
()).
toBe
(
true
);
});
it
(
'
renders token item when value is selected
'
,
()
=>
{
const
tokenSegments
=
wrapper
.
findAll
(
GlFilteredSearchTokenSegment
);
expect
(
tokenSegments
).
toHaveLength
(
3
);
// Label, =, "Foo Label"
expect
(
tokenSegments
.
at
(
2
).
text
()).
toBe
(
`~
${
mockRegularLabel
.
title
}
`
);
// "Foo Label"
expect
(
tokenSegments
.
at
(
2
)
.
find
(
'
.gl-token
'
)
.
attributes
(
'
style
'
),
).
toBe
(
'
background-color: rgb(186, 218, 85); color: rgb(255, 255, 255);
'
);
});
});
});
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment