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
10861232
Commit
10861232
authored
Sep 30, 2021
by
Ezekiel Kigbo
Committed by
Olena Horal-Koretska
Sep 30, 2021
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
[VSA][FE] Easily distinguish selected projects in the project selector
parent
50cf5e41
Changes
2
Hide whitespace changes
Inline
Side-by-side
Showing
2 changed files
with
174 additions
and
38 deletions
+174
-38
app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue
.../analytics/shared/components/projects_dropdown_filter.vue
+71
-22
spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js
...lytics/shared/components/projects_dropdown_filter_spec.js
+103
-16
No files found.
app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue
View file @
10861232
...
...
@@ -15,6 +15,8 @@ import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import
{
n__
,
s__
,
__
}
from
'
~/locale
'
;
import
getProjects
from
'
../graphql/projects.query.graphql
'
;
const
sortByProjectName
=
(
projects
=
[])
=>
projects
.
sort
((
a
,
b
)
=>
a
.
name
.
localeCompare
(
b
.
name
));
export
default
{
name
:
'
ProjectsDropdownFilter
'
,
components
:
{
...
...
@@ -88,6 +90,9 @@ export default {
selectedProjectIds
()
{
return
this
.
selectedProjects
.
map
((
p
)
=>
p
.
id
);
},
hasSelectedProjects
()
{
return
Boolean
(
this
.
selectedProjects
.
length
);
},
availableProjects
()
{
return
filterBySearchTerm
(
this
.
projects
,
this
.
searchTerm
);
},
...
...
@@ -95,6 +100,14 @@ export default {
const
{
loading
,
availableProjects
}
=
this
;
return
!
loading
&&
!
availableProjects
.
length
;
},
selectedItems
()
{
return
sortByProjectName
(
this
.
availableProjects
.
filter
(({
id
})
=>
this
.
selectedProjectIds
.
includes
(
id
)),
);
},
unselectedItems
()
{
return
this
.
availableProjects
.
filter
(({
id
})
=>
!
this
.
selectedProjectIds
.
includes
(
id
));
},
},
watch
:
{
searchTerm
()
{
...
...
@@ -105,44 +118,53 @@ export default {
this
.
search
();
},
methods
:
{
handleUpdatedSelectedProjects
()
{
this
.
$emit
(
'
selected
'
,
this
.
selectedProjects
);
},
search
:
debounce
(
function
debouncedSearch
()
{
this
.
fetchData
();
},
DEFAULT_DEBOUNCE_AND_THROTTLE_MS
),
getSelectedProjects
(
selectedProject
,
is
Marking
)
{
return
is
Marking
getSelectedProjects
(
selectedProject
,
is
Selected
)
{
return
is
Selected
?
this
.
selectedProjects
.
concat
([
selectedProject
])
:
this
.
selectedProjects
.
filter
((
project
)
=>
project
.
id
!==
selectedProject
.
id
);
},
singleSelectedProject
(
selectedObj
,
isMarking
)
{
return
isMarking
?
[
selectedObj
]
:
[];
},
setSelectedProjects
(
selectedObj
,
isMarking
)
{
setSelectedProjects
(
project
)
{
this
.
selectedProjects
=
this
.
multiSelect
?
this
.
getSelectedProjects
(
selectedObj
,
isMarking
)
:
this
.
singleSelectedProject
(
selectedObj
,
isMarking
);
?
this
.
getSelectedProjects
(
project
,
!
this
.
isProjectSelected
(
project
)
)
:
this
.
singleSelectedProject
(
project
,
!
this
.
isProjectSelected
(
project
)
);
},
onClick
(
{
project
,
isSelected
}
)
{
this
.
setSelectedProjects
(
project
,
!
isSelected
);
this
.
$emit
(
'
selected
'
,
this
.
selectedProjects
);
onClick
(
project
)
{
this
.
setSelectedProjects
(
project
);
this
.
handleUpdatedSelectedProjects
(
);
},
onMultiSelectClick
(
{
project
,
isSelected
}
)
{
this
.
setSelectedProjects
(
project
,
!
isSelected
);
onMultiSelectClick
(
project
)
{
this
.
setSelectedProjects
(
project
);
this
.
isDirty
=
true
;
},
onSelected
(
ev
)
{
onSelected
(
project
)
{
if
(
this
.
multiSelect
)
{
this
.
onMultiSelectClick
(
ev
);
this
.
onMultiSelectClick
(
project
);
}
else
{
this
.
onClick
(
ev
);
this
.
onClick
(
project
);
}
},
onHide
()
{
if
(
this
.
multiSelect
&&
this
.
isDirty
)
{
this
.
$emit
(
'
selected
'
,
this
.
selectedProjects
);
this
.
handleUpdatedSelectedProjects
(
);
}
this
.
searchTerm
=
''
;
this
.
isDirty
=
false
;
},
onClearAll
()
{
if
(
this
.
hasSelectedProjects
)
{
this
.
isDirty
=
true
;
}
this
.
selectedProjects
=
[];
},
fetchData
()
{
this
.
loading
=
true
;
...
...
@@ -168,8 +190,8 @@ export default {
this
.
projects
=
nodes
;
});
},
isProjectSelected
(
id
)
{
return
this
.
selectedProject
s
?
this
.
selectedProjectIds
.
includes
(
id
)
:
false
;
isProjectSelected
(
project
)
{
return
this
.
selectedProject
Ids
.
includes
(
project
.
id
)
;
},
getEntityId
(
project
)
{
return
getIdFromGraphQLId
(
project
.
id
);
...
...
@@ -182,6 +204,10 @@ export default {
ref=
"projectsDropdown"
class=
"dropdown dropdown-projects"
toggle-class=
"gl-shadow-none"
:show-clear-all=
"hasSelectedProjects"
show-highlighted-items-title
highlighted-items-title-class=
"gl-p-3"
@
clear-all.stop=
"onClearAll"
@
hide=
"onHide"
>
<template
#button-content
>
...
...
@@ -204,14 +230,37 @@ export default {
<gl-dropdown-section-header>
{{
__
(
'
Projects
'
)
}}
</gl-dropdown-section-header>
<gl-search-box-by-type
v-model.trim=
"searchTerm"
/>
</
template
>
<
template
#highlighted-items
>
<gl-dropdown-item
v-for=
"project in selectedItems"
:key=
"project.id"
is-check-item
:is-checked=
"isProjectSelected(project)"
@
click.native.capture.stop=
"onSelected(project)"
>
<div
class=
"gl-display-flex"
>
<gl-avatar
class=
"gl-mr-2 gl-vertical-align-middle"
:alt=
"project.name"
:size=
"16"
:entity-id=
"getEntityId(project)"
:entity-name=
"project.name"
:src=
"project.avatarUrl"
shape=
"rect"
/>
<div>
<div
data-testid=
"project-name"
>
{{
project
.
name
}}
</div>
<div
class=
"gl-text-gray-500"
data-testid=
"project-full-path"
>
{{
project
.
fullPath
}}
</div>
</div>
</div>
</gl-dropdown-item>
</
template
>
<gl-dropdown-item
v-for=
"project in
availableProject
s"
v-for=
"project in
unselectedItem
s"
:key=
"project.id"
:is-check-item=
"true"
:is-checked=
"isProjectSelected(project.id)"
@
click.native.capture.stop=
"
onSelected({ project, isSelected: isProjectSelected(project.id) })
"
@
click.native.capture.stop=
"onSelected(project)"
>
<div
class=
"gl-display-flex"
>
<gl-avatar
...
...
spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js
View file @
10861232
import
{
GlDropdown
,
GlDropdownItem
}
from
'
@gitlab/ui
'
;
import
{
mount
}
from
'
@vue/test-utils
'
;
import
{
mountExtended
}
from
'
helpers/vue_test_utils_helper
'
;
import
{
stubComponent
}
from
'
helpers/stub_component
'
;
import
{
TEST_HOST
}
from
'
helpers/test_constants
'
;
import
ProjectsDropdownFilter
from
'
~/analytics/shared/components/projects_dropdown_filter.vue
'
;
import
getProjects
from
'
~/analytics/shared/graphql/projects.query.graphql
'
;
...
...
@@ -25,6 +26,17 @@ const projects = [
},
];
const
MockGlDropdown
=
stubComponent
(
GlDropdown
,
{
template
:
`
<div>
<div data-testid="vsa-highlighted-items">
<slot name="highlighted-items"></slot>
</div>
<div data-testid="vsa-default-items"><slot></slot></div>
</div>
`
,
});
const
defaultMocks
=
{
$apollo
:
{
query
:
jest
.
fn
().
mockResolvedValue
({
...
...
@@ -38,22 +50,32 @@ let spyQuery;
describe
(
'
ProjectsDropdownFilter component
'
,
()
=>
{
let
wrapper
;
const
createComponent
=
(
props
=
{})
=>
{
const
createComponent
=
(
props
=
{}
,
stubs
=
{}
)
=>
{
spyQuery
=
defaultMocks
.
$apollo
.
query
;
wrapper
=
mount
(
ProjectsDropdownFilter
,
{
wrapper
=
mount
Extended
(
ProjectsDropdownFilter
,
{
mocks
:
{
...
defaultMocks
},
propsData
:
{
groupId
:
1
,
groupNamespace
:
'
gitlab-org
'
,
...
props
,
},
stubs
,
});
};
const
createWithMockDropdown
=
(
props
)
=>
{
createComponent
(
props
,
{
GlDropdown
:
MockGlDropdown
});
return
wrapper
.
vm
.
$nextTick
();
};
afterEach
(()
=>
{
wrapper
.
destroy
();
});
const
findHighlightedItems
=
()
=>
wrapper
.
findByTestId
(
'
vsa-highlighted-items
'
);
const
findHighlightedItemsTitle
=
()
=>
wrapper
.
findByText
(
'
Selected
'
);
const
findClearAllButton
=
()
=>
wrapper
.
findByText
(
'
Clear all
'
);
const
findDropdown
=
()
=>
wrapper
.
find
(
GlDropdown
);
const
findDropdownItems
=
()
=>
...
...
@@ -75,8 +97,19 @@ describe('ProjectsDropdownFilter component', () => {
const
findDropdownFullPathAtIndex
=
(
index
)
=>
findDropdownAtIndex
(
index
).
find
(
'
[data-testid="project-full-path"]
'
);
const
selectDropdownItemAtIndex
=
(
index
)
=>
const
selectDropdownItemAtIndex
=
(
index
)
=>
{
findDropdownAtIndex
(
index
).
find
(
'
button
'
).
trigger
(
'
click
'
);
return
wrapper
.
vm
.
$nextTick
();
};
// NOTE: Selected items are now visually separated from unselected items
const
findSelectedDropdownItems
=
()
=>
findHighlightedItems
().
findAll
(
GlDropdownItem
);
const
findSelectedDropdownAtIndex
=
(
index
)
=>
findSelectedDropdownItems
().
at
(
index
);
const
findSelectedButtonIdentIconAtIndex
=
(
index
)
=>
findSelectedDropdownAtIndex
(
index
).
find
(
'
div.gl-avatar-identicon
'
);
const
findSelectedButtonAvatarItemAtIndex
=
(
index
)
=>
findSelectedDropdownAtIndex
(
index
).
find
(
'
img.gl-avatar
'
);
const
selectedIds
=
()
=>
wrapper
.
vm
.
selectedProjects
.
map
(({
id
})
=>
id
);
...
...
@@ -109,7 +142,62 @@ describe('ProjectsDropdownFilter component', () => {
});
});
describe
(
'
when passed a an array of defaultProject as prop
'
,
()
=>
{
describe
(
'
highlighted items
'
,
()
=>
{
const
blockDefaultProps
=
{
multiSelect
:
true
};
beforeEach
(()
=>
{
createComponent
(
blockDefaultProps
);
});
describe
(
'
with no project selected
'
,
()
=>
{
it
(
'
does not render the highlighted items
'
,
async
()
=>
{
await
createWithMockDropdown
(
blockDefaultProps
);
expect
(
findSelectedDropdownItems
().
length
).
toBe
(
0
);
});
it
(
'
does not render the highlighted items title
'
,
()
=>
{
expect
(
findHighlightedItemsTitle
().
exists
()).
toBe
(
false
);
});
it
(
'
does not render the clear all button
'
,
()
=>
{
expect
(
findClearAllButton
().
exists
()).
toBe
(
false
);
});
});
describe
(
'
with a selected project
'
,
()
=>
{
beforeEach
(
async
()
=>
{
await
selectDropdownItemAtIndex
(
0
);
});
it
(
'
renders the highlighted items
'
,
async
()
=>
{
await
createWithMockDropdown
(
blockDefaultProps
);
await
selectDropdownItemAtIndex
(
0
);
expect
(
findSelectedDropdownItems
().
length
).
toBe
(
1
);
});
it
(
'
renders the highlighted items title
'
,
()
=>
{
expect
(
findHighlightedItemsTitle
().
exists
()).
toBe
(
true
);
});
it
(
'
renders the clear all button
'
,
()
=>
{
expect
(
findClearAllButton
().
exists
()).
toBe
(
true
);
});
it
(
'
clears all selected items when the clear all button is clicked
'
,
async
()
=>
{
await
selectDropdownItemAtIndex
(
1
);
expect
(
wrapper
.
text
()).
toContain
(
'
2 projects selected
'
);
findClearAllButton
().
trigger
(
'
click
'
);
await
wrapper
.
vm
.
$nextTick
();
expect
(
wrapper
.
text
()).
not
.
toContain
(
'
2 projects selected
'
);
expect
(
wrapper
.
text
()).
toContain
(
'
Select projects
'
);
});
});
});
describe
(
'
when passed an array of defaultProject as prop
'
,
()
=>
{
beforeEach
(()
=>
{
createComponent
({
defaultProjects
:
[
projects
[
0
]],
...
...
@@ -130,8 +218,9 @@ describe('ProjectsDropdownFilter component', () => {
});
describe
(
'
when multiSelect is false
'
,
()
=>
{
const
blockDefaultProps
=
{
multiSelect
:
false
};
beforeEach
(()
=>
{
createComponent
(
{
multiSelect
:
false
}
);
createComponent
(
blockDefaultProps
);
});
describe
(
'
displays the correct information
'
,
()
=>
{
...
...
@@ -183,21 +272,19 @@ describe('ProjectsDropdownFilter component', () => {
});
it
(
'
renders an avatar in the dropdown button when the project has an avatarUrl
'
,
async
()
=>
{
selectDropdownItemAtIndex
(
0
);
await
createWithMockDropdown
(
blockDefaultProps
);
await
selectDropdownItemAtIndex
(
0
);
await
wrapper
.
vm
.
$nextTick
().
then
(()
=>
{
expect
(
findDropdownButtonAvatarAtIndex
(
0
).
exists
()).
toBe
(
true
);
expect
(
findDropdownButtonIdentIconAtIndex
(
0
).
exists
()).
toBe
(
false
);
});
expect
(
findSelectedButtonAvatarItemAtIndex
(
0
).
exists
()).
toBe
(
true
);
expect
(
findSelectedButtonIdentIconAtIndex
(
0
).
exists
()).
toBe
(
false
);
});
it
(
"
renders an identicon in the dropdown button when the project doesn't have an avatarUrl
"
,
async
()
=>
{
selectDropdownItemAtIndex
(
1
);
await
createWithMockDropdown
(
blockDefaultProps
);
await
selectDropdownItemAtIndex
(
1
);
await
wrapper
.
vm
.
$nextTick
().
then
(()
=>
{
expect
(
findDropdownButtonAvatarAtIndex
(
1
).
exists
()).
toBe
(
false
);
expect
(
findDropdownButtonIdentIconAtIndex
(
1
).
exists
()).
toBe
(
true
);
});
expect
(
findSelectedButtonAvatarItemAtIndex
(
0
).
exists
()).
toBe
(
false
);
expect
(
findSelectedButtonIdentIconAtIndex
(
0
).
exists
()).
toBe
(
true
);
});
});
});
...
...
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