diff --git a/app/assets/javascripts/admin/users/components/app.vue b/app/assets/javascripts/admin/users/components/app.vue new file mode 100644 index 0000000000000000000000000000000000000000..5ff2dbbfcb1ea2eb31bfe4ecba5059f4efb2377c --- /dev/null +++ b/app/assets/javascripts/admin/users/components/app.vue @@ -0,0 +1,21 @@ +<script> +export default { + props: { + users: { + type: Array, + required: false, + default: () => [], + }, + paths: { + type: Object, + required: true, + }, + }, +}; +</script> + +<template> + <div> + <!-- Temporary empty app --> + </div> +</template> diff --git a/app/assets/javascripts/admin/users/index.js b/app/assets/javascripts/admin/users/index.js new file mode 100644 index 0000000000000000000000000000000000000000..21780ee99846f76e22e8ba5409c85924b8f0aec7 --- /dev/null +++ b/app/assets/javascripts/admin/users/index.js @@ -0,0 +1,22 @@ +import Vue from 'vue'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import AdminUsersApp from './components/app.vue'; + +export default function(el = document.querySelector('#js-admin-users-app')) { + if (!el) { + return false; + } + + const { users, paths } = el.dataset; + + return new Vue({ + el, + render: createElement => + createElement(AdminUsersApp, { + props: { + users: convertObjectPropsToCamelCase(JSON.parse(users), { deep: true }), + paths: convertObjectPropsToCamelCase(JSON.parse(paths)), + }, + }), + }); +} diff --git a/app/assets/javascripts/pages/admin/users/index.js b/app/assets/javascripts/pages/admin/users/index.js index 62a18200b8a9b94ec953b69d07da0f167ae4c25d..07462b4592f680734e85ceb89f0a83b33ecd4882 100644 --- a/app/assets/javascripts/pages/admin/users/index.js +++ b/app/assets/javascripts/pages/admin/users/index.js @@ -4,6 +4,7 @@ import Translate from '~/vue_shared/translate'; import ModalManager from './components/user_modal_manager.vue'; import csrf from '~/lib/utils/csrf'; import initConfirmModal from '~/confirm_modal'; +import initAdminUsersApp from '~/admin/users'; const MODAL_TEXTS_CONTAINER_SELECTOR = '#js-modal-texts'; const MODAL_MANAGER_SELECTOR = '#js-delete-user-modal'; @@ -56,4 +57,5 @@ document.addEventListener('DOMContentLoaded', () => { }); initConfirmModal(); + initAdminUsersApp(); }); diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb index 6679b6224ed1613f347bb52dafd1f928227006af..a58f8a6f792719ae35ebf2dd9a60af7cb1ac49f5 100644 --- a/app/helpers/users_helper.rb +++ b/app/helpers/users_helper.rb @@ -1,6 +1,13 @@ # frozen_string_literal: true module UsersHelper + def admin_users_data_attributes(users) + { + users: Admin::UserSerializer.new.represent(users).to_json, + paths: admin_users_paths.to_json + } + end + def user_link(user) link_to(user.name, user_path(user), title: user.email, @@ -208,6 +215,22 @@ def user_display_name(user) private + def admin_users_paths + { + edit: edit_admin_user_path(:id), + approve: approve_admin_user_path(:id), + reject: reject_admin_user_path(:id), + unblock: unblock_admin_user_path(:id), + block: block_admin_user_path(:id), + deactivate: deactivate_admin_user_path(:id), + activate: activate_admin_user_path(:id), + unlock: unlock_admin_user_path(:id), + delete: admin_user_path(:id), + delete_with_contributions: admin_user_path(:id), + admin_user: admin_user_path(:id) + } + end + def blocked_user_badge(user) pending_approval_badge = { text: s_('AdminUsers|Pending approval'), variant: 'info' } return pending_approval_badge if user.blocked_pending_approval? diff --git a/app/views/admin/users/index.html.haml b/app/views/admin/users/index.html.haml index 18f6fd51ca57c8eba9ee3a3c67364cc15ec35f98..731d5ff6746b770296d7d02a47486bdeb363076b 100644 --- a/app/views/admin/users/index.html.haml +++ b/app/views/admin/users/index.html.haml @@ -72,6 +72,10 @@ - if @users.empty? .nothing-here-block.border-top-0 = s_('AdminUsers|No users found') +- elsif Feature.enabled?(:vue_admin_users) + #js-admin-users-app{ data: admin_users_data_attributes(@users) } + .gl-spinner-container.gl-my-7 + %span.gl-vertical-align-bottom.gl-spinner.gl-spinner-dark.gl-spinner-lg{ aria: { label: _('Loading') } } - else .table-holder .thead-white.text-nowrap.gl-responsive-table-row.table-row-header{ role: 'row' } diff --git a/config/feature_flags/development/vue_admin_users.yml b/config/feature_flags/development/vue_admin_users.yml new file mode 100644 index 0000000000000000000000000000000000000000..7464a25c0da342f7fbf6c992c7cad2d66678cab8 --- /dev/null +++ b/config/feature_flags/development/vue_admin_users.yml @@ -0,0 +1,8 @@ +--- +name: vue_admin_users +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/48922 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/290737 +milestone: '13.7' +type: development +group: group::compliance +default_enabled: false diff --git a/ee/spec/features/admin/admin_users_spec.rb b/ee/spec/features/admin/admin_users_spec.rb index 84f184fc7766ac7fb34de39b5316c5abf25c22e4..21c314889901c85ca18675e0c9e7b1d0bab07164 100644 --- a/ee/spec/features/admin/admin_users_spec.rb +++ b/ee/spec/features/admin/admin_users_spec.rb @@ -12,6 +12,7 @@ let!(:current_user) { create(:admin, last_activity_on: 5.days.ago) } before do + stub_feature_flags(vue_admin_users: false) sign_in(current_user) gitlab_enable_admin_mode_sign_in(current_user) end diff --git a/spec/features/admin/users/user_spec.rb b/spec/features/admin/users/user_spec.rb index 46380218e9154cbe495b4f1e3f3de785987cc130..e7dd50ed514603e792f1f14b92a5a368bea3f0ea 100644 --- a/spec/features/admin/users/user_spec.rb +++ b/spec/features/admin/users/user_spec.rb @@ -9,6 +9,7 @@ before do sign_in(current_user) gitlab_enable_admin_mode_sign_in(current_user) + stub_feature_flags(vue_admin_users: false) end describe 'GET /admin/users/:id' do diff --git a/spec/features/admin/users/users_spec.rb b/spec/features/admin/users/users_spec.rb index afabdcf4fb72e25b31d152aed8cc96f658ceff9f..9482b4f8603b0700d28109636ab3e5c028021864 100644 --- a/spec/features/admin/users/users_spec.rb +++ b/spec/features/admin/users/users_spec.rb @@ -15,6 +15,7 @@ describe 'GET /admin/users' do before do + stub_feature_flags(vue_admin_users: false) visit admin_users_path end @@ -418,6 +419,7 @@ def expects_warning_to_be_shown describe 'GET /admin/users/:id/edit' do before do + stub_feature_flags(vue_admin_users: false) visit admin_users_path click_link "edit_user_#{user.id}" end diff --git a/spec/fixtures/api/schemas/entities/admin_users_data_attributes_paths.json b/spec/fixtures/api/schemas/entities/admin_users_data_attributes_paths.json new file mode 100644 index 0000000000000000000000000000000000000000..eab8b6268763e93a34f2cdc6f29168b947680361 --- /dev/null +++ b/spec/fixtures/api/schemas/entities/admin_users_data_attributes_paths.json @@ -0,0 +1,30 @@ +{ + "type": "object", + "properties": { + "edit": { "type": "string" }, + "approve": { "type": "string" }, + "reject": { "type": "string" }, + "unblock": { "type": "string" }, + "block": { "type": "string" }, + "deactivate": { "type": "string" }, + "activate": { "type": "string" }, + "unlock": { "type": "string" }, + "delete": { "type": "string" }, + "delete_with_contributions": { "type": "string" }, + "admin_user": { "type": "string" } + }, + "required": [ + "edit", + "approve", + "reject", + "unblock", + "block", + "deactivate", + "activate", + "unlock", + "delete", + "delete_with_contributions", + "admin_user" + ], + "additionalProperties": false +} diff --git a/spec/frontend/admin/users/index_spec.js b/spec/frontend/admin/users/index_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..171d54c8f4f5d297c1a510dffd99bfeea6104481 --- /dev/null +++ b/spec/frontend/admin/users/index_spec.js @@ -0,0 +1,35 @@ +import { createWrapper } from '@vue/test-utils'; +import initAdminUsers from '~/admin/users'; +import AdminUsersApp from '~/admin/users/components/app.vue'; +import { users, paths } from './mock_data'; + +describe('initAdminUsersApp', () => { + let wrapper; + let el; + + const findApp = () => wrapper.find(AdminUsersApp); + + beforeEach(() => { + el = document.createElement('div'); + el.setAttribute('data-users', JSON.stringify(users)); + el.setAttribute('data-paths', JSON.stringify(paths)); + + document.body.appendChild(el); + + wrapper = createWrapper(initAdminUsers(el)); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + el.remove(); + el = null; + }); + + it('parses and passes props', () => { + expect(findApp().props()).toMatchObject({ + users, + paths, + }); + }); +}); diff --git a/spec/frontend/admin/users/mock_data.js b/spec/frontend/admin/users/mock_data.js new file mode 100644 index 0000000000000000000000000000000000000000..b80d04454b0cefc948c5654c0151f42a2b248cc5 --- /dev/null +++ b/spec/frontend/admin/users/mock_data.js @@ -0,0 +1,29 @@ +export const users = [ + { + id: 2177, + name: 'Nikki', + createdAt: '2020-11-13T12:26:54.177Z', + email: 'nikki@example.com', + username: 'nikki', + lastActivityOn: null, + avatarUrl: + 'https://secure.gravatar.com/avatar/054f062d8b1a42b123f17e13a173cda8?s=80\\u0026d=identicon', + badges: [], + projectsCount: 0, + actions: [], + }, +]; + +export const paths = { + edit: '/admin/users/id/edit', + approve: '/admin/users/id/approve', + reject: '/admin/users/id/reject', + unblock: '/admin/users/id/unblock', + block: '/admin/users/id/block', + deactivate: '/admin/users/id/deactivate', + activate: '/admin/users/id/activate', + unlock: '/admin/users/id/unlock', + delete: '/admin/users/id', + deleteWithContributions: '/admin/users/id', + adminUser: '/admin/users/id', +}; diff --git a/spec/helpers/users_helper_spec.rb b/spec/helpers/users_helper_spec.rb index 5b559e40a80b4cae18b7d3e6f57e0ae7fc80a680..c92c6e6e78eee02eb6805aeab826b95738e623a9 100644 --- a/spec/helpers/users_helper_spec.rb +++ b/spec/helpers/users_helper_spec.rb @@ -333,4 +333,21 @@ def stub_profile_permission_allowed(allowed, current_user = nil) allow(helper).to receive(:can?).with(current_user, :read_user_profile, user).and_return(allowed) end end + + describe '#admin_users_data_attributes' do + subject(:data) { helper.admin_users_data_attributes([user]) } + + it 'users matches the serialized json' do + entity = double + expect_next_instance_of(Admin::UserSerializer) do |instance| + expect(instance).to receive(:represent).with([user]).and_return(entity) + end + expect(entity).to receive(:to_json).and_return("{\"username\":\"admin\"}") + expect(data[:users]).to eq "{\"username\":\"admin\"}" + end + + it 'paths matches the schema' do + expect(data[:paths]).to match_schema('entities/admin_users_data_attributes_paths') + end + end end