Commit 7177d72a authored by Jiaan Louw's avatar Jiaan Louw Committed by Phil Hughes

Add admin users table row components

This adds new components for the admin/users table
to show a user's available actions & dates.
parent ca434b45
<script>
import {
GlButton,
GlDropdown,
GlDropdownItem,
GlDropdownSectionHeader,
GlDropdownDivider,
} from '@gitlab/ui';
import { s__, __ } from '~/locale';
import { convertArrayToCamelCase } from '~/lib/utils/common_utils';
import { generateUserPaths } from '../utils';
export default {
components: {
GlButton,
GlDropdown,
GlDropdownItem,
GlDropdownSectionHeader,
GlDropdownDivider,
},
props: {
user: {
type: Object,
required: true,
},
paths: {
type: Object,
required: true,
},
},
computed: {
userActions() {
return convertArrayToCamelCase(this.user.actions);
},
dropdownActions() {
return this.userActions.filter((a) => a !== 'edit');
},
dropdownDeleteActions() {
return this.dropdownActions.filter((a) => a.includes('delete'));
},
dropdownSafeActions() {
return this.dropdownActions.filter((a) => !this.dropdownDeleteActions.includes(a));
},
hasDropdownActions() {
return this.dropdownActions.length > 0;
},
hasDeleteActions() {
return this.dropdownDeleteActions.length > 0;
},
hasEditAction() {
return this.userActions.includes('edit');
},
userPaths() {
return generateUserPaths(this.paths, this.user.username);
},
},
methods: {
isLdapAction(action) {
return action === 'ldapBlocked';
},
},
i18n: {
edit: __('Edit'),
settings: __('Settings'),
unlock: __('Unlock'),
block: s__('AdminUsers|Block'),
unblock: s__('AdminUsers|Unblock'),
approve: s__('AdminUsers|Approve'),
reject: s__('AdminUsers|Reject'),
deactivate: s__('AdminUsers|Deactivate'),
activate: s__('AdminUsers|Activate'),
ldapBlocked: s__('AdminUsers|Cannot unblock LDAP blocked users'),
delete: s__('AdminUsers|Delete user'),
deleteWithContributions: s__('AdminUsers|Delete user and contributions'),
},
};
</script>
<template>
<div class="gl-display-flex gl-justify-content-end">
<gl-button v-if="hasEditAction" data-testid="edit" :href="userPaths.edit">{{
$options.i18n.edit
}}</gl-button>
<gl-dropdown
v-if="hasDropdownActions"
data-testid="actions"
right
class="gl-ml-2"
icon="settings"
>
<gl-dropdown-section-header>{{ $options.i18n.settings }}</gl-dropdown-section-header>
<template v-for="action in dropdownSafeActions">
<gl-dropdown-item v-if="isLdapAction(action)" :key="action" :data-testid="action">
{{ $options.i18n.ldap }}
</gl-dropdown-item>
<gl-dropdown-item v-else :key="action" :href="userPaths[action]" :data-testid="action">
{{ $options.i18n[action] }}
</gl-dropdown-item>
</template>
<gl-dropdown-divider v-if="hasDeleteActions" />
<gl-dropdown-item
v-for="action in dropdownDeleteActions"
:key="action"
:href="userPaths[action]"
:data-testid="`delete-${action}`"
>
<span class="gl-text-red-500">{{ $options.i18n[action] }}</span>
</gl-dropdown-item>
</gl-dropdown>
</div>
</template>
<script>
import { formatDate } from '~/lib/utils/datetime_utility';
import { __ } from '~/locale';
import { SHORT_DATE_FORMAT } from '../constants';
export default {
props: {
date: {
type: String,
required: false,
default: null,
},
},
computed: {
formattedDate() {
const { date } = this;
if (date === null) {
return __('Never');
}
return formatDate(new Date(date), SHORT_DATE_FORMAT);
},
},
};
</script>
<template>
<span>
{{ formattedDate }}
</span>
</template>
...@@ -2,6 +2,8 @@ ...@@ -2,6 +2,8 @@
import { GlTable } from '@gitlab/ui'; import { GlTable } from '@gitlab/ui';
import { __ } from '~/locale'; import { __ } from '~/locale';
import UserAvatar from './user_avatar.vue'; import UserAvatar from './user_avatar.vue';
import UserActions from './user_actions.vue';
import UserDate from './user_date.vue';
const DEFAULT_TH_CLASSES = const DEFAULT_TH_CLASSES =
'gl-bg-transparent! gl-border-b-solid! gl-border-b-gray-100! gl-p-5! gl-border-b-1!'; 'gl-bg-transparent! gl-border-b-solid! gl-border-b-gray-100! gl-p-5! gl-border-b-1!';
...@@ -11,6 +13,8 @@ export default { ...@@ -11,6 +13,8 @@ export default {
components: { components: {
GlTable, GlTable,
UserAvatar, UserAvatar,
UserActions,
UserDate,
}, },
props: { props: {
users: { users: {
...@@ -62,7 +66,19 @@ export default { ...@@ -62,7 +66,19 @@ export default {
stacked="md" stacked="md"
> >
<template #cell(name)="{ item: user }"> <template #cell(name)="{ item: user }">
<UserAvatar :user="user" :admin-user-path="paths.adminUser" /> <user-avatar :user="user" :admin-user-path="paths.adminUser" />
</template>
<template #cell(createdAt)="{ item: { createdAt } }">
<user-date :date="createdAt" />
</template>
<template #cell(lastActivityOn)="{ item: { lastActivityOn } }">
<user-date :date="lastActivityOn" show-never />
</template>
<template #cell(settings)="{ item: user }">
<user-actions :user="user" :paths="paths" />
</template> </template>
</gl-table> </gl-table>
</div> </div>
......
export const USER_AVATAR_SIZE = 32; export const USER_AVATAR_SIZE = 32;
export const SHORT_DATE_FORMAT = 'd mmm, yyyy';
export const generateUserPaths = (paths, id) => {
return Object.fromEntries(
Object.entries(paths).map(([action, genericPath]) => {
return [action, genericPath.replace('id', id)];
}),
);
};
...@@ -801,3 +801,12 @@ export const removeCookie = (name) => Cookies.remove(name); ...@@ -801,3 +801,12 @@ export const removeCookie = (name) => Cookies.remove(name);
* @returns {Boolean} on/off * @returns {Boolean} on/off
*/ */
export const isFeatureFlagEnabled = (flag) => window.gon.features?.[flag]; export const isFeatureFlagEnabled = (flag) => window.gon.features?.[flag];
/**
* This method takes in array with snake_case strings
* and returns a new array with camelCase strings
*
* @param {Array[String]} array - Array to be converted
* @returns {Array[String]} Converted array
*/
export const convertArrayToCamelCase = (array) => array.map((i) => convertToCamelCase(i));
import { shallowMount } from '@vue/test-utils';
import { GlDropdownDivider } from '@gitlab/ui';
import AdminUserActions from '~/admin/users/components/user_actions.vue';
import { generateUserPaths } from '~/admin/users/utils';
import { users, paths } from '../mock_data';
const BLOCK = 'block';
const EDIT = 'edit';
const LDAP = 'ldapBlocked';
const DELETE = 'delete';
const DELETE_WITH_CONTRIBUTIONS = 'deleteWithContributions';
describe('AdminUserActions component', () => {
let wrapper;
const user = users[0];
const userPaths = generateUserPaths(paths, user.username);
const findEditButton = () => wrapper.find('[data-testid="edit"]');
const findActionsDropdown = () => wrapper.find('[data-testid="actions"');
const findDropdownDivider = () => wrapper.find(GlDropdownDivider);
const initComponent = ({ actions = [] } = {}) => {
wrapper = shallowMount(AdminUserActions, {
propsData: {
user: {
...user,
actions,
},
paths,
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('edit button', () => {
describe('when the user has an edit action attached', () => {
beforeEach(() => {
initComponent({ actions: [EDIT] });
});
it('renders the edit button linking to the user edit path', () => {
expect(findEditButton().exists()).toBe(true);
expect(findEditButton().attributes('href')).toBe(userPaths.edit);
});
});
describe('when there is no edit action attached to the user', () => {
beforeEach(() => {
initComponent({ actions: [] });
});
it('does not render the edit button linking to the user edit path', () => {
expect(findEditButton().exists()).toBe(false);
});
});
});
describe('actions dropdown', () => {
describe('when there are actions', () => {
const actions = [EDIT, BLOCK];
beforeEach(() => {
initComponent({ actions });
});
it('renders the actions dropdown', () => {
expect(findActionsDropdown().exists()).toBe(true);
});
it.each(actions)('renders a dropdown item for %s', (action) => {
const dropdownAction = wrapper.find(`[data-testid="${action}"]`);
expect(dropdownAction.exists()).toBe(true);
expect(dropdownAction.attributes('href')).toBe(userPaths[action]);
});
describe('when there is a LDAP action', () => {
beforeEach(() => {
initComponent({ actions: [LDAP] });
});
it('renders the LDAP dropdown item without a link', () => {
const dropdownAction = wrapper.find(`[data-testid="${LDAP}"]`);
expect(dropdownAction.exists()).toBe(true);
expect(dropdownAction.attributes('href')).toBe(undefined);
});
});
describe('when there is a delete action', () => {
const deleteActions = [DELETE, DELETE_WITH_CONTRIBUTIONS];
beforeEach(() => {
initComponent({ actions: [BLOCK, ...deleteActions] });
});
it('renders a dropdown divider', () => {
expect(findDropdownDivider().exists()).toBe(true);
});
it('only renders delete dropdown items for actions containing the word "delete"', () => {
const { length } = wrapper.findAll(`[data-testid*="delete-"]`);
expect(length).toBe(deleteActions.length);
});
it.each(deleteActions)('renders a delete dropdown item for %s', (action) => {
const deleteAction = wrapper.find(`[data-testid="delete-${action}"]`);
expect(deleteAction.exists()).toBe(true);
expect(deleteAction.attributes('href')).toBe(userPaths[action]);
});
});
describe('when there are no delete actions', () => {
it('does not render a dropdown divider', () => {
expect(findDropdownDivider().exists()).toBe(false);
});
it('does not render a delete dropdown item', () => {
const anyDeleteAction = wrapper.find(`[data-testid*="delete-"]`);
expect(anyDeleteAction.exists()).toBe(false);
});
});
});
describe('when there are no actions', () => {
beforeEach(() => {
initComponent({ actions: [] });
});
it('does not render the actions dropdown', () => {
expect(findActionsDropdown().exists()).toBe(false);
});
});
});
});
import { shallowMount } from '@vue/test-utils';
import UserDate from '~/admin/users/components/user_date.vue';
import { users } from '../mock_data';
const mockDate = users[0].createdAt;
describe('FormatDate component', () => {
let wrapper;
const initComponent = (props = {}) => {
wrapper = shallowMount(UserDate, {
propsData: {
...props,
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it.each`
date | output
${mockDate} | ${'13 Nov, 2020'}
${null} | ${'Never'}
${undefined} | ${'Never'}
`('renders $date as $output', ({ date, output }) => {
initComponent({ date });
expect(wrapper.text()).toBe(output);
});
});
...@@ -3,6 +3,9 @@ import { mount } from '@vue/test-utils'; ...@@ -3,6 +3,9 @@ import { mount } from '@vue/test-utils';
import AdminUsersTable from '~/admin/users/components/users_table.vue'; import AdminUsersTable from '~/admin/users/components/users_table.vue';
import AdminUserAvatar from '~/admin/users/components/user_avatar.vue'; import AdminUserAvatar from '~/admin/users/components/user_avatar.vue';
import AdminUserDate from '~/admin/users/components/user_date.vue';
import AdminUserActions from '~/admin/users/components/user_actions.vue';
import { users, paths } from '../mock_data'; import { users, paths } from '../mock_data';
describe('AdminUsersTable component', () => { describe('AdminUsersTable component', () => {
...@@ -39,18 +42,21 @@ describe('AdminUsersTable component', () => { ...@@ -39,18 +42,21 @@ describe('AdminUsersTable component', () => {
initComponent(); initComponent();
}); });
it.each` it('renders the projects count', () => {
key | label expect(getCellByLabel(0, 'Projects').text()).toContain(`${user.projectsCount}`);
${'name'} | ${'Name'}
${'projectsCount'} | ${'Projects'}
${'createdAt'} | ${'Created on'}
${'lastActivityOn'} | ${'Last activity'}
`('renders users.$key in column $label', ({ key, label }) => {
expect(getCellByLabel(0, label).text()).toContain(`${user[key]}`);
}); });
it('renders an AdminUserAvatar component', () => { it('renders the user actions', () => {
expect(getCellByLabel(0, 'Name').find(AdminUserAvatar).exists()).toBe(true); expect(wrapper.find(AdminUserActions).exists()).toBe(true);
});
it.each`
component | label
${AdminUserAvatar} | ${'Name'}
${AdminUserDate} | ${'Created on'}
${AdminUserDate} | ${'Last activity'}
`('renders the component for column $label', ({ component, label }) => {
expect(getCellByLabel(0, label).find(component).exists()).toBe(true);
}); });
}); });
......
...@@ -1045,4 +1045,12 @@ describe('common_utils', () => { ...@@ -1045,4 +1045,12 @@ describe('common_utils', () => {
expect(commonUtils.getDashPath('/some/url')).toEqual(null); expect(commonUtils.getDashPath('/some/url')).toEqual(null);
}); });
}); });
describe('convertArrayToCamelCase', () => {
it('returns a new array with snake_case string elements converted camelCase', () => {
const result = commonUtils.convertArrayToCamelCase(['hello', 'hello_world']);
expect(result).toEqual(['hello', 'helloWorld']);
});
});
}); });
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