Commit 2c0f4f4f authored by Peter Hegman's avatar Peter Hegman Committed by Mayra Cabrera

Pass group member data from HAML to Vue

As MVC pass group membership data as JSON from HAML Vue.
parent 433a839a
<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