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
b65a7c7a
Commit
b65a7c7a
authored
May 19, 2021
by
Peter Hegman
Committed by
Nick Thomas
May 19, 2021
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Convert bootstrap tabs on group/members page to `GlTabs`
parent
8a6e9d96
Changes
22
Hide whitespace changes
Inline
Side-by-side
Showing
22 changed files
with
272 additions
and
354 deletions
+272
-354
app/assets/javascripts/members/components/app.vue
app/assets/javascripts/members/components/app.vue
+11
-1
app/assets/javascripts/members/index.js
app/assets/javascripts/members/index.js
+27
-20
app/assets/javascripts/pages/groups/group_members/index.js
app/assets/javascripts/pages/groups/group_members/index.js
+34
-37
app/assets/javascripts/pages/projects/project_members/index.js
...ssets/javascripts/pages/projects/project_members/index.js
+34
-39
app/helpers/groups/group_members_helper.rb
app/helpers/groups/group_members_helper.rb
+12
-12
app/helpers/projects/project_members_helper.rb
app/helpers/projects/project_members_helper.rb
+12
-13
app/views/groups/group_members/index.html.haml
app/views/groups/group_members/index.html.haml
+6
-47
app/views/projects/project_members/index.html.haml
app/views/projects/project_members/index.html.haml
+7
-44
changelogs/unreleased/324681-group-project-members-migrate-to-one-vue-app-and-gltabs-2.yml
...p-project-members-migrate-to-one-vue-app-and-gltabs-2.yml
+5
-0
ee/spec/frontend/members/index_spec.js
ee/spec/frontend/members/index_spec.js
+1
-1
ee/spec/helpers/ee/groups/group_members_helper_spec.rb
ee/spec/helpers/ee/groups/group_members_helper_spec.rb
+12
-3
qa/qa/page/group/members.rb
qa/qa/page/group/members.rb
+1
-1
qa/qa/page/project/members.rb
qa/qa/page/project/members.rb
+1
-1
spec/features/groups/members/tabs_spec.rb
spec/features/groups/members/tabs_spec.rb
+3
-3
spec/features/projects/members/tabs_spec.rb
spec/features/projects/members/tabs_spec.rb
+2
-2
spec/frontend/members/components/app_spec.js
spec/frontend/members/components/app_spec.js
+1
-1
spec/frontend/members/components/members_tabs_spec.js
spec/frontend/members/components/members_tabs_spec.js
+5
-5
spec/frontend/members/index_spec.js
spec/frontend/members/index_spec.js
+10
-9
spec/frontend/members/mock_data.js
spec/frontend/members/mock_data.js
+8
-4
spec/frontend/members/utils_spec.js
spec/frontend/members/utils_spec.js
+6
-4
spec/helpers/groups/group_members_helper_spec.rb
spec/helpers/groups/group_members_helper_spec.rb
+48
-58
spec/helpers/projects/project_members_helper_spec.rb
spec/helpers/projects/project_members_helper_spec.rb
+26
-49
No files found.
app/assets/javascripts/members/components/app.vue
View file @
b65a7c7a
...
...
@@ -9,7 +9,17 @@ import MembersTable from './table/members_table.vue';
export
default
{
name
:
'
MembersApp
'
,
components
:
{
MembersTable
,
FilterSortContainer
,
GlAlert
},
inject
:
[
'
namespace
'
],
provide
()
{
return
{
namespace
:
this
.
namespace
,
};
},
props
:
{
namespace
:
{
type
:
String
,
required
:
true
,
},
},
computed
:
{
...
mapState
({
showError
(
state
)
{
...
...
app/assets/javascripts/members/index.js
View file @
b65a7c7a
...
...
@@ -2,20 +2,11 @@ import { GlToast } from '@gitlab/ui';
import
Vue
from
'
vue
'
;
import
Vuex
from
'
vuex
'
;
import
{
parseDataAttributes
}
from
'
~/members/utils
'
;
import
App
from
'
./components/app.vue
'
;
import
MembersTabs
from
'
./components/members_tabs.vue
'
;
import
{
MEMBER_TYPES
}
from
'
./constants
'
;
import
membersStore
from
'
./store
'
;
export
const
initMembersApp
=
(
el
,
{
namespace
,
tableFields
=
[],
tableAttrs
=
{},
tableSortableFields
=
[],
requestFormatter
=
()
=>
{},
filteredSearchBar
=
{
show
:
false
},
},
)
=>
{
export
const
initMembersApp
=
(
el
,
options
)
=>
{
if
(
!
el
)
{
return
()
=>
{};
}
...
...
@@ -25,29 +16,45 @@ export const initMembersApp = (
const
{
sourceId
,
canManageMembers
,
...
vuexStoreAttributes
}
=
parseDataAttributes
(
el
);
const
store
=
new
Vuex
.
Store
({
modules
:
{
const
modules
=
Object
.
keys
(
MEMBER_TYPES
).
reduce
((
accumulator
,
namespace
)
=>
{
const
namespacedOptions
=
options
[
namespace
];
if
(
!
namespacedOptions
)
{
return
accumulator
;
}
const
{
tableFields
=
[],
tableAttrs
=
{},
tableSortableFields
=
[],
requestFormatter
=
()
=>
{},
filteredSearchBar
=
{
show
:
false
},
}
=
namespacedOptions
;
return
{
...
accumulator
,
[
namespace
]:
membersStore
({
...
vuexStoreAttributes
,
...
vuexStoreAttributes
[
namespace
]
,
tableFields
,
tableAttrs
,
tableSortableFields
,
requestFormatter
,
filteredSearchBar
,
}),
},
});
};
},
{});
const
store
=
new
Vuex
.
Store
({
modules
});
return
new
Vue
({
el
,
components
:
{
App
},
components
:
{
MembersTabs
},
store
,
provide
:
{
namespace
,
currentUserId
:
gon
.
current_user_id
||
null
,
sourceId
,
canManageMembers
,
},
render
:
(
createElement
)
=>
createElement
(
'
app
'
),
render
:
(
createElement
)
=>
createElement
(
'
members-tabs
'
),
});
};
app/assets/javascripts/pages/groups/group_members/index.js
View file @
b65a7c7a
...
...
@@ -29,46 +29,43 @@ function mountRemoveMemberModal() {
const
SHARED_FIELDS
=
[
'
account
'
,
'
expires
'
,
'
maxRole
'
,
'
expiration
'
,
'
actions
'
];
initMembersApp
(
document
.
querySelector
(
'
.js-group-members-list
'
),
{
namespace
:
MEMBER_TYPES
.
user
,
tableFields
:
SHARED_FIELDS
.
concat
([
'
source
'
,
'
granted
'
]),
tableAttrs
:
{
tr
:
{
'
data-qa-selector
'
:
'
member_row
'
}
},
tableSortableFields
:
[
'
account
'
,
'
granted
'
,
'
maxRole
'
,
'
lastSignIn
'
],
requestFormatter
:
groupMemberRequestFormatter
,
filteredSearchBar
:
{
show
:
true
,
tokens
:
[
'
two_factor
'
,
'
with_inherited_permissions
'
],
searchParam
:
'
search
'
,
placeholder
:
s__
(
'
Members|Filter members
'
),
recentSearchesStorageKey
:
'
group_members
'
,
initMembersApp
(
document
.
querySelector
(
'
.js-group-members-list-app
'
),
{
[
MEMBER_TYPES
.
user
]:
{
tableFields
:
SHARED_FIELDS
.
concat
([
'
source
'
,
'
granted
'
]),
tableAttrs
:
{
tr
:
{
'
data-qa-selector
'
:
'
member_row
'
}
},
tableSortableFields
:
[
'
account
'
,
'
granted
'
,
'
maxRole
'
,
'
lastSignIn
'
],
requestFormatter
:
groupMemberRequestFormatter
,
filteredSearchBar
:
{
show
:
true
,
tokens
:
[
'
two_factor
'
,
'
with_inherited_permissions
'
],
searchParam
:
'
search
'
,
placeholder
:
s__
(
'
Members|Filter members
'
),
recentSearchesStorageKey
:
'
group_members
'
,
},
},
});
initMembersApp
(
document
.
querySelector
(
'
.js-group-group-links-list
'
),
{
namespace
:
MEMBER_TYPES
.
group
,
tableFields
:
SHARED_FIELDS
.
concat
(
'
granted
'
),
tableAttrs
:
{
table
:
{
'
data-qa-selector
'
:
'
groups_list
'
},
tr
:
{
'
data-qa-selector
'
:
'
group_row
'
},
[
MEMBER_TYPES
.
group
]:
{
tableFields
:
SHARED_FIELDS
.
concat
(
'
granted
'
),
tableAttrs
:
{
table
:
{
'
data-qa-selector
'
:
'
groups_list
'
},
tr
:
{
'
data-qa-selector
'
:
'
group_row
'
},
},
requestFormatter
:
groupLinkRequestFormatter
,
},
requestFormatter
:
groupLinkRequestFormatter
,
});
initMembersApp
(
document
.
querySelector
(
'
.js-group-invited-members-list
'
),
{
namespace
:
MEMBER_TYPES
.
invite
,
tableFields
:
SHARED_FIELDS
.
concat
(
'
invited
'
),
requestFormatter
:
groupMemberRequestFormatter
,
filteredSearchBar
:
{
show
:
true
,
tokens
:
[],
searchParam
:
'
search_invited
'
,
placeholder
:
s__
(
'
Members|Search invited
'
),
recentSearchesStorageKey
:
'
group_invited_members
'
,
[
MEMBER_TYPES
.
invite
]:
{
tableFields
:
SHARED_FIELDS
.
concat
(
'
invited
'
),
requestFormatter
:
groupMemberRequestFormatter
,
filteredSearchBar
:
{
show
:
true
,
tokens
:
[],
searchParam
:
'
search_invited
'
,
placeholder
:
s__
(
'
Members|Search invited
'
),
recentSearchesStorageKey
:
'
group_invited_members
'
,
},
},
[
MEMBER_TYPES
.
accessRequest
]:
{
tableFields
:
SHARED_FIELDS
.
concat
(
'
requested
'
),
requestFormatter
:
groupMemberRequestFormatter
,
},
});
initMembersApp
(
document
.
querySelector
(
'
.js-group-access-requests-list
'
),
{
namespace
:
MEMBER_TYPES
.
accessRequest
,
tableFields
:
SHARED_FIELDS
.
concat
(
'
requested
'
),
requestFormatter
:
groupMemberRequestFormatter
,
});
groupsSelect
();
...
...
app/assets/javascripts/pages/projects/project_members/index.js
View file @
b65a7c7a
...
...
@@ -42,46 +42,41 @@ initInviteMembersForm();
new
UsersSelect
();
// eslint-disable-line no-new
const
SHARED_FIELDS
=
[
'
account
'
,
'
expires
'
,
'
maxRole
'
,
'
expiration
'
,
'
actions
'
];
initMembersApp
(
document
.
querySelector
(
'
.js-project-members-list
'
),
{
namespace
:
MEMBER_TYPES
.
user
,
tableFields
:
SHARED_FIELDS
.
concat
([
'
source
'
,
'
granted
'
]),
tableAttrs
:
{
tr
:
{
'
data-qa-selector
'
:
'
member_row
'
}
},
tableSortableFields
:
[
'
account
'
,
'
granted
'
,
'
maxRole
'
,
'
lastSignIn
'
],
requestFormatter
:
projectMemberRequestFormatter
,
filteredSearchBar
:
{
show
:
true
,
tokens
:
[
'
with_inherited_permissions
'
],
searchParam
:
'
search
'
,
placeholder
:
s__
(
'
Members|Filter members
'
),
recentSearchesStorageKey
:
'
project_members
'
,
initMembersApp
(
document
.
querySelector
(
'
.js-project-members-list-app
'
),
{
[
MEMBER_TYPES
.
user
]:
{
tableFields
:
SHARED_FIELDS
.
concat
([
'
source
'
,
'
granted
'
]),
tableAttrs
:
{
tr
:
{
'
data-qa-selector
'
:
'
member_row
'
}
},
tableSortableFields
:
[
'
account
'
,
'
granted
'
,
'
maxRole
'
,
'
lastSignIn
'
],
requestFormatter
:
projectMemberRequestFormatter
,
filteredSearchBar
:
{
show
:
true
,
tokens
:
[
'
with_inherited_permissions
'
],
searchParam
:
'
search
'
,
placeholder
:
s__
(
'
Members|Filter members
'
),
recentSearchesStorageKey
:
'
project_members
'
,
},
},
});
initMembersApp
(
document
.
querySelector
(
'
.js-project-group-links-list
'
),
{
namespace
:
MEMBER_TYPES
.
group
,
tableFields
:
SHARED_FIELDS
.
concat
(
'
granted
'
),
tableAttrs
:
{
table
:
{
'
data-qa-selector
'
:
'
groups_list
'
},
tr
:
{
'
data-qa-selector
'
:
'
group_row
'
},
[
MEMBER_TYPES
.
group
]:
{
tableFields
:
SHARED_FIELDS
.
concat
(
'
granted
'
),
tableAttrs
:
{
table
:
{
'
data-qa-selector
'
:
'
groups_list
'
},
tr
:
{
'
data-qa-selector
'
:
'
group_row
'
},
},
requestFormatter
:
groupLinkRequestFormatter
,
filteredSearchBar
:
{
show
:
true
,
tokens
:
[],
searchParam
:
'
search_groups
'
,
placeholder
:
s__
(
'
Members|Search groups
'
),
recentSearchesStorageKey
:
'
project_group_links
'
,
},
},
requestFormatter
:
groupLinkRequestFormatter
,
filteredSearchBar
:
{
show
:
true
,
tokens
:
[]
,
searchParam
:
'
search_groups
'
,
placeholder
:
s__
(
'
Members|Search groups
'
),
re
centSearchesStorageKey
:
'
project_group_links
'
,
[
MEMBER_TYPES
.
invite
]:
{
tableFields
:
SHARED_FIELDS
.
concat
(
'
invited
'
),
requestFormatter
:
projectMemberRequestFormatter
,
}
,
[
MEMBER_TYPES
.
accessRequest
]:
{
tableFields
:
SHARED_FIELDS
.
concat
(
'
requested
'
),
re
questFormatter
:
projectMemberRequestFormatter
,
},
});
initMembersApp
(
document
.
querySelector
(
'
.js-project-invited-members-list
'
),
{
namespace
:
MEMBER_TYPES
.
invite
,
tableFields
:
SHARED_FIELDS
.
concat
(
'
invited
'
),
requestFormatter
:
projectMemberRequestFormatter
,
});
initMembersApp
(
document
.
querySelector
(
'
.js-project-access-requests-list
'
),
{
namespace
:
MEMBER_TYPES
.
accessRequest
,
tableFields
:
SHARED_FIELDS
.
concat
(
'
requested
'
),
requestFormatter
:
projectMemberRequestFormatter
,
});
app/helpers/groups/group_members_helper.rb
View file @
b65a7c7a
...
...
@@ -13,12 +13,15 @@ module Groups::GroupMembersHelper
render
'shared/members/invite_member'
,
submit_url:
group_group_members_path
(
group
),
access_levels:
group
.
access_level_roles
,
default_access_level:
default_access_level
end
def
group_members_list_data_json
(
group
,
members
,
pagination
=
{})
group_members_list_data
(
group
,
members
,
pagination
).
to_json
end
def
group_group_links_list_data_json
(
group
)
group_group_links_list_data
(
group
).
to_json
def
group_members_app_data_json
(
group
,
members
:,
invited
:,
access_requests
:)
{
user:
group_members_list_data
(
group
,
members
,
{
param_name: :page
,
params:
{
invited_members_page:
nil
,
search_invited:
nil
}
}),
group:
group_group_links_list_data
(
group
),
invite:
group_members_list_data
(
group
,
invited
.
nil?
?
[]
:
invited
,
{
param_name: :invited_members_page
,
params:
{
page:
nil
}
}),
access_request:
group_members_list_data
(
group
,
access_requests
.
nil?
?
[]
:
access_requests
),
source_id:
group
.
id
,
can_manage_members:
can?
(
current_user
,
:admin_group_member
,
group
)
}.
to_json
end
private
...
...
@@ -32,13 +35,11 @@ module Groups::GroupMembersHelper
end
# Overridden in `ee/app/helpers/ee/groups/group_members_helper.rb`
def
group_members_list_data
(
group
,
members
,
pagination
)
def
group_members_list_data
(
group
,
members
,
pagination
=
{}
)
{
members:
group_members_serialized
(
group
,
members
),
pagination:
members_pagination_data
(
members
,
pagination
),
member_path:
group_group_member_path
(
group
,
':id'
),
source_id:
group
.
id
,
can_manage_members:
can?
(
current_user
,
:admin_group_member
,
group
)
member_path:
group_group_member_path
(
group
,
':id'
)
}
end
...
...
@@ -48,8 +49,7 @@ module Groups::GroupMembersHelper
{
members:
group_group_links_serialized
(
group_links
),
pagination:
members_pagination_data
(
group_links
),
member_path:
group_group_link_path
(
group
,
':id'
),
source_id:
group
.
id
member_path:
group_group_link_path
(
group
,
':id'
)
}
end
end
...
...
app/helpers/projects/project_members_helper.rb
View file @
b65a7c7a
...
...
@@ -27,12 +27,15 @@ module Projects::ProjectMembersHelper
project
.
group
.
has_owner?
(
current_user
)
end
def
project_members_list_data_json
(
project
,
members
,
pagination
=
{})
project_members_list_data
(
project
,
members
,
pagination
).
to_json
end
def
project_group_links_list_data_json
(
project
,
group_links
)
project_group_links_list_data
(
project
,
group_links
).
to_json
def
project_members_app_data_json
(
project
,
members
:,
group_links
:,
invited
:,
access_requests
:)
{
user:
project_members_list_data
(
project
,
members
,
{
param_name: :page
,
params:
{
search_groups:
nil
}
}),
group:
project_group_links_list_data
(
project
,
group_links
),
invite:
project_members_list_data
(
project
,
invited
.
nil?
?
[]
:
invited
),
access_request:
project_members_list_data
(
project
,
access_requests
.
nil?
?
[]
:
access_requests
),
source_id:
project
.
id
,
can_manage_members:
can_manage_project_members?
(
project
)
}.
to_json
end
private
...
...
@@ -45,13 +48,11 @@ module Projects::ProjectMembersHelper
GroupLink
::
ProjectGroupLinkSerializer
.
new
.
represent
(
group_links
,
{
current_user:
current_user
})
end
def
project_members_list_data
(
project
,
members
,
pagination
)
def
project_members_list_data
(
project
,
members
,
pagination
=
{}
)
{
members:
project_members_serialized
(
project
,
members
),
pagination:
members_pagination_data
(
members
,
pagination
),
member_path:
project_project_member_path
(
project
,
':id'
),
source_id:
project
.
id
,
can_manage_members:
can_manage_project_members?
(
project
)
member_path:
project_project_member_path
(
project
,
':id'
)
}
end
...
...
@@ -59,9 +60,7 @@ module Projects::ProjectMembersHelper
{
members:
project_group_links_serialized
(
group_links
),
pagination:
members_pagination_data
(
group_links
),
member_path:
project_group_link_path
(
project
,
':id'
),
source_id:
project
.
id
,
can_manage_members:
can_manage_project_members?
(
project
)
member_path:
project_group_link_path
(
project
,
':id'
)
}
end
end
app/views/groups/group_members/index.html.haml
View file @
b65a7c7a
-
add_page_specific_style
'page_bundles/members'
-
page_title
_
(
'Group members'
)
-
show_invited_members
=
can_manage_members?
&&
@invited_members
.
load
.
any?
-
show_access_requests
=
can_manage_members?
&&
@requesters
.
load
.
any?
-
invited_active
=
params
[
:search_invited
].
present?
||
params
[
:invited_members_page
].
present?
.js-remove-member-modal
.row.gl-mt-3
...
...
@@ -35,47 +32,9 @@
=
render_if_exists
'groups/group_members/ldap_sync'
%ul
.nav-links.mobile-separator.nav.nav-tabs
%li
.nav-item
=
link_to
'#tab-members'
,
class:
[
'nav-link'
,
(
'active'
unless
invited_active
)],
data:
{
toggle:
'tab'
}
do
%span
=
_
(
'Members'
)
%span
.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm
=
@members
.
total_count
-
if
@group
.
shared_with_group_links
.
present?
%li
.nav-item
=
link_to
'#tab-groups'
,
class:
[
'nav-link'
]
,
data:
{
toggle:
'tab'
,
qa_selector:
'groups_list_tab'
}
do
%span
=
_
(
'Groups'
)
%span
.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm
=
@group
.
shared_with_group_links
.
count
-
if
show_invited_members
%li
.nav-item
=
link_to
'#tab-invited-members'
,
class:
[
'nav-link'
,
(
'active'
if
invited_active
)],
data:
{
toggle:
'tab'
}
do
%span
=
_
(
'Invited'
)
%span
.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm
=
@invited_members
.
total_count
-
if
show_access_requests
%li
.nav-item
=
link_to
'#tab-access-requests'
,
class:
'nav-link'
,
data:
{
toggle:
'tab'
}
do
%span
=
_
(
'Access requests'
)
%span
.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm
=
@requesters
.
count
.tab-content
#tab-members
.tab-pane
{
class:
(
'active'
unless
invited_active
)
}
.js-group-members-list
{
data:
{
members_data:
group_members_list_data_json
(
@group
,
@members
,
{
param_name: :page
,
params:
{
invited_members_page:
nil
,
search_invited:
nil
}
})
}
}
.loading
.gl-spinner.gl-spinner-md
-
if
@group
.
shared_with_group_links
.
present?
#tab-groups
.tab-pane
.js-group-group-links-list
{
data:
{
members_data:
group_group_links_list_data_json
(
@group
)
}
}
.loading
.gl-spinner.gl-spinner-md
-
if
show_invited_members
#tab-invited-members
.tab-pane
{
class:
(
'active'
if
invited_active
)
}
.js-group-invited-members-list
{
data:
{
members_data:
group_members_list_data_json
(
@group
,
@invited_members
,
{
param_name: :invited_members_page
,
params:
{
page:
nil
}
})
}
}
.loading
.gl-spinner.gl-spinner-md
-
if
show_access_requests
#tab-access-requests
.tab-pane
.js-group-access-requests-list
{
data:
{
members_data:
group_members_list_data_json
(
@group
,
@requesters
)
}
}
.loading
.gl-spinner.gl-spinner-md
.js-group-members-list-app
{
data:
{
members_data:
group_members_app_data_json
(
@group
,
members:
@members
,
invited:
@invited_members
,
access_requests:
@requesters
)
}
}
.loading
.gl-spinner.gl-spinner-md
app/views/projects/project_members/index.html.haml
View file @
b65a7c7a
...
...
@@ -56,47 +56,10 @@
.invite-member
=
render
'shared/members/invite_member'
,
submit_url:
project_project_members_path
(
@project
),
access_levels:
ProjectMember
.
access_level_roles
,
default_access_level:
@project_member
.
access_level
,
can_import_members?:
can_import_members?
,
import_path:
import_project_project_members_path
(
@project
)
-
elsif
@project
.
allowed_to_share_with_group?
.invite-group
=
render
'shared/members/invite_group'
,
access_levels:
ProjectGroupLink
.
access_options
,
default_access_level:
ProjectGroupLink
.
default_access
,
submit_url:
project_group_links_path
(
@project
),
group_link_field:
'link_group_id'
,
group_access_field:
'link_group_access'
%ul
.nav-links.mobile-separator.nav.nav-tabs
%li
.nav-item
=
link_to
'#tab-members'
,
class:
[
'nav-link'
,
(
'active'
unless
groups_tab_active?
)],
data:
{
toggle:
'tab'
}
do
%span
=
_
(
'Members'
)
%span
.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm
=
@project_members
.
total_count
-
if
show_groups?
(
@group_links
)
%li
.nav-item
=
link_to
'#tab-groups'
,
class:
[
'nav-link'
,
(
'active'
if
groups_tab_active?
)]
,
data:
{
toggle:
'tab'
,
qa_selector:
'groups_list_tab'
}
do
%span
=
_
(
'Groups'
)
%span
.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm
=
@group_links
.
count
-
if
show_invited_members?
(
@project
,
@invited_members
)
%li
.nav-item
=
link_to
'#tab-invited-members'
,
class:
'nav-link'
,
data:
{
toggle:
'tab'
}
do
%span
=
_
(
'Invited'
)
%span
.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm
=
@invited_members
.
count
-
if
show_access_requests?
(
@project
,
@requesters
)
%li
.nav-item
=
link_to
'#tab-access-requests'
,
class:
'nav-link'
,
data:
{
toggle:
'tab'
}
do
%span
=
_
(
'Access requests'
)
%span
.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm
=
@requesters
.
count
.tab-content
#tab-members
.tab-pane
{
class:
(
'active'
unless
groups_tab_active?
)
}
.js-project-members-list
{
data:
{
members_data:
project_members_list_data_json
(
@project
,
@project_members
,
{
param_name: :page
,
params:
{
search_groups:
nil
}
})
}
}
.loading
.gl-spinner.gl-spinner-md
-
if
show_groups?
(
@group_links
)
#tab-groups
.tab-pane
{
class:
(
'active'
if
groups_tab_active?
)
}
.js-project-group-links-list
{
data:
{
members_data:
project_group_links_list_data_json
(
@project
,
@group_links
)
}
}
.loading
.gl-spinner.gl-spinner-md
-
if
show_invited_members?
(
@project
,
@invited_members
)
#tab-invited-members
.tab-pane
.js-project-invited-members-list
{
data:
{
members_data:
project_members_list_data_json
(
@project
,
@invited_members
)
}
}
.loading
.gl-spinner.gl-spinner-md
-
if
show_access_requests?
(
@project
,
@requesters
)
#tab-access-requests
.tab-pane
.js-project-access-requests-list
{
data:
{
members_data:
project_members_list_data_json
(
@project
,
@requesters
)
}
}
.loading
.gl-spinner.gl-spinner-md
.js-project-members-list-app
{
data:
{
members_data:
project_members_app_data_json
(
@project
,
members:
@project_members
,
group_links:
@group_links
,
invited:
@invited_members
,
access_requests:
@requesters
)
}
}
.loading
.gl-spinner.gl-spinner-md
changelogs/unreleased/324681-group-project-members-migrate-to-one-vue-app-and-gltabs-2.yml
0 → 100644
View file @
b65a7c7a
---
title
:
Update group/project member tabs to comply with Pajamas design system
merge_request
:
author
:
type
:
other
ee/spec/frontend/members/index_spec.js
View file @
b65a7c7a
...
...
@@ -8,7 +8,7 @@ describe('initMembersApp', () => {
const
createVm
=
()
=>
{
vm
=
initMembersApp
(
el
,
{
namespace
:
MEMBER_TYPES
.
user
,
[
MEMBER_TYPES
.
user
]:
{}
,
});
};
...
...
ee/spec/helpers/ee/groups/group_members_helper_spec.rb
View file @
b65a7c7a
...
...
@@ -22,8 +22,17 @@ RSpec.describe Groups::GroupMembersHelper do
end
end
describe
'#group_members_list_data_json'
do
subject
{
Gitlab
::
Json
.
parse
(
helper
.
group_members_list_data_json
(
group
,
[]))
}
describe
'#group_members_app_data_json'
do
subject
do
Gitlab
::
Json
.
parse
(
helper
.
group_members_app_data_json
(
group
,
members:
[],
invited:
[],
access_requests:
[]
)
)
end
before
do
allow
(
helper
).
to
receive
(
:override_group_group_member_path
).
with
(
group
,
':id'
).
and_return
(
'/groups/foo-bar/-/group_members/:id/override'
)
...
...
@@ -32,7 +41,7 @@ RSpec.describe Groups::GroupMembersHelper do
end
it
'adds `ldap_override_path` to returned json'
do
expect
(
subject
[
'ldap_override_path'
]).
to
eq
(
'/groups/foo-bar/-/group_members/:id/override'
)
expect
(
subject
[
'
user'
][
'
ldap_override_path'
]).
to
eq
(
'/groups/foo-bar/-/group_members/:id/override'
)
end
end
end
qa/qa/page/group/members.rb
View file @
b65a7c7a
...
...
@@ -26,7 +26,7 @@ module QA
element
:delete_member_button
end
view
'app/
views/groups/group_members/index.html.haml
'
do
view
'app/
assets/javascripts/members/components/members_tabs.vue
'
do
element
:groups_list_tab
end
...
...
qa/qa/page/project/members.rb
View file @
b65a7c7a
...
...
@@ -6,7 +6,7 @@ module QA
class
Members
<
Page
::
Base
include
QA
::
Page
::
Component
::
InviteMembersModal
view
'app/
views/projects/project_members/index.html.haml
'
do
view
'app/
assets/javascripts/members/components/members_tabs.vue
'
do
element
:groups_list_tab
end
...
...
spec/features/groups/members/tabs_spec.rb
View file @
b65a7c7a
...
...
@@ -2,7 +2,7 @@
require
'spec_helper'
RSpec
.
describe
'Groups > Members > Tabs'
do
RSpec
.
describe
'Groups > Members > Tabs'
,
:js
do
using
RSpec
::
Parameterized
::
TableSyntax
shared_examples
'active "Members" tab'
do
...
...
@@ -56,7 +56,7 @@ RSpec.describe 'Groups > Members > Tabs' do
it_behaves_like
'active "Members" tab'
end
context
'when searching "Invited"'
,
:js
do
context
'when searching "Invited"'
do
before
do
visit
group_group_members_path
(
group
)
...
...
@@ -86,7 +86,7 @@ RSpec.describe 'Groups > Members > Tabs' do
end
end
context
'when using "Invited" pagination'
,
:js
do
context
'when using "Invited" pagination'
do
before
do
visit
group_group_members_path
(
group
)
...
...
spec/features/projects/members/tabs_spec.rb
View file @
b65a7c7a
...
...
@@ -2,7 +2,7 @@
require
'spec_helper'
RSpec
.
describe
'Projects > Members > Tabs'
do
RSpec
.
describe
'Projects > Members > Tabs'
,
:js
do
include
Spec
::
Support
::
Helpers
::
Features
::
MembersHelpers
using
RSpec
::
Parameterized
::
TableSyntax
...
...
@@ -44,7 +44,7 @@ RSpec.describe 'Projects > Members > Tabs' do
end
end
context
'when searching "Groups"'
,
:js
do
context
'when searching "Groups"'
do
before
do
click_link
'Groups'
...
...
spec/frontend/members/components/app_spec.js
View file @
b65a7c7a
...
...
@@ -33,7 +33,7 @@ describe('MembersApp', () => {
wrapper
=
shallowMount
(
MembersApp
,
{
localVue
,
pro
vide
:
{
pro
psData
:
{
namespace
:
MEMBER_TYPES
.
user
,
},
store
,
...
...
spec/frontend/members/components/members_tabs_spec.js
View file @
b65a7c7a
...
...
@@ -6,7 +6,7 @@ import MembersTabs from '~/members/components/members_tabs.vue';
import
{
MEMBER_TYPES
}
from
'
~/members/constants
'
;
import
{
pagination
}
from
'
../mock_data
'
;
describe
(
'
Members
App
'
,
()
=>
{
describe
(
'
Members
Tabs
'
,
()
=>
{
Vue
.
use
(
Vuex
);
let
wrapper
;
...
...
@@ -111,10 +111,10 @@ describe('MembersApp', () => {
const
membersApps
=
wrapper
.
findAllComponents
(
MembersApp
).
wrappers
;
expect
(
membersApps
[
0
].
attribute
s
(
'
namespace
'
)).
toBe
(
MEMBER_TYPES
.
user
);
expect
(
membersApps
[
1
].
attribute
s
(
'
namespace
'
)).
toBe
(
MEMBER_TYPES
.
group
);
expect
(
membersApps
[
2
].
attribute
s
(
'
namespace
'
)).
toBe
(
MEMBER_TYPES
.
invite
);
expect
(
membersApps
[
3
].
attribute
s
(
'
namespace
'
)).
toBe
(
MEMBER_TYPES
.
accessRequest
);
expect
(
membersApps
[
0
].
prop
s
(
'
namespace
'
)).
toBe
(
MEMBER_TYPES
.
user
);
expect
(
membersApps
[
1
].
prop
s
(
'
namespace
'
)).
toBe
(
MEMBER_TYPES
.
group
);
expect
(
membersApps
[
2
].
prop
s
(
'
namespace
'
)).
toBe
(
MEMBER_TYPES
.
invite
);
expect
(
membersApps
[
3
].
prop
s
(
'
namespace
'
)).
toBe
(
MEMBER_TYPES
.
accessRequest
);
});
});
...
...
spec/frontend/members/index_spec.js
View file @
b65a7c7a
import
{
createWrapper
}
from
'
@vue/test-utils
'
;
import
Members
App
from
'
~/members/components/app
.vue
'
;
import
Members
Tabs
from
'
~/members/components/members_tabs
.vue
'
;
import
{
MEMBER_TYPES
}
from
'
~/members/constants
'
;
import
{
initMembersApp
}
from
'
~/members/index
'
;
import
{
members
,
pagination
,
dataAttribute
}
from
'
./mock_data
'
;
...
...
@@ -11,12 +11,13 @@ describe('initMembersApp', () => {
const
setup
=
()
=>
{
vm
=
initMembersApp
(
el
,
{
namespace
:
MEMBER_TYPES
.
user
,
tableFields
:
[
'
account
'
],
tableAttrs
:
{
table
:
{
'
data-qa-selector
'
:
'
members_list
'
}
},
tableSortableFields
:
[
'
account
'
],
requestFormatter
:
()
=>
({}),
filteredSearchBar
:
{
show
:
false
},
[
MEMBER_TYPES
.
user
]:
{
tableFields
:
[
'
account
'
],
tableAttrs
:
{
table
:
{
'
data-qa-selector
'
:
'
members_list
'
}
},
tableSortableFields
:
[
'
account
'
],
requestFormatter
:
()
=>
({}),
filteredSearchBar
:
{
show
:
false
},
},
});
wrapper
=
createWrapper
(
vm
);
};
...
...
@@ -35,10 +36,10 @@ describe('initMembersApp', () => {
wrapper
=
null
;
});
it
(
'
renders `Members
App
`
'
,
()
=>
{
it
(
'
renders `Members
Tabs
`
'
,
()
=>
{
setup
();
expect
(
wrapper
.
find
(
Members
App
).
exists
()).
toBe
(
true
);
expect
(
wrapper
.
find
(
Members
Tabs
).
exists
()).
toBe
(
true
);
});
it
(
'
parses and sets `members` in Vuex store
'
,
()
=>
{
...
...
spec/frontend/members/mock_data.js
View file @
b65a7c7a
import
{
MEMBER_TYPES
}
from
'
~/members/constants
'
;
export
const
member
=
{
requestedAt
:
null
,
canUpdate
:
false
,
...
...
@@ -97,10 +99,12 @@ export const pagination = {
};
export
const
dataAttribute
=
JSON
.
stringify
({
members
,
pagination
:
paginationData
,
[
MEMBER_TYPES
.
user
]:
{
members
,
pagination
:
paginationData
,
member_path
:
'
/groups/foo-bar/-/group_members/:id
'
,
ldap_override_path
:
'
/groups/ldap-group/-/group_members/:id/override
'
,
},
source_id
:
234
,
can_manage_members
:
true
,
member_path
:
'
/groups/foo-bar/-/group_members/:id
'
,
ldap_override_path
:
'
/groups/ldap-group/-/group_members/:id/override
'
,
});
spec/frontend/members/utils_spec.js
View file @
b65a7c7a
import
{
DEFAULT_SORT
}
from
'
~/members/constants
'
;
import
{
DEFAULT_SORT
,
MEMBER_TYPES
}
from
'
~/members/constants
'
;
import
{
generateBadges
,
isGroup
,
...
...
@@ -268,11 +268,13 @@ describe('Members Utils', () => {
it
(
'
correctly parses the data attribute
'
,
()
=>
{
expect
(
parseDataAttributes
(
el
)).
toMatchObject
({
members
,
pagination
,
[
MEMBER_TYPES
.
user
]:
{
members
,
pagination
,
memberPath
:
'
/groups/foo-bar/-/group_members/:id
'
,
},
sourceId
:
234
,
canManageMembers
:
true
,
memberPath
:
'
/groups/foo-bar/-/group_members/:id
'
,
});
});
});
...
...
spec/helpers/groups/group_members_helper_spec.rb
View file @
b65a7c7a
...
...
@@ -23,58 +23,79 @@ RSpec.describe Groups::GroupMembersHelper do
end
end
describe
'#group_members_list_data_json'
do
let
(
:group_members
)
{
create_list
(
:group_member
,
2
,
group:
group
,
created_by:
current_user
)
}
let
(
:pagination
)
{
{}
}
let
(
:collection
)
{
group_members
}
let
(
:presented_members
)
{
present_members
(
collection
)
}
describe
'#group_members_app_data_json'
do
include_context
'group_group_link'
subject
{
Gitlab
::
Json
.
parse
(
helper
.
group_members_list_data_json
(
group
,
presented_members
,
pagination
))
}
let
(
:members
)
{
create_list
(
:group_member
,
2
,
group:
shared_group
,
created_by:
current_user
)
}
let
(
:invited
)
{
create_list
(
:group_member
,
2
,
:invited
,
group:
shared_group
,
created_by:
current_user
)
}
let!
(
:access_requests
)
{
create_list
(
:group_member
,
2
,
:access_request
,
group:
shared_group
,
created_by:
current_user
)
}
let
(
:members_collection
)
{
members
}
subject
do
Gitlab
::
Json
.
parse
(
helper
.
group_members_app_data_json
(
shared_group
,
members:
present_members
(
members_collection
),
invited:
present_members
(
invited
),
access_requests:
present_members
(
access_requests
)
)
)
end
shared_examples
'members.json'
do
shared_examples
'members.json'
do
|
member_type
|
it
'returns `members` property that matches json schema'
do
expect
(
subject
[
'members'
].
to_json
).
to
match_schema
(
'members'
)
expect
(
subject
[
member_type
][
'members'
].
to_json
).
to
match_schema
(
'members'
)
end
it
'sets `member_path` property'
do
expect
(
subject
[
member_type
][
'member_path'
]).
to
eq
(
'/groups/foo-bar/-/group_members/:id'
)
end
end
before
do
allow
(
helper
).
to
receive
(
:group_group_member_path
).
with
(
group
,
':id'
).
and_return
(
'/groups/foo-bar/-/group_members/:id'
)
allow
(
helper
).
to
receive
(
:can?
).
with
(
current_user
,
:admin_group_member
,
group
).
and_return
(
true
)
allow
(
helper
).
to
receive
(
:group_group_member_path
).
with
(
shared_group
,
':id'
).
and_return
(
'/groups/foo-bar/-/group_members/:id'
)
allow
(
helper
).
to
receive
(
:group_group_link_path
).
with
(
shared_group
,
':id'
).
and_return
(
'/groups/foo-bar/-/group_links/:id'
)
allow
(
helper
).
to
receive
(
:can?
).
with
(
current_user
,
:admin_group_member
,
shared_group
).
and_return
(
true
)
end
it
'returns expected json'
do
expected
=
{
member_path:
'/groups/foo-bar/-/group_members/:id'
,
source_id:
group
.
id
,
source_id:
shared_group
.
id
,
can_manage_members:
true
}.
as_json
expect
(
subject
).
to
include
(
expected
)
end
context
'
for a group member
'
do
it_behaves_like
'members.json'
context
'
group members
'
do
it_behaves_like
'members.json'
,
'user'
context
'with user status set'
do
let
(
:user
)
{
create
(
:user
)
}
let!
(
:status
)
{
create
(
:user_status
,
user:
user
)
}
let
(
:
group_members
)
{
[
create
(
:group_member
,
group:
group
,
user:
user
,
created_by:
current_user
)]
}
let
(
:
members
)
{
[
create
(
:group_member
,
group:
shared_
group
,
user:
user
,
created_by:
current_user
)]
}
it_behaves_like
'members.json'
it_behaves_like
'members.json'
,
'user'
end
end
context
'for an invited group member'
do
let
(
:group_members
)
{
create_list
(
:group_member
,
2
,
:invited
,
group:
group
,
created_by:
current_user
)
}
context
'invited group members'
do
it_behaves_like
'members.json'
,
'invite'
end
it_behaves_like
'members.json'
context
'access requests'
do
it_behaves_like
'members.json'
,
'access_request'
end
context
'for an access request'
do
let
(
:group_members
)
{
create_list
(
:group_member
,
2
,
:access_request
,
group:
group
,
created_by:
current_user
)
}
context
'group links'
do
it
'sets `group.members` property that matches json schema'
do
expect
(
subject
[
'group'
][
'members'
].
to_json
).
to
match_schema
(
'group_link/group_group_links'
)
end
it_behaves_like
'members.json'
it
'sets `member_path` property'
do
expect
(
subject
[
'group'
][
'member_path'
]).
to
eq
(
'/groups/foo-bar/-/group_links/:id'
)
end
end
context
'when pagination is not available'
do
...
...
@@ -87,13 +108,12 @@ RSpec.describe Groups::GroupMembersHelper do
params:
{}
}.
as_json
expect
(
subject
[
'pagination'
]).
to
include
(
expected
)
expect
(
subject
[
'
access_request'
][
'
pagination'
]).
to
include
(
expected
)
end
end
context
'when pagination is available'
do
let
(
:collection
)
{
Kaminari
.
paginate_array
(
group_members
).
page
(
1
).
per
(
1
)
}
let
(
:pagination
)
{
{
param_name: :page
,
params:
{
search_groups:
nil
}
}
}
let
(
:members_collection
)
{
Kaminari
.
paginate_array
(
members
).
page
(
1
).
per
(
1
)
}
it
'sets `pagination` attribute to expected json'
do
expected
=
{
...
...
@@ -101,41 +121,11 @@ RSpec.describe Groups::GroupMembersHelper do
per_page:
1
,
total_items:
2
,
param_name: :page
,
params:
{
search_groups
:
nil
}
params:
{
invited_members_page:
nil
,
search_invited
:
nil
}
}.
as_json
expect
(
subject
[
'pagination'
]).
to
include
(
expected
)
expect
(
subject
[
'
user'
][
'
pagination'
]).
to
include
(
expected
)
end
end
end
describe
'#group_group_links_list_data_json'
do
include_context
'group_group_link'
subject
{
Gitlab
::
Json
.
parse
(
helper
.
group_group_links_list_data_json
(
shared_group
))
}
before
do
allow
(
helper
).
to
receive
(
:group_group_link_path
).
with
(
shared_group
,
':id'
).
and_return
(
'/groups/foo-bar/-/group_links/:id'
)
end
it
'returns expected json'
do
expected
=
{
pagination:
{
current_page:
nil
,
per_page:
nil
,
total_items:
1
,
param_name:
nil
,
params:
{}
},
member_path:
'/groups/foo-bar/-/group_links/:id'
,
source_id:
shared_group
.
id
}.
as_json
expect
(
subject
).
to
include
(
expected
)
end
it
'returns `members` property that matches json schema'
do
expect
(
subject
[
'members'
].
to_json
).
to
match_schema
(
'group_link/group_group_links'
)
end
end
end
spec/helpers/projects/project_members_helper_spec.rb
View file @
b65a7c7a
...
...
@@ -147,16 +147,27 @@ RSpec.describe Projects::ProjectMembersHelper do
end
describe
'project members'
do
let_it_be
(
:project_members
)
{
create_list
(
:project_member
,
2
,
project:
project
)
}
let_it_be
(
:members
)
{
create_list
(
:project_member
,
2
,
project:
project
)
}
let_it_be
(
:group_links
)
{
create_list
(
:project_group_link
,
1
,
project:
project
)
}
let_it_be
(
:invited
)
{
create_list
(
:project_member
,
2
,
:invited
,
project:
project
)
}
let_it_be
(
:access_requests
)
{
create_list
(
:project_member
,
2
,
:access_request
,
project:
project
)
}
let
(
:collection
)
{
project_members
}
let
(
:presented_members
)
{
present_members
(
collection
)
}
let
(
:members_collection
)
{
members
}
describe
'#project_members_
list
_data_json'
do
describe
'#project_members_
app
_data_json'
do
let
(
:allow_admin_project
)
{
true
}
let
(
:pagination
)
{
{}
}
subject
{
Gitlab
::
Json
.
parse
(
helper
.
project_members_list_data_json
(
project
,
presented_members
,
pagination
))
}
subject
do
Gitlab
::
Json
.
parse
(
helper
.
project_members_app_data_json
(
project
,
members:
present_members
(
members_collection
),
group_links:
group_links
,
invited:
present_members
(
invited
),
access_requests:
present_members
(
access_requests
)
)
)
end
before
do
allow
(
helper
).
to
receive
(
:project_project_member_path
).
with
(
project
,
':id'
).
and_return
(
'/foo-bar/-/project_members/:id'
)
...
...
@@ -164,7 +175,6 @@ RSpec.describe Projects::ProjectMembersHelper do
it
'returns expected json'
do
expected
=
{
member_path:
'/foo-bar/-/project_members/:id'
,
source_id:
project
.
id
,
can_manage_members:
true
}.
as_json
...
...
@@ -172,8 +182,12 @@ RSpec.describe Projects::ProjectMembersHelper do
expect
(
subject
).
to
include
(
expected
)
end
it
'returns `members` property that matches json schema'
do
expect
(
subject
[
'members'
].
to_json
).
to
match_schema
(
'members'
)
it
'sets `members` property that matches json schema'
do
expect
(
subject
[
'user'
][
'members'
].
to_json
).
to
match_schema
(
'members'
)
end
it
'sets `member_path` property'
do
expect
(
subject
[
'user'
][
'member_path'
]).
to
eq
(
'/foo-bar/-/project_members/:id'
)
end
context
'when pagination is not available'
do
...
...
@@ -186,13 +200,12 @@ RSpec.describe Projects::ProjectMembersHelper do
params:
{}
}.
as_json
expect
(
subject
[
'pagination'
]).
to
include
(
expected
)
expect
(
subject
[
'
invite'
][
'
pagination'
]).
to
include
(
expected
)
end
end
context
'when pagination is available'
do
let
(
:collection
)
{
Kaminari
.
paginate_array
(
project_members
).
page
(
1
).
per
(
1
)
}
let
(
:pagination
)
{
{
param_name: :page
,
params:
{
search_groups:
nil
}
}
}
let
(
:members_collection
)
{
Kaminari
.
paginate_array
(
members
).
page
(
1
).
per
(
1
)
}
it
'sets `pagination` attribute to expected json'
do
expected
=
{
...
...
@@ -203,45 +216,9 @@ RSpec.describe Projects::ProjectMembersHelper do
params:
{
search_groups:
nil
}
}.
as_json
expect
(
subject
[
'pagination'
]).
to
match
(
expected
)
expect
(
subject
[
'
user'
][
'
pagination'
]).
to
match
(
expected
)
end
end
end
end
describe
'project group links'
do
let_it_be
(
:project_group_links
)
{
create_list
(
:project_group_link
,
1
,
project:
project
)
}
let
(
:allow_admin_project
)
{
true
}
describe
'#project_group_links_list_data_json'
do
subject
{
Gitlab
::
Json
.
parse
(
helper
.
project_group_links_list_data_json
(
project
,
project_group_links
))
}
before
do
allow
(
helper
).
to
receive
(
:project_group_link_path
).
with
(
project
,
':id'
).
and_return
(
'/foo-bar/-/group_links/:id'
)
allow
(
helper
).
to
receive
(
:can?
).
with
(
current_user
,
:admin_project_member
,
project
).
and_return
(
true
)
end
it
'returns expected json'
do
expected
=
{
pagination:
{
current_page:
nil
,
per_page:
nil
,
total_items:
1
,
param_name:
nil
,
params:
{}
},
member_path:
'/foo-bar/-/group_links/:id'
,
source_id:
project
.
id
,
can_manage_members:
true
}.
as_json
expect
(
subject
).
to
include
(
expected
)
end
it
'returns `members` property that matches json schema'
do
expect
(
subject
[
'members'
].
to_json
).
to
match_schema
(
'group_link/project_group_links'
)
end
end
end
end
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