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
ede344a7
Commit
ede344a7
authored
Nov 05, 2020
by
Jackie Fraser
Committed by
Nicolò Maria Mezzopera
Nov 05, 2020
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Add username search to invite members modal
parent
573a263c
Changes
6
Show whitespace changes
Inline
Side-by-side
Showing
6 changed files
with
329 additions
and
39 deletions
+329
-39
app/assets/javascripts/invite_members/components/invite_members_modal.vue
...cripts/invite_members/components/invite_members_modal.vue
+27
-22
app/assets/javascripts/invite_members/components/members_token_select.vue
...cripts/invite_members/components/members_token_select.vue
+120
-0
app/assets/javascripts/invite_members/constants.js
app/assets/javascripts/invite_members/constants.js
+1
-0
locale/gitlab.pot
locale/gitlab.pot
+6
-3
spec/frontend/invite_members/components/invite_members_modal_spec.js
...nd/invite_members/components/invite_members_modal_spec.js
+63
-14
spec/frontend/invite_members/components/members_token_select_spec.js
...nd/invite_members/components/members_token_select_spec.js
+112
-0
No files found.
app/assets/javascripts/invite_members/components/invite_members_modal.vue
View file @
ede344a7
...
...
@@ -6,13 +6,13 @@ import {
GlDatepicker
,
GlLink
,
GlSprintf
,
GlSearchBoxByType
,
GlButton
,
GlFormInput
,
}
from
'
@gitlab/ui
'
;
import
eventHub
from
'
../event_hub
'
;
import
{
s__
,
sprintf
}
from
'
~/locale
'
;
import
Api
from
'
~/api
'
;
import
MembersTokenSelect
from
'
~/invite_members/components/members_token_select.vue
'
;
export
default
{
name
:
'
InviteMembersModal
'
,
...
...
@@ -23,9 +23,9 @@ export default {
GlDropdown
,
GlDropdownItem
,
GlSprintf
,
GlSearchBoxByType
,
GlButton
,
GlFormInput
,
MembersTokenSelect
,
},
props
:
{
groupId
:
{
...
...
@@ -129,44 +129,45 @@ export default {
},
labels
:
{
modalTitle
:
s__
(
'
InviteMembersModal|Invite team members
'
),
user
ToInvite
:
s__
(
'
InviteMembersModal|GitLab member or Email address
'
),
newUsers
ToInvite
:
s__
(
'
InviteMembersModal|GitLab member or Email address
'
),
userPlaceholder
:
s__
(
'
InviteMembersModal|Search for members to invite
'
),
accessLevel
:
s__
(
'
InviteMembersModal|Choose a role permission
'
),
accessExpireDate
:
s__
(
'
InviteMembersModal|Access expiration date (optional)
'
),
toastMessageSuccessful
:
s__
(
'
InviteMembersModal|
Users were succe
sfully added
'
),
toastMessageUnsuccessful
:
s__
(
'
InviteMembersModal|
User not invited. Feature coming soon!
'
),
toastMessageSuccessful
:
s__
(
'
InviteMembersModal|
Members were succes
sfully added
'
),
toastMessageUnsuccessful
:
s__
(
'
InviteMembersModal|
Some of the members could not be added
'
),
readMoreText
:
s__
(
`InviteMembersModal|%{linkStart}Read more%{linkEnd} about role permissions`
),
inviteButtonText
:
s__
(
'
InviteMembersModal|Invite
'
),
cancelButtonText
:
s__
(
'
InviteMembersModal|Cancel
'
),
headerCloseLabel
:
s__
(
'
InviteMembersModal|Close invite team members
'
),
},
membersTokenSelectLabelId
:
'
invite-members-input
'
,
};
</
script
>
<
template
>
<gl-modal
:modal-id=
"modalId"
size=
"sm"
:title=
"$options.labels.modalTitle"
>
<gl-modal
:modal-id=
"modalId"
size=
"sm"
:title=
"$options.labels.modalTitle"
:header-close-label=
"$options.labels.headerCloseLabel"
>
<div
class=
"gl-ml-5 gl-mr-5"
>
<div>
{{
introText
}}
</div>
<label
class=
"gl-font-weight-bold gl-mt-5"
>
{{
$options
.
labels
.
userToInvite
}}
</label>
<label
:id=
"$options.membersTokenSelectLabelId"
class=
"gl-font-weight-bold gl-mt-5"
>
{{
$options
.
labels
.
newUsersToInvite
}}
</label>
<div
class=
"gl-mt-2"
>
<
gl-search-box-by-type
<
members-token-select
v-model=
"newUsersToInvite"
:label=
"$options.labels.newUsersToInvite"
:aria-labelledby=
"$options.membersTokenSelectLabelId"
:placeholder=
"$options.labels.userPlaceholder"
type=
"text"
autocomplete=
"off"
autocorrect=
"off"
autocapitalize=
"off"
spellcheck=
"false"
/>
</div>
<label
class=
"gl-font-weight-bold gl-mt-5"
>
{{
$options
.
labels
.
accessLevel
}}
</label>
<div
class=
"gl-mt-2 gl-w-half gl-xs-w-full"
>
<gl-dropdown
menu-class=
"dropdown-menu-selectable"
class=
"gl-shadow-none gl-w-full"
v-bind=
"$attrs"
:text=
"selectedRoleName"
>
<gl-dropdown
class=
"gl-shadow-none gl-w-full"
v-bind=
"$attrs"
:text=
"selectedRoleName"
>
<template
v-for=
"(key, item) in accessLevels"
>
<gl-dropdown-item
:key=
"key"
...
...
@@ -215,9 +216,13 @@ export default {
{{
$options
.
labels
.
cancelButtonText
}}
</gl-button>
<div
class=
"gl-mr-3"
></div>
<gl-button
ref=
"inviteButton"
variant=
"success"
@
click=
"sendInvite"
>
{{
$options
.
labels
.
inviteButtonText
}}
</gl-button>
<gl-button
ref=
"inviteButton"
:disabled=
"!newUsersToInvite"
variant=
"success"
@
click=
"sendInvite"
>
{{
$options
.
labels
.
inviteButtonText
}}
</gl-button
>
</div>
</
template
>
</gl-modal>
...
...
app/assets/javascripts/invite_members/components/members_token_select.vue
0 → 100644
View file @
ede344a7
<
script
>
import
{
debounce
}
from
'
lodash
'
;
import
{
GlTokenSelector
,
GlAvatar
,
GlAvatarLabeled
}
from
'
@gitlab/ui
'
;
import
{
USER_SEARCH_DELAY
}
from
'
../constants
'
;
import
Api
from
'
~/api
'
;
export
default
{
components
:
{
GlTokenSelector
,
GlAvatar
,
GlAvatarLabeled
,
},
props
:
{
placeholder
:
{
type
:
String
,
required
:
false
,
default
:
''
,
},
ariaLabelledby
:
{
type
:
String
,
required
:
true
,
},
},
data
()
{
return
{
loading
:
false
,
query
:
''
,
users
:
[],
selectedTokens
:
[],
hasBeenFocused
:
false
,
hideDropdownWithNoItems
:
true
,
};
},
computed
:
{
newUsersToInvite
()
{
return
this
.
selectedTokens
.
map
(
obj
=>
{
return
obj
.
id
;
})
.
join
(
'
,
'
);
},
placeholderText
()
{
if
(
this
.
selectedTokens
.
length
===
0
)
{
return
this
.
placeholder
;
}
return
''
;
},
},
methods
:
{
handleTextInput
(
query
)
{
this
.
hideDropdownWithNoItems
=
false
;
this
.
query
=
query
;
this
.
loading
=
true
;
this
.
retrieveUsers
(
query
);
},
retrieveUsers
:
debounce
(
function
debouncedRetrieveUsers
()
{
return
Api
.
users
(
this
.
query
,
this
.
$options
.
queryOptions
)
.
then
(
response
=>
{
this
.
users
=
response
.
data
.
map
(
token
=>
({
id
:
token
.
id
,
name
:
token
.
name
,
username
:
token
.
username
,
avatar_url
:
token
.
avatar_url
,
}));
this
.
loading
=
false
;
})
.
catch
(()
=>
{
this
.
loading
=
false
;
});
},
USER_SEARCH_DELAY
),
handleInput
()
{
this
.
$emit
(
'
input
'
,
this
.
newUsersToInvite
);
},
handleBlur
()
{
this
.
hideDropdownWithNoItems
=
false
;
},
handleFocus
()
{
// The modal auto-focuses on the input when opened.
// This prevents the dropdown from opening when the modal opens.
if
(
this
.
hasBeenFocused
)
{
this
.
loading
=
true
;
this
.
retrieveUsers
();
}
this
.
hasBeenFocused
=
true
;
},
},
queryOptions
:
{
exclude_internal
:
true
,
active
:
true
},
};
</
script
>
<
template
>
<gl-token-selector
v-model=
"selectedTokens"
:dropdown-items=
"users"
:loading=
"loading"
:allow-user-defined-tokens=
"false"
:hide-dropdown-with-no-items=
"hideDropdownWithNoItems"
:placeholder=
"placeholderText"
:aria-labelledby=
"ariaLabelledby"
@
blur=
"handleBlur"
@
text-input=
"handleTextInput"
@
input=
"handleInput"
@
focus=
"handleFocus"
>
<template
#token-content
="
{ token }">
<gl-avatar
v-if=
"token.avatar_url"
:src=
"token.avatar_url"
:size=
"16"
/>
{{
token
.
name
}}
</
template
>
<
template
#dropdown-item-content=
"{ dropdownItem }"
>
<gl-avatar-labeled
:src=
"dropdownItem.avatar_url"
:size=
"32"
:label=
"dropdownItem.name"
:sub-label=
"dropdownItem.username"
/>
</
template
>
</gl-token-selector>
</template>
app/assets/javascripts/invite_members/constants.js
0 → 100644
View file @
ede344a7
export
const
USER_SEARCH_DELAY
=
200
;
locale/gitlab.pot
View file @
ede344a7
...
...
@@ -14729,6 +14729,9 @@ msgstr ""
msgid "InviteMembersModal|Choose a role permission"
msgstr ""
msgid "InviteMembersModal|Close invite team members"
msgstr ""
msgid "InviteMembersModal|GitLab member or Email address"
msgstr ""
...
...
@@ -14738,13 +14741,13 @@ msgstr ""
msgid "InviteMembersModal|Invite team members"
msgstr ""
msgid "InviteMembersModal|
Search for members to invite
"
msgid "InviteMembersModal|
Members were successfully added
"
msgstr ""
msgid "InviteMembersModal|
User not invited. Feature coming soon!
"
msgid "InviteMembersModal|
Search for members to invite
"
msgstr ""
msgid "InviteMembersModal|
Users were succesfully
added"
msgid "InviteMembersModal|
Some of the members could not be
added"
msgstr ""
msgid "InviteMembersModal|You're inviting members to the %{group_name} group"
...
...
spec/frontend/invite_members/components/invite_members_modal_spec.js
View file @
ede344a7
...
...
@@ -9,7 +9,7 @@ const accessLevels = { Guest: 10, Reporter: 20, Developer: 30, Maintainer: 40, O
const
defaultAccessLevel
=
'
10
'
;
const
helpLink
=
'
https://example.com
'
;
const
createComponent
=
()
=>
{
const
createComponent
=
(
data
=
{}
)
=>
{
return
shallowMount
(
InviteMembersModal
,
{
propsData
:
{
groupId
,
...
...
@@ -18,9 +18,14 @@ const createComponent = () => {
defaultAccessLevel
,
helpLink
,
},
data
()
{
return
data
;
},
stubs
:
{
GlSprintf
,
'
gl-modal
'
:
'
<div><slot name="modal-footer"></slot><slot></slot></div>
'
,
'
gl-dropdown
'
:
true
,
'
gl-dropdown-item
'
:
true
,
GlSprintf
,
},
});
};
...
...
@@ -34,7 +39,7 @@ describe('InviteMembersModal', () => {
});
const
findDropdown
=
()
=>
wrapper
.
find
(
GlDropdown
);
const
findDropdownItems
=
()
=>
wrapper
.
findAll
(
GlDropdownItem
);
const
findDropdownItems
=
()
=>
findDropdown
()
.
findAll
(
GlDropdownItem
);
const
findDatepicker
=
()
=>
wrapper
.
find
(
GlDatepicker
);
const
findLink
=
()
=>
wrapper
.
find
(
GlLink
);
const
findCancelButton
=
()
=>
wrapper
.
find
({
ref
:
'
cancelButton
'
});
...
...
@@ -88,25 +93,69 @@ describe('InviteMembersModal', () => {
format
:
'
json
'
,
};
describe
(
'
when the invite was sent successfully
'
,
()
=>
{
beforeEach
(()
=>
{
wrapper
=
createComponent
();
jest
.
spyOn
(
Api
,
'
inviteGroupMember
'
).
mockResolvedValue
({
data
:
postData
});
wrapper
.
vm
.
$toast
=
{
show
:
jest
.
fn
()
};
jest
.
spyOn
(
Api
,
'
inviteGroupMember
'
).
mockResolvedValue
({
data
:
postData
});
wrapper
.
vm
.
submitForm
(
postData
);
});
it
(
'
displays the successful toastMessage
'
,
()
=>
{
const
toastMessageSuccessful
=
'
Members were successfully added
'
;
expect
(
wrapper
.
vm
.
$toast
.
show
).
toHaveBeenCalledWith
(
toastMessageSuccessful
,
wrapper
.
vm
.
toastOptions
,
);
});
it
(
'
calls Api inviteGroupMember with the correct params
'
,
()
=>
{
expect
(
Api
.
inviteGroupMember
).
toHaveBeenCalledWith
(
groupId
,
postData
);
});
});
describe
(
'
when
the invite was sent successfully
'
,
()
=>
{
const
toastMessageSuccessful
=
'
Users were succesfully added
'
;
describe
(
'
when
sending the invite for a single member returned an api error
'
,
()
=>
{
const
apiErrorMessage
=
'
Members already exists
'
;
it
(
'
displays the successful toastMessage
'
,
()
=>
{
beforeEach
(()
=>
{
wrapper
=
createComponent
({
newUsersToInvite
:
'
123
'
});
wrapper
.
vm
.
$toast
=
{
show
:
jest
.
fn
()
};
jest
.
spyOn
(
Api
,
'
inviteGroupMember
'
)
.
mockRejectedValue
({
response
:
{
data
:
{
message
:
apiErrorMessage
}
}
});
findInviteButton
().
vm
.
$emit
(
'
click
'
);
});
it
(
'
displays the api error message for the toastMessage
'
,
()
=>
{
expect
(
wrapper
.
vm
.
$toast
.
show
).
toHaveBeenCalledWith
(
toastMessageSuccessful
,
apiErrorMessage
,
wrapper
.
vm
.
toastOptions
,
);
});
});
describe
(
'
when sending the invite for multiple members returned any error
'
,
()
=>
{
const
genericErrorMessage
=
'
Some of the members could not be added
'
;
beforeEach
(()
=>
{
wrapper
=
createComponent
({
newUsersToInvite
:
'
123
'
});
wrapper
.
vm
.
$toast
=
{
show
:
jest
.
fn
()
};
jest
.
spyOn
(
Api
,
'
inviteGroupMember
'
)
.
mockRejectedValue
({
response
:
{
data
:
{
success
:
false
}
}
});
findInviteButton
().
vm
.
$emit
(
'
click
'
);
});
it
(
'
displays the expected toastMessage
'
,
()
=>
{
expect
(
wrapper
.
vm
.
$toast
.
show
).
toHaveBeenCalledWith
(
genericErrorMessage
,
wrapper
.
vm
.
toastOptions
,
);
});
...
...
spec/frontend/invite_members/components/members_token_select_spec.js
0 → 100644
View file @
ede344a7
import
{
shallowMount
}
from
'
@vue/test-utils
'
;
import
{
nextTick
}
from
'
vue
'
;
import
{
GlTokenSelector
}
from
'
@gitlab/ui
'
;
import
waitForPromises
from
'
helpers/wait_for_promises
'
;
import
Api
from
'
~/api
'
;
import
MembersTokenSelect
from
'
~/invite_members/components/members_token_select.vue
'
;
const
label
=
'
testgroup
'
;
const
placeholder
=
'
Search for a member
'
;
const
user1
=
{
id
:
1
,
name
:
'
Name One
'
,
username
:
'
one_1
'
,
avatar_url
:
''
};
const
user2
=
{
id
:
2
,
name
:
'
Name Two
'
,
username
:
'
two_2
'
,
avatar_url
:
''
};
const
allUsers
=
[
user1
,
user2
];
const
createComponent
=
()
=>
{
return
shallowMount
(
MembersTokenSelect
,
{
propsData
:
{
ariaLabelledby
:
label
,
placeholder
,
},
});
};
describe
(
'
MembersTokenSelect
'
,
()
=>
{
let
wrapper
;
beforeEach
(()
=>
{
jest
.
spyOn
(
Api
,
'
users
'
).
mockResolvedValue
({
data
:
allUsers
});
wrapper
=
createComponent
();
});
afterEach
(()
=>
{
wrapper
.
destroy
();
wrapper
=
null
;
});
const
findTokenSelector
=
()
=>
wrapper
.
find
(
GlTokenSelector
);
describe
(
'
rendering the token-selector component
'
,
()
=>
{
it
(
'
renders with the correct props
'
,
()
=>
{
const
expectedProps
=
{
ariaLabelledby
:
label
,
placeholder
,
};
expect
(
findTokenSelector
().
props
()).
toEqual
(
expect
.
objectContaining
(
expectedProps
));
});
});
describe
(
'
users
'
,
()
=>
{
describe
(
'
when input is focused for the first time (modal auto-focus)
'
,
()
=>
{
it
(
'
does not call the API
'
,
async
()
=>
{
findTokenSelector
().
vm
.
$emit
(
'
focus
'
);
await
waitForPromises
();
expect
(
Api
.
users
).
not
.
toHaveBeenCalled
();
});
});
describe
(
'
when input is manually focused
'
,
()
=>
{
it
(
'
calls the API and sets dropdown items as request result
'
,
async
()
=>
{
const
tokenSelector
=
findTokenSelector
();
tokenSelector
.
vm
.
$emit
(
'
focus
'
);
tokenSelector
.
vm
.
$emit
(
'
blur
'
);
tokenSelector
.
vm
.
$emit
(
'
focus
'
);
await
waitForPromises
();
expect
(
tokenSelector
.
props
(
'
dropdownItems
'
)).
toMatchObject
(
allUsers
);
expect
(
tokenSelector
.
props
(
'
hideDropdownWithNoItems
'
)).
toBe
(
false
);
});
});
describe
(
'
when text input is typed in
'
,
()
=>
{
it
(
'
calls the API with search parameter
'
,
async
()
=>
{
const
searchParam
=
'
One
'
;
const
tokenSelector
=
findTokenSelector
();
tokenSelector
.
vm
.
$emit
(
'
text-input
'
,
searchParam
);
await
waitForPromises
();
expect
(
Api
.
users
).
toHaveBeenCalledWith
(
searchParam
,
wrapper
.
vm
.
$options
.
queryOptions
);
expect
(
tokenSelector
.
props
(
'
hideDropdownWithNoItems
'
)).
toBe
(
false
);
});
});
describe
(
'
when user is selected
'
,
()
=>
{
it
(
'
emits `input` event with selected users
'
,
()
=>
{
findTokenSelector
().
vm
.
$emit
(
'
input
'
,
[
{
id
:
1
,
name
:
'
John Smith
'
},
{
id
:
2
,
name
:
'
Jane Doe
'
},
]);
expect
(
wrapper
.
emitted
().
input
[
0
][
0
]).
toBe
(
'
1,2
'
);
});
});
});
describe
(
'
when text input is blurred
'
,
()
=>
{
it
(
'
clears text input
'
,
async
()
=>
{
const
tokenSelector
=
findTokenSelector
();
tokenSelector
.
vm
.
$emit
(
'
blur
'
);
await
nextTick
();
expect
(
tokenSelector
.
props
(
'
hideDropdownWithNoItems
'
)).
toBe
(
false
);
});
});
});
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