Commit e5213830 authored by Mayra Cabrera's avatar Mayra Cabrera

Merge branch...

Merge branch '235605-convert-group-members-list-view-from-haml-to-vue-table-layout-components' into 'master'

Pass group member data from HAML to Vue

See merge request gitlab-org/gitlab!40548
parents 1c72c599 2c0f4f4f
<script>
export default {
name: 'GroupMembersApp',
props: {
groupId: {
type: Number,
required: true,
},
currentUserId: {
type: Number,
required: false,
default: null,
},
members: {
type: Array,
required: true,
},
},
};
</script>
<template>
<span>
<!-- Temporary empty template -->
</span>
</template>
import Vue from 'vue';
import App from './components/app.vue';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
export default el => {
if (!el) {
return () => {};
}
return new Vue({
el,
components: { App },
data() {
const { members, groupId, currentUserId } = this.$options.el.dataset;
return {
members: convertObjectPropsToCamelCase(JSON.parse(members), { deep: true }),
groupId: parseInt(groupId, 10),
...(currentUserId ? { currentUserId: parseInt(currentUserId, 10) } : {}),
};
},
render(createElement) {
return createElement('app', {
props: {
members: this.members,
groupId: this.groupId,
currentUserId: this.currentUserId,
},
});
},
});
};
......@@ -4,6 +4,7 @@ import memberExpirationDate from '~/member_expiration_date';
import UsersSelect from '~/users_select';
import groupsSelect from '~/groups_select';
import RemoveMemberModal from '~/vue_shared/components/remove_member_modal.vue';
import initGroupMembersApp from '~/groups/members';
function mountRemoveMemberModal() {
const el = document.querySelector('.js-remove-member-modal');
......@@ -25,6 +26,11 @@ document.addEventListener('DOMContentLoaded', () => {
memberExpirationDate('.js-access-expiration-date-groups');
mountRemoveMemberModal();
initGroupMembersApp(document.querySelector('.js-group-members-list'));
initGroupMembersApp(document.querySelector('.js-group-linked-list'));
initGroupMembersApp(document.querySelector('.js-group-invited-members-list'));
initGroupMembersApp(document.querySelector('.js-group-access-requests-list'));
new Members(); // eslint-disable-line no-new
new UsersSelect(); // eslint-disable-line no-new
});
# frozen_string_literal: true
module Groups::GroupMembersHelper
include AvatarsHelper
AVATAR_SIZE = 40
def group_member_select_options
{ multiple: true, class: 'input-clamp qa-member-select-field ', scope: :all, email_user: true }
end
......@@ -8,6 +12,81 @@ module Groups::GroupMembersHelper
def render_invite_member_for_group(group, default_access_level)
render 'shared/members/invite_member', submit_url: group_group_members_path(group), access_levels: GroupMember.access_level_roles, default_access_level: default_access_level
end
def linked_groups_data_json(group_links)
GroupGroupLinkSerializer.new.represent(group_links).to_json
end
def members_data_json(group, members)
members_data(group, members).to_json
end
private
def members_data(group, members)
members.map do |member|
user = member.user
source = member.source
data = {
id: member.id,
created_at: member.created_at,
expires_at: member.expires_at&.to_time,
requested_at: member.requested_at,
can_update: member.can_update?,
can_remove: member.can_remove?,
can_override: member.can_override?,
access_level: {
string_value: member.human_access,
integer_value: member.access_level
},
source: {
id: source.id,
name: source.full_name,
web_url: Gitlab::UrlBuilder.build(source)
}
}.merge(member_created_by_data(member.created_by))
if user.present?
data[:user] = member_user_data(user)
else
data[:invite] = member_invite_data(member)
end
data
end
end
def member_created_by_data(created_by)
return {} unless created_by.present?
{
created_by: {
name: created_by.name,
web_url: Gitlab::UrlBuilder.build(created_by)
}
}
end
def member_user_data(user)
{
id: user.id,
name: user.name,
username: user.username,
web_url: Gitlab::UrlBuilder.build(user),
avatar_url: avatar_icon_for_user(user, AVATAR_SIZE),
blocked: user.blocked?,
two_factor_enabled: user.two_factor_enabled?
}
end
def member_invite_data(member)
{
email: member.invite_email,
avatar_url: avatar_icon_for_email(member.invite_email, AVATAR_SIZE),
can_resend: member.can_resend_invite?
}
end
end
Groups::GroupMembersHelper.prepend_if_ee('EE::Groups::GroupMembersHelper')
# frozen_string_literal: true
class GroupGroupLinkEntity < Grape::Entity
expose :id
expose :created_at
expose :expires_at do |group_link|
group_link.expires_at&.to_time
end
expose :access_level do
expose :human_access, as: :string_value
expose :group_access, as: :integer_value
end
expose :shared_with_group do
expose :avatar_url do |group_link|
group_link.shared_with_group.avatar_url(only_path: false)
end
expose :web_url do |group_link|
group_link.shared_with_group.web_url
end
expose :shared_with_group, merge: true, using: GroupBasicEntity
end
end
# frozen_string_literal: true
class GroupGroupLinkSerializer < BaseSerializer
entity GroupGroupLinkEntity
end
......@@ -3,6 +3,8 @@
- show_invited_members = can_manage_members && @invited_members.exists?
- show_access_requests = can_manage_members && @requesters.exists?
- invited_active = params[:search_invited].present? || params[:invited_members_page].present?
- vue_members_list_enabled = Feature.enabled?(:vue_group_members_list, @group)
- data_attributes = { group_id: @group.id, current_user_id: current_user&.id }
- form_item_label_css_class = 'label-bold gl-mr-2 gl-mb-0 gl-py-2 align-self-md-center'
......@@ -66,18 +68,24 @@
= render 'groups/group_members/tab_pane/form_item' do
= label_tag :sort_by, _('Sort by'), class: form_item_label_css_class
= render 'shared/members/sort_dropdown'
%ul.content-list.members-list{ data: { qa_selector: 'members_list' } }
= render partial: 'shared/members/member', collection: @members, as: :member
= paginate @members, theme: 'gitlab', params: { invited_members_page: nil, search_invited: nil }
- if vue_members_list_enabled
.js-group-members-list{ data: { members: members_data_json(@group, @members), **data_attributes } }
- else
%ul.content-list.members-list{ data: { qa_selector: 'members_list' } }
= render partial: 'shared/members/member', collection: @members, as: :member
= paginate @members, theme: 'gitlab', params: { invited_members_page: nil, search_invited: nil }
- if @group.shared_with_group_links.any?
#tab-groups.tab-pane
.card.card-without-border
= render 'groups/group_members/tab_pane/header' do
= render 'groups/group_members/tab_pane/title' do
= html_escape(_('Groups with access to %{strong_start}%{group_name}%{strong_end}')) % { group_name: @group.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe }
%ul.content-list.members-list{ data: { qa_selector: 'groups_list' } }
- @group.shared_with_group_links.each do |group_link|
= render 'shared/members/group', group_link: group_link, can_admin_member: can_manage_members, group_link_path: group_group_link_path(@group, group_link)
- if vue_members_list_enabled
.js-group-linked-list{ data: { members: linked_groups_data_json(@group.shared_with_group_links), **data_attributes } }
- else
%ul.content-list.members-list{ data: { qa_selector: 'groups_list' } }
- @group.shared_with_group_links.each do |group_link|
= render 'shared/members/group', group_link: group_link, can_admin_member: can_manage_members, group_link_path: group_group_link_path(@group, group_link)
- if show_invited_members
#tab-invited-members.tab-pane{ class: ('active' if invited_active) }
.card.card-without-border
......@@ -86,14 +94,20 @@
= html_escape(_('Members invited to %{strong_start}%{group_name}%{strong_end}')) % { group_name: @group.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe }
= form_tag group_group_members_path(@group), method: :get, class: 'user-search-form', data: { testid: 'user-search-form' } do
= render 'shared/members/search_field', name: 'search_invited'
%ul.content-list.members-list
= render partial: 'shared/members/member', collection: @invited_members, as: :member
= paginate @invited_members, param_name: 'invited_members_page', theme: 'gitlab', params: { page: nil }
- if vue_members_list_enabled
.js-group-invited-members-list{ data: { members: members_data_json(@group, @invited_members), **data_attributes } }
- else
%ul.content-list.members-list
= render partial: 'shared/members/member', collection: @invited_members, as: :member
= paginate @invited_members, param_name: 'invited_members_page', theme: 'gitlab', params: { page: nil }
- if show_access_requests
#tab-access-requests.tab-pane
.card.card-without-border
= render 'groups/group_members/tab_pane/header' do
= render 'groups/group_members/tab_pane/title' do
= html_escape(_('Users requesting access to %{strong_start}%{group_name}%{strong_end}')) % { group_name: @group.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe }
%ul.content-list.members-list
= render partial: 'shared/members/member', collection: @requesters, as: :member
- if vue_members_list_enabled
.js-group-access-requests-list{ data: { members: members_data_json(@group, @requesters), **data_attributes } }
- else
%ul.content-list.members-list
= render partial: 'shared/members/member', collection: @requesters, as: :member
---
name: vue_group_members_list
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/40548
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/241194
group: group::access
type: development
default_enabled: false
\ No newline at end of file
......@@ -7,4 +7,19 @@ module EE::Groups::GroupMembersHelper
def group_member_select_options
super.merge(skip_ldap: @group.ldap_synced?)
end
private
override :members_data
def members_data(group, members)
ce_members = super(group, members)
members.map.with_index do |member, index|
ce_members[index].merge({
using_license: can?(current_user, :owner_access, group) && member.user&.using_gitlab_com_seat?(group),
group_sso: member.user&.group_sso?(group),
group_managed_account: member.user&.group_managed_account?
})
end
end
end
......@@ -8,6 +8,8 @@ RSpec.describe 'Groups > Audit Events', :js do
let(:group) { create(:group) }
before do
stub_feature_flags(vue_group_members_list: false)
group.add_owner(user)
group.add_developer(alex)
sign_in(user)
......
......@@ -6,6 +6,10 @@ RSpec.describe 'Groups > Members > List members' do
let(:user2) { create(:user, name: 'Mary Jane') }
let(:group) { create(:group) }
before do
stub_feature_flags(vue_group_members_list: false)
end
context 'with Group SAML identity linked for a user' do
let(:saml_provider) { create(:saml_provider) }
let(:group) { saml_provider.group }
......
......@@ -16,6 +16,8 @@ RSpec.describe 'Groups > Members > Maintainer/Owner can override LDAP access lev
let!(:regular_member) { create(:group_member, :guest, group: group, user: maryjane, ldap: false) }
before do
stub_feature_flags(vue_group_members_list: false)
# We need to actually activate the LDAP config otherwise `Group#ldap_synced?` will always be false!
allow(Gitlab.config.ldap).to receive_messages(enabled: true)
......
{
"type": "array",
"items": {
"allOf": [
{ "$ref": "../../../../../spec/fixtures/api/schemas/group_member.json" },
{
"required": ["using_license", "group_sso", "group_managed_account"],
"properties": {
"using_license": { "type": ["boolean", "null"] },
"group_sso": { "type": ["boolean", "null"] },
"group_managed_account": { "type": ["boolean", "null"] }
}
}
]
}
}
......@@ -3,6 +3,8 @@
require "spec_helper"
RSpec.describe Groups::GroupMembersHelper do
include MembersPresentation
describe '.group_member_select_options' do
let(:group) { create(:group) }
......@@ -14,4 +16,35 @@ RSpec.describe Groups::GroupMembersHelper do
expect(helper.group_member_select_options).to include(skip_ldap: false)
end
end
describe '#members_data' do
let(:current_user) { create(:user) }
let(:group) { create(:group) }
let(:group_member) { create(:group_member, group: group, created_by: current_user) }
subject { helper.send('members_data', group, present_members([group_member])) }
before do
allow(helper).to receive(:can?).with(current_user, :owner_access, group).and_return(true)
allow(helper).to receive(:current_user).and_return(current_user)
end
it 'adds `using_license` property to hash' do
allow(group_member.user).to receive(:using_gitlab_com_seat?).with(group).and_return(true)
expect(subject.first).to include(using_license: true)
end
it 'adds `group_sso` property to hash' do
allow(group_member.user).to receive(:group_sso?).with(group).and_return(true)
expect(subject.first).to include(group_sso: true)
end
it 'adds `group_managed_account` property to hash' do
allow(group_member.user).to receive(:group_managed_account?).and_return(true)
expect(subject.first).to include(group_managed_account: true)
end
end
end
......@@ -11,6 +11,8 @@ RSpec.describe 'Admin Groups' do
let!(:current_user) { create(:admin) }
before do
stub_feature_flags(vue_group_members_list: false)
sign_in(current_user)
stub_application_setting(default_group_visibility: internal)
end
......
......@@ -10,6 +10,8 @@ RSpec.describe 'Groups > Members > Filter members' do
let(:nested_group) { create(:group, parent: group) }
before do
stub_feature_flags(vue_group_members_list: false)
group.add_owner(user)
group.add_maintainer(user_with_2fa)
nested_group.add_maintainer(nested_group_user)
......
......@@ -8,6 +8,8 @@ RSpec.describe 'Groups > Members > Leave group' do
let(:group) { create(:group) }
before do
stub_feature_flags(vue_group_members_list: false)
gitlab_sign_in(user)
end
......
......@@ -12,6 +12,8 @@ RSpec.describe 'Groups > Members > List members' do
let(:nested_group) { create(:group, parent: group) }
before do
stub_feature_flags(vue_group_members_list: false)
sign_in(user1)
end
......
......@@ -11,6 +11,8 @@ RSpec.describe 'Groups > Members > Manage groups', :js do
let(:shared_group) { create(:group) }
before do
stub_feature_flags(vue_group_members_list: false)
shared_group.add_owner(user)
sign_in(user)
end
......
......@@ -11,6 +11,8 @@ RSpec.describe 'Groups > Members > Manage members' do
let(:group) { create(:group) }
before do
stub_feature_flags(vue_group_members_list: false)
sign_in(user1)
end
......
......@@ -11,6 +11,8 @@ RSpec.describe 'Groups > Members > Owner adds member with expiration date', :js
let(:group) { create(:group) }
before do
stub_feature_flags(vue_group_members_list: false)
group.add_owner(user1)
sign_in(user1)
end
......
......@@ -3,6 +3,10 @@
require 'spec_helper'
RSpec.describe 'Groups > Members > Maintainer manages access requests' do
before do
stub_feature_flags(vue_group_members_list: false)
end
it_behaves_like 'Maintainer manages access requests' do
let(:has_tabs) { true }
let(:entity) { create(:group, :public) }
......
......@@ -14,6 +14,8 @@ RSpec.describe 'Search group member' do
end
before do
stub_feature_flags(vue_group_members_list: false)
sign_in(user)
visit group_group_members_path(guest_group)
end
......
......@@ -8,6 +8,8 @@ RSpec.describe 'Groups > Members > Sort members' do
let(:group) { create(:group) }
before do
stub_feature_flags(vue_group_members_list: false)
create(:group_member, :owner, user: owner, group: group, created_at: 5.days.ago)
create(:group_member, :developer, user: developer, group: group, created_at: 3.days.ago)
......
{
"type": "object",
"required": ["id", "created_at", "expires_at", "access_level"],
"properties": {
"id": { "type": "integer" },
"created_at": { "type": "date-time" },
"expires_at": { "type": ["date-time", "null"] },
"access_level": {
"type": "object",
"required": ["integer_value", "string_value"],
"properties": {
"integer_value": { "type": "integer" },
"string_value": { "type": "string" }
}
},
"shared_with_group": {
"type": "object",
"required": ["id", "name", "full_name", "full_path", "avatar_url", "web_url"],
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" },
"full_name": { "type": "string" },
"full_path": { "type": "string" },
"avatar_url": { "type": ["string", "null"] },
"web_url": { "type": "string" }
}
}
}
}
{
"type": "array",
"items": {
"$ref": "entities/group_group_link.json"
}
}
{
"type": "object",
"required": [
"id",
"created_at",
"expires_at",
"access_level",
"requested_at",
"source",
"can_update",
"can_remove",
"can_override"
],
"properties": {
"id": { "type": "integer" },
"created_at": { "type": "date-time" },
"expires_at": { "type": ["date-time", "null"] },
"requested_at": { "type": ["date-time", "null"] },
"can_update": { "type": "boolean" },
"can_remove": { "type": "boolean" },
"can_override": { "type": "boolean" },
"access_level": {
"type": "object",
"required": ["integer_value", "string_value"],
"properties": {
"integer_value": { "type": "integer" },
"string_value": { "type": "string" }
}
},
"source": {
"type": "object",
"required": ["id", "name", "web_url"],
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" },
"web_url": { "type": "string" }
}
},
"created_by": {
"type": "object",
"required": ["name", "web_url"],
"properties": {
"name": { "type": "string" },
"web_url": { "type": "string" }
}
},
"user": {
"type": "object",
"required": [
"id",
"name",
"username",
"avatar_url",
"web_url",
"blocked",
"two_factor_enabled"
],
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" },
"username": { "type": "string" },
"avatar_url": { "type": ["string", "null"] },
"web_url": { "type": "string" },
"blocked": { "type": "boolean" },
"two_factor_enabled": { "type": "boolean" }
}
},
"invite": {
"type": "object",
"required": ["email", "avatar_url", "can_resend"],
"properties": {
"email": { "type": "string" },
"avatar_url": { "type": "string" },
"can_resend": { "type": "boolean" }
}
}
}
}
{
"type": "array",
"items": {
"$ref": "group_member.json"
}
}
import { createWrapper } from '@vue/test-utils';
import initGroupMembersApp from '~/groups/members';
import GroupMembersApp from '~/groups/members/components/app.vue';
import { membersJsonString, membersParsed } from './mock_data';
describe('initGroupMembersApp', () => {
let el;
let wrapper;
const setup = () => {
const vm = initGroupMembersApp(el);
wrapper = createWrapper(vm);
};
const getGroupMembersApp = () => wrapper.find(GroupMembersApp);
beforeEach(() => {
el = document.createElement('div');
el.setAttribute('data-members', membersJsonString);
el.setAttribute('data-current-user-id', '123');
el.setAttribute('data-group-id', '234');
document.body.appendChild(el);
});
afterEach(() => {
document.body.innerHTML = '';
el = null;
wrapper.destroy();
wrapper = null;
});
it('parses and passes `currentUserId` prop to `GroupMembersApp`', () => {
setup();
expect(getGroupMembersApp().props('currentUserId')).toBe(123);
});
it('does not pass `currentUserId` prop if not provided by the data attribute (user is not logged in)', () => {
el.removeAttribute('data-current-user-id');
setup();
expect(getGroupMembersApp().props('currentUserId')).toBeNull();
});
it('parses and passes `groupId` prop to `GroupMembersApp`', () => {
setup();
expect(getGroupMembersApp().props('groupId')).toBe(234);
});
it('parses and passes `members` prop to `GroupMembersApp`', () => {
setup();
expect(getGroupMembersApp().props('members')).toEqual(membersParsed);
});
});
export const membersJsonString =
'[{"requested_at":null,"can_update":true,"can_remove":true,"can_override":false,"access_level":{"integer_value":50,"string_value":"Owner"},"source":{"id":323,"name":"My group / my subgroup","web_url":"http://127.0.0.1:3000/groups/my-group/my-subgroup"},"user":{"id":1,"name":"Administrator","username":"root","web_url":"http://127.0.0.1:3000/root","avatar_url":"https://www.gravatar.com/avatar/4816142ef496f956a277bedf1a40607b?s=80\u0026d=identicon","blocked":false,"two_factor_enabled":false},"id":524,"created_at":"2020-08-21T21:33:27.631Z","expires_at":null,"using_license":false,"group_sso":false,"group_managed_account":false}]';
export const membersParsed = [
{
requestedAt: null,
canUpdate: true,
canRemove: true,
canOverride: false,
accessLevel: { integerValue: 50, stringValue: 'Owner' },
source: {
id: 323,
name: 'My group / my subgroup',
webUrl: 'http://127.0.0.1:3000/groups/my-group/my-subgroup',
},
user: {
id: 1,
name: 'Administrator',
username: 'root',
webUrl: 'http://127.0.0.1:3000/root',
avatarUrl:
'https://www.gravatar.com/avatar/4816142ef496f956a277bedf1a40607b?s=80&d=identicon',
blocked: false,
twoFactorEnabled: false,
},
id: 524,
createdAt: '2020-08-21T21:33:27.631Z',
expiresAt: null,
usingLicense: false,
groupSso: false,
groupManagedAccount: false,
},
];
......@@ -3,6 +3,8 @@
require "spec_helper"
RSpec.describe Groups::GroupMembersHelper do
include MembersPresentation
describe '.group_member_select_options' do
let(:group) { create(:group) }
......@@ -14,4 +16,50 @@ RSpec.describe Groups::GroupMembersHelper do
expect(helper.group_member_select_options).to include(multiple: true, scope: :all, email_user: true)
end
end
describe '#linked_groups_data_json' do
include_context 'group_group_link'
it 'matches json schema' do
json = helper.linked_groups_data_json(shared_group.shared_with_group_links)
expect(json).to match_schema('group_group_links')
end
end
describe '#members_data_json' do
let(:current_user) { create(:user) }
let(:group) { create(:group) }
before do
allow(helper).to receive(:can?).with(current_user, :owner_access, group).and_return(true)
allow(helper).to receive(:current_user).and_return(current_user)
end
shared_examples 'group_members.json' do
it 'matches json schema' do
json = helper.members_data_json(group, present_members([group_member]))
expect(json).to match_schema('group_members')
end
end
context 'for a group member' do
let(:group_member) { create(:group_member, group: group, created_by: current_user) }
it_behaves_like 'group_members.json'
end
context 'for an invited group member' do
let(:group_member) { create(:group_member, :invited, group: group, created_by: current_user) }
it_behaves_like 'group_members.json'
end
context 'for an access request' do
let(:group_member) { create(:group_member, :access_request, group: group, created_by: current_user) }
it_behaves_like 'group_members.json'
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GroupGroupLinkEntity do
include_context 'group_group_link'
subject(:json) { described_class.new(group_group_link).to_json }
it 'matches json schema' do
expect(json).to match_schema('entities/group_group_link')
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GroupGroupLinkSerializer do
include_context 'group_group_link'
subject(:json) { described_class.new.represent(shared_group.shared_with_group_links).to_json }
it 'matches json schema' do
expect(json).to match_schema('group_group_links')
end
end
# frozen_string_literal: true
RSpec.shared_context 'group_group_link' do
let(:shared_with_group) { create(:group) }
let(:shared_group) { create(:group) }
let!(:group_group_link) do
create(
:group_group_link,
{
shared_group: shared_group,
shared_with_group: shared_with_group,
expires_at: '2020-05-12'
}
)
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment