diff --git a/app/assets/javascripts/vue_shared/components/groups_list/groups_list.stories.js b/app/assets/javascripts/vue_shared/components/groups_list/groups_list.stories.js new file mode 100644 index 0000000000000000000000000000000000000000..235523054c390fd62e2c15bc79e9a63510f76322 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/groups_list/groups_list.stories.js @@ -0,0 +1,19 @@ +import { groups } from 'jest/vue_shared/components/groups_list/mock_data'; +import GroupsList from './groups_list.vue'; + +export default { + component: GroupsList, + title: 'vue_shared/groups_list', +}; + +const Template = (args, { argTypes }) => ({ + components: { GroupsList }, + props: Object.keys(argTypes), + template: '<groups-list v-bind="$props" />', +}); + +export const Default = Template.bind({}); +Default.args = { + groups, + showGroupIcon: true, +}; diff --git a/app/assets/javascripts/vue_shared/components/groups_list/groups_list.vue b/app/assets/javascripts/vue_shared/components/groups_list/groups_list.vue new file mode 100644 index 0000000000000000000000000000000000000000..7da45169fee61787a965cddda17dd4ffd1c99601 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/groups_list/groups_list.vue @@ -0,0 +1,29 @@ +<script> +import GroupsListItem from './groups_list_item.vue'; + +export default { + components: { GroupsListItem }, + props: { + groups: { + type: Array, + required: true, + }, + showGroupIcon: { + type: Boolean, + required: false, + default: false, + }, + }, +}; +</script> + +<template> + <ul class="gl-p-0 gl-list-style-none"> + <groups-list-item + v-for="group in groups" + :key="group.id" + :group="group" + :show-group-icon="showGroupIcon" + /> + </ul> +</template> diff --git a/app/assets/javascripts/vue_shared/components/groups_list/groups_list_item.vue b/app/assets/javascripts/vue_shared/components/groups_list/groups_list_item.vue new file mode 100644 index 0000000000000000000000000000000000000000..8a301cd0dd05e6245d793459ea126a6c45837ff7 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/groups_list/groups_list_item.vue @@ -0,0 +1,168 @@ +<script> +import { GlAvatarLabeled, GlIcon, GlTooltipDirective, GlTruncateText } from '@gitlab/ui'; + +import { VISIBILITY_TYPE_ICON, GROUP_VISIBILITY_TYPE } from '~/visibility_level/constants'; +import { ACCESS_LEVEL_LABELS } from '~/access_level/constants'; +import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue'; +import { __ } from '~/locale'; +import { numberToMetricPrefix } from '~/lib/utils/number_utils'; +import SafeHtml from '~/vue_shared/directives/safe_html'; + +export default { + i18n: { + subgroups: __('Subgroups'), + projects: __('Projects'), + directMembers: __('Direct members'), + showMore: __('Show more'), + showLess: __('Show less'), + }, + avatarSize: { default: 32, md: 48 }, + safeHtmlConfig: { + ADD_TAGS: ['gl-emoji'], + }, + components: { + GlAvatarLabeled, + GlIcon, + UserAccessRoleBadge, + GlTruncateText, + }, + directives: { + GlTooltip: GlTooltipDirective, + SafeHtml, + }, + props: { + group: { + type: Object, + required: true, + }, + showGroupIcon: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + visibility() { + return this.group.visibility; + }, + visibilityIcon() { + return VISIBILITY_TYPE_ICON[this.visibility]; + }, + visibilityTooltip() { + return GROUP_VISIBILITY_TYPE[this.visibility]; + }, + accessLevel() { + return this.group.accessLevel?.integerValue; + }, + accessLevelLabel() { + return ACCESS_LEVEL_LABELS[this.accessLevel]; + }, + shouldShowAccessLevel() { + return this.accessLevel !== undefined; + }, + groupIconName() { + return this.group.parent ? 'subgroup' : 'group'; + }, + statsPadding() { + return this.showGroupIcon ? 'gl-pl-11' : 'gl-pl-8'; + }, + descendantGroupsCount() { + return numberToMetricPrefix(this.group.descendantGroupsCount); + }, + projectsCount() { + return numberToMetricPrefix(this.group.projectsCount); + }, + groupMembersCount() { + return numberToMetricPrefix(this.group.groupMembersCount); + }, + }, +}; +</script> + +<template> + <li class="groups-list-item gl-py-5 gl-md-display-flex gl-align-items-center gl-border-b"> + <div class="gl-display-flex gl-flex-grow-1"> + <gl-icon + v-if="showGroupIcon" + class="gl-mr-3 gl-mt-3 gl-md-mt-5 gl-flex-shrink-0 gl-text-secondary" + :name="groupIconName" + /> + <gl-avatar-labeled + :entity-id="group.id" + :entity-name="group.fullName" + :label="group.fullName" + :label-link="group.webUrl" + shape="rect" + :size="$options.avatarSize" + > + <template #meta> + <div class="gl-px-2"> + <div class="gl-mx-n2 gl-display-flex gl-align-items-center gl-flex-wrap"> + <div class="gl-px-2"> + <gl-icon + v-if="visibility" + v-gl-tooltip="visibilityTooltip" + :name="visibilityIcon" + class="gl-text-secondary" + /> + </div> + <div class="gl-px-2"> + <user-access-role-badge v-if="shouldShowAccessLevel">{{ + accessLevelLabel + }}</user-access-role-badge> + </div> + </div> + </div> + </template> + <gl-truncate-text + v-if="group.descriptionHtml" + :lines="2" + :mobile-lines="2" + :show-more-text="$options.i18n.showMore" + :show-less-text="$options.i18n.showLess" + class="gl-mt-2" + > + <div + v-safe-html:[$options.safeHtmlConfig]="group.descriptionHtml" + class="gl-font-sm md" + data-testid="group-description" + ></div> + </gl-truncate-text> + </gl-avatar-labeled> + </div> + <div + class="gl-md-display-flex gl-flex-direction-column gl-align-items-flex-end gl-flex-shrink-0 gl-mt-3 gl-md-pl-0 gl-md-mt-0 gl-md-ml-3" + :class="statsPadding" + > + <div class="gl-display-flex gl-align-items-center gl-gap-x-3"> + <div + v-gl-tooltip="$options.i18n.subgroups" + :aria-label="$options.i18n.subgroups" + class="gl-text-secondary" + data-testid="subgroups-count" + > + <gl-icon name="subgroup" /> + <span>{{ descendantGroupsCount }}</span> + </div> + <div + v-gl-tooltip="$options.i18n.projects" + :aria-label="$options.i18n.projects" + class="gl-text-secondary" + data-testid="projects-count" + > + <gl-icon name="project" /> + <span>{{ projectsCount }}</span> + </div> + <div + v-gl-tooltip="$options.i18n.directMembers" + :aria-label="$options.i18n.directMembers" + class="gl-text-secondary" + data-testid="members-count" + > + <gl-icon name="users" /> + <span>{{ groupMembersCount }}</span> + </div> + </div> + </div> + </li> +</template> diff --git a/spec/frontend/vue_shared/components/groups_list/groups_list_item_spec.js b/spec/frontend/vue_shared/components/groups_list/groups_list_item_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..877de4f4695f567a246cb83f930de51f784d1a09 --- /dev/null +++ b/spec/frontend/vue_shared/components/groups_list/groups_list_item_spec.js @@ -0,0 +1,182 @@ +import { GlAvatarLabeled, GlIcon } from '@gitlab/ui'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import GroupsListItem from '~/vue_shared/components/groups_list/groups_list_item.vue'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import { + VISIBILITY_TYPE_ICON, + VISIBILITY_LEVEL_INTERNAL_STRING, + GROUP_VISIBILITY_TYPE, +} from '~/visibility_level/constants'; +import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue'; +import { ACCESS_LEVEL_LABELS } from '~/access_level/constants'; +import { groups } from './mock_data'; + +describe('GroupsListItem', () => { + let wrapper; + + const [group] = groups; + + const defaultPropsData = { group }; + + const createComponent = ({ propsData = {} } = {}) => { + wrapper = mountExtended(GroupsListItem, { + propsData: { ...defaultPropsData, ...propsData }, + directives: { + GlTooltip: createMockDirective('gl-tooltip'), + }, + }); + }; + + const findAvatarLabeled = () => wrapper.findComponent(GlAvatarLabeled); + const findGroupDescription = () => wrapper.findByTestId('group-description'); + const findVisibilityIcon = () => findAvatarLabeled().findComponent(GlIcon); + + it('renders group avatar', () => { + createComponent(); + + const avatarLabeled = findAvatarLabeled(); + + expect(avatarLabeled.props()).toMatchObject({ + label: group.fullName, + labelLink: group.webUrl, + }); + + expect(avatarLabeled.attributes()).toMatchObject({ + 'entity-id': group.id.toString(), + 'entity-name': group.fullName, + shape: 'rect', + }); + }); + + it('renders visibility icon with tooltip', () => { + createComponent(); + + const icon = findAvatarLabeled().findComponent(GlIcon); + const tooltip = getBinding(icon.element, 'gl-tooltip'); + + expect(icon.props('name')).toBe(VISIBILITY_TYPE_ICON[VISIBILITY_LEVEL_INTERNAL_STRING]); + expect(tooltip.value).toBe(GROUP_VISIBILITY_TYPE[VISIBILITY_LEVEL_INTERNAL_STRING]); + }); + + it('renders subgroup count', () => { + createComponent(); + + const countWrapper = wrapper.findByTestId('subgroups-count'); + const tooltip = getBinding(countWrapper.element, 'gl-tooltip'); + + expect(tooltip.value).toBe(GroupsListItem.i18n.subgroups); + expect(countWrapper.text()).toBe(group.descendantGroupsCount.toString()); + expect(countWrapper.findComponent(GlIcon).props('name')).toBe('subgroup'); + }); + + it('renders projects count', () => { + createComponent(); + + const countWrapper = wrapper.findByTestId('projects-count'); + const tooltip = getBinding(countWrapper.element, 'gl-tooltip'); + + expect(tooltip.value).toBe(GroupsListItem.i18n.projects); + expect(countWrapper.text()).toBe(group.projectsCount.toString()); + expect(countWrapper.findComponent(GlIcon).props('name')).toBe('project'); + }); + + it('renders members count', () => { + createComponent(); + + const countWrapper = wrapper.findByTestId('members-count'); + const tooltip = getBinding(countWrapper.element, 'gl-tooltip'); + + expect(tooltip.value).toBe(GroupsListItem.i18n.directMembers); + expect(countWrapper.text()).toBe(group.groupMembersCount.toString()); + expect(countWrapper.findComponent(GlIcon).props('name')).toBe('users'); + }); + + describe('when visibility is not provided', () => { + it('does not render visibility icon', () => { + const { visibility, ...groupWithoutVisibility } = group; + createComponent({ + propsData: { + group: groupWithoutVisibility, + }, + }); + + expect(findVisibilityIcon().exists()).toBe(false); + }); + }); + + it('renders access role badge', () => { + createComponent(); + + expect(findAvatarLabeled().findComponent(UserAccessRoleBadge).text()).toBe( + ACCESS_LEVEL_LABELS[group.accessLevel.integerValue], + ); + }); + + describe('when group has a description', () => { + it('renders description', () => { + const descriptionHtml = '<p>Foo bar</p>'; + + createComponent({ + propsData: { + group: { + ...group, + descriptionHtml, + }, + }, + }); + + expect(findGroupDescription().element.innerHTML).toBe(descriptionHtml); + }); + }); + + describe('when group does not have a description', () => { + it('does not render description', () => { + createComponent({ + propsData: { + group: { + ...group, + descriptionHtml: null, + }, + }, + }); + + expect(findGroupDescription().exists()).toBe(false); + }); + }); + + describe('when `showGroupIcon` prop is `true`', () => { + describe('when `parent` attribute is `null`', () => { + it('shows group icon', () => { + createComponent({ propsData: { showGroupIcon: true } }); + + expect(wrapper.findByTestId('group-icon').exists()).toBe(true); + }); + }); + + describe('when `parent` attribute is set', () => { + it('shows subgroup icon', () => { + createComponent({ + propsData: { + showGroupIcon: true, + group: { + ...group, + parent: { + id: 'gid://gitlab/Group/35', + }, + }, + }, + }); + + expect(wrapper.findByTestId('subgroup-icon').exists()).toBe(true); + }); + }); + }); + + describe('when `showGroupIcon` prop is `false`', () => { + it('does not show group icon', () => { + createComponent(); + + expect(wrapper.findByTestId('group-icon').exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/groups_list/groups_list_spec.js b/spec/frontend/vue_shared/components/groups_list/groups_list_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..c65aa347bcf9d4d729de5091347d6c507ccc8dc0 --- /dev/null +++ b/spec/frontend/vue_shared/components/groups_list/groups_list_spec.js @@ -0,0 +1,34 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import GroupsList from '~/vue_shared/components/groups_list/groups_list.vue'; +import GroupsListItem from '~/vue_shared/components/groups_list/groups_list_item.vue'; +import { groups } from './mock_data'; + +describe('GroupsList', () => { + let wrapper; + + const defaultPropsData = { + groups, + }; + + const createComponent = () => { + wrapper = shallowMountExtended(GroupsList, { + propsData: defaultPropsData, + }); + }; + + it('renders list with `GroupsListItem` component', () => { + createComponent(); + + const groupsListItemWrappers = wrapper.findAllComponents(GroupsListItem).wrappers; + const expectedProps = groupsListItemWrappers.map((groupsListItemWrapper) => + groupsListItemWrapper.props(), + ); + + expect(expectedProps).toEqual( + defaultPropsData.groups.map((group) => ({ + group, + showGroupIcon: false, + })), + ); + }); +}); diff --git a/spec/frontend/vue_shared/components/groups_list/mock_data.js b/spec/frontend/vue_shared/components/groups_list/mock_data.js new file mode 100644 index 0000000000000000000000000000000000000000..0dad27f83116f6907e0de643318401064651d738 --- /dev/null +++ b/spec/frontend/vue_shared/components/groups_list/mock_data.js @@ -0,0 +1,35 @@ +export const groups = [ + { + id: 1, + fullName: 'Gitlab Org', + parent: null, + webUrl: 'http://127.0.0.1:3000/groups/gitlab-org', + descriptionHtml: + '<p data-sourcepos="1:1-1:64" dir="auto">Dolorem dolorem omnis impedit cupiditate pariatur officia velit. Fusce eget orci a ipsum tempus vehicula. Donec rhoncus ante sed lacus pharetra, vitae imperdiet felis lobortis. Donec maximus dapibus orci, sit amet euismod dolor rhoncus vel. In nec mauris nibh.</p>', + avatarUrl: null, + descendantGroupsCount: 1, + projectsCount: 1, + groupMembersCount: 2, + visibility: 'internal', + accessLevel: { + integerValue: 10, + }, + }, + { + id: 2, + fullName: 'Gitlab Org / test subgroup', + parent: { + id: 1, + }, + webUrl: 'http://127.0.0.1:3000/groups/gitlab-org/test-subgroup', + descriptionHtml: '', + avatarUrl: null, + descendantGroupsCount: 4, + projectsCount: 4, + groupMembersCount: 4, + visibility: 'private', + accessLevel: { + integerValue: 20, + }, + }, +];