diff options
48 files changed, 2753 insertions, 891 deletions
diff --git a/app/assets/javascripts/filterable_list.js b/app/assets/javascripts/filterable_list.js index 6d516a253bb..9e91f72b2ea 100644 --- a/app/assets/javascripts/filterable_list.js +++ b/app/assets/javascripts/filterable_list.js @@ -6,10 +6,11 @@ import _ from 'underscore'; */ export default class FilterableList { - constructor(form, filter, holder) { + constructor(form, filter, holder, filterInputField = 'filter_groups') { this.filterForm = form; this.listFilterElement = filter; this.listHolderElement = holder; + this.filterInputField = filterInputField; this.isBusy = false; } @@ -32,10 +33,10 @@ export default class FilterableList { onFilterInput() { const $form = $(this.filterForm); const queryData = {}; - const filterGroupsParam = $form.find('[name="filter_groups"]').val(); + const filterGroupsParam = $form.find(`[name="${this.filterInputField}"]`).val(); if (filterGroupsParam) { - queryData.filter_groups = filterGroupsParam; + queryData[this.filterInputField] = filterGroupsParam; } this.filterResults(queryData); diff --git a/app/assets/javascripts/groups/components/app.vue b/app/assets/javascripts/groups/components/app.vue new file mode 100644 index 00000000000..fdec34f5dab --- /dev/null +++ b/app/assets/javascripts/groups/components/app.vue @@ -0,0 +1,191 @@ +<script> +/* global Flash */ + +import eventHub from '../event_hub'; +import { getParameterByName } from '../../lib/utils/common_utils'; +import loadingIcon from '../../vue_shared/components/loading_icon.vue'; +import { COMMON_STR } from '../constants'; + +import groupsComponent from './groups.vue'; + +export default { + components: { + loadingIcon, + groupsComponent, + }, + props: { + store: { + type: Object, + required: true, + }, + service: { + type: Object, + required: true, + }, + hideProjects: { + type: Boolean, + required: true, + }, + }, + data() { + return { + isLoading: true, + isSearchEmpty: false, + searchEmptyMessage: '', + }; + }, + computed: { + groups() { + return this.store.getGroups(); + }, + pageInfo() { + return this.store.getPaginationInfo(); + }, + }, + methods: { + fetchGroups({ parentId, page, filterGroupsBy, sortBy, updatePagination }) { + return this.service.getGroups(parentId, page, filterGroupsBy, sortBy) + .then((res) => { + if (updatePagination) { + this.updatePagination(res.headers); + } + + return res; + }) + .then(res => res.json()) + .catch(() => { + this.isLoading = false; + $.scrollTo(0); + + Flash(COMMON_STR.FAILURE); + }); + }, + fetchAllGroups() { + const page = getParameterByName('page') || null; + const sortBy = getParameterByName('sort') || null; + const filterGroupsBy = getParameterByName('filter') || null; + + this.isLoading = true; + // eslint-disable-next-line promise/catch-or-return + this.fetchGroups({ + page, + filterGroupsBy, + sortBy, + updatePagination: true, + }).then((res) => { + this.isLoading = false; + this.updateGroups(res, Boolean(filterGroupsBy)); + }); + }, + fetchPage(page, filterGroupsBy, sortBy) { + this.isLoading = true; + + // eslint-disable-next-line promise/catch-or-return + this.fetchGroups({ + page, + filterGroupsBy, + sortBy, + updatePagination: true, + }).then((res) => { + this.isLoading = false; + $.scrollTo(0); + + const currentPath = gl.utils.mergeUrlParams({ page }, window.location.href); + window.history.replaceState({ + page: currentPath, + }, document.title, currentPath); + + this.updateGroups(res); + }); + }, + toggleChildren(group) { + const parentGroup = group; + if (!parentGroup.isOpen) { + if (parentGroup.children.length === 0) { + parentGroup.isChildrenLoading = true; + // eslint-disable-next-line promise/catch-or-return + this.fetchGroups({ + parentId: parentGroup.id, + }).then((res) => { + this.store.setGroupChildren(parentGroup, res); + }).catch(() => { + parentGroup.isChildrenLoading = false; + }); + } else { + parentGroup.isOpen = true; + } + } else { + parentGroup.isOpen = false; + } + }, + leaveGroup(group, parentGroup) { + const targetGroup = group; + targetGroup.isBeingRemoved = true; + this.service.leaveGroup(targetGroup.leavePath) + .then(res => res.json()) + .then((res) => { + $.scrollTo(0); + this.store.removeGroup(targetGroup, parentGroup); + Flash(res.notice, 'notice'); + }) + .catch((err) => { + let message = COMMON_STR.FAILURE; + if (err.status === 403) { + message = COMMON_STR.LEAVE_FORBIDDEN; + } + Flash(message); + targetGroup.isBeingRemoved = false; + }); + }, + updatePagination(headers) { + this.store.setPaginationInfo(headers); + }, + updateGroups(groups, fromSearch) { + this.isSearchEmpty = groups ? groups.length === 0 : false; + if (fromSearch) { + this.store.setSearchedGroups(groups); + } else { + this.store.setGroups(groups); + } + }, + }, + created() { + this.searchEmptyMessage = this.hideProjects ? + COMMON_STR.GROUP_SEARCH_EMPTY : COMMON_STR.GROUP_PROJECT_SEARCH_EMPTY; + + eventHub.$on('fetchPage', this.fetchPage); + eventHub.$on('toggleChildren', this.toggleChildren); + eventHub.$on('leaveGroup', this.leaveGroup); + eventHub.$on('updatePagination', this.updatePagination); + eventHub.$on('updateGroups', this.updateGroups); + }, + mounted() { + this.fetchAllGroups(); + }, + beforeDestroy() { + eventHub.$off('fetchPage', this.fetchPage); + eventHub.$off('toggleChildren', this.toggleChildren); + eventHub.$off('leaveGroup', this.leaveGroup); + eventHub.$off('updatePagination', this.updatePagination); + eventHub.$off('updateGroups', this.updateGroups); + }, +}; +</script> + +<template> + <div> + <loading-icon + class="loading-animation prepend-top-20" + size="2" + v-if="isLoading" + :label="s__('GroupsTree|Loading groups')" + /> + <groups-component + v-if="!isLoading" + :groups="groups" + :search-empty="isSearchEmpty" + :search-empty-message="searchEmptyMessage" + :page-info="pageInfo" + /> + </div> +</template> diff --git a/app/assets/javascripts/groups/components/group_folder.vue b/app/assets/javascripts/groups/components/group_folder.vue index 7cc6c4b0359..e60221fa08d 100644 --- a/app/assets/javascripts/groups/components/group_folder.vue +++ b/app/assets/javascripts/groups/components/group_folder.vue @@ -1,15 +1,27 @@ <script> +import { n__ } from '../../locale'; +import { MAX_CHILDREN_COUNT } from '../constants'; + export default { props: { - groups: { - type: Object, - required: true, - }, - baseGroup: { + parentGroup: { type: Object, required: false, default: () => ({}), }, + groups: { + type: Array, + required: false, + default: () => ([]), + }, + }, + computed: { + hasMoreChildren() { + return this.parentGroup.childrenCount > MAX_CHILDREN_COUNT; + }, + moreChildrenStats() { + return n__('One more item', '%d more items', this.parentGroup.childrenCount - this.parentGroup.children.length); + }, }, }; </script> @@ -20,8 +32,20 @@ export default { v-for="(group, index) in groups" :key="index" :group="group" - :base-group="baseGroup" - :collection="groups" + :parent-group="parentGroup" /> + <li + v-if="hasMoreChildren" + class="group-row"> + <a + :href="parentGroup.relativePath" + class="group-row-contents has-more-items"> + <i + class="fa fa-external-link" + aria-hidden="true" + /> + {{moreChildrenStats}} + </a> + </li> </ul> </template> diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue index 2060410e991..356a95c05ca 100644 --- a/app/assets/javascripts/groups/components/group_item.vue +++ b/app/assets/javascripts/groups/components/group_item.vue @@ -2,49 +2,28 @@ import identicon from '../../vue_shared/components/identicon.vue'; import eventHub from '../event_hub'; +import itemCaret from './item_caret.vue'; +import itemTypeIcon from './item_type_icon.vue'; +import itemStats from './item_stats.vue'; +import itemActions from './item_actions.vue'; + export default { components: { identicon, + itemCaret, + itemTypeIcon, + itemStats, + itemActions, }, props: { - group: { - type: Object, - required: true, - }, - baseGroup: { + parentGroup: { type: Object, required: false, default: () => ({}), }, - collection: { + group: { type: Object, - required: false, - default: () => ({}), - }, - }, - methods: { - onClickRowGroup(e) { - e.stopPropagation(); - - // Skip for buttons - if (!(e.target.tagName === 'A') && !(e.target.tagName === 'I' && e.target.parentElement.tagName === 'A')) { - if (this.group.hasSubgroups) { - eventHub.$emit('toggleSubGroups', this.group); - } else { - window.location.href = this.group.groupPath; - } - } - }, - onLeaveGroup(e) { - e.preventDefault(); - - // eslint-disable-next-line no-alert - if (confirm(`Are you sure you want to leave the "${this.group.fullName}" group?`)) { - this.leaveGroup(); - } - }, - leaveGroup() { - eventHub.$emit('leaveGroup', this.group, this.collection); + required: true, }, }, computed: { @@ -53,51 +32,33 @@ export default { }, rowClass() { return { - 'group-row': true, 'is-open': this.group.isOpen, - 'has-subgroups': this.group.hasSubgroups, - 'no-description': !this.group.description, + 'has-children': this.hasChildren, + 'has-description': this.group.description, + 'being-removed': this.group.isBeingRemoved, }; }, - visibilityIcon() { - return { - fa: true, - 'fa-globe': this.group.visibility === 'public', - 'fa-shield': this.group.visibility === 'internal', - 'fa-lock': this.group.visibility === 'private', - }; + hasChildren() { + return this.group.childrenCount > 0; }, - fullPath() { - let fullPath = ''; - - if (this.group.isOrphan) { - // check if current group is baseGroup - if (Object.keys(this.baseGroup).length > 0 && this.baseGroup !== this.group) { - // Remove baseGroup prefix from our current group.fullName. e.g: - // baseGroup.fullName: `level1` - // group.fullName: `level1 / level2 / level3` - // Result: `level2 / level3` - const gfn = this.group.fullName; - const bfn = this.baseGroup.fullName; - const length = bfn.length; - const start = gfn.indexOf(bfn); - const extraPrefixChars = 3; - - fullPath = gfn.substr(start + length + extraPrefixChars); + hasAvatar() { + return this.group.avatarUrl !== null; + }, + isGroup() { + return this.group.type === 'group'; + }, + }, + methods: { + onClickRowGroup(e) { + const NO_EXPAND_CLS = 'no-expand'; + if (!(e.target.classList.contains(NO_EXPAND_CLS) || + e.target.parentElement.classList.contains(NO_EXPAND_CLS))) { + if (this.hasChildren) { + eventHub.$emit('toggleChildren', this.group); } else { - fullPath = this.group.fullName; + gl.utils.visitUrl(this.group.relativePath); } - } else { - fullPath = this.group.name; } - - return fullPath; - }, - hasGroups() { - return Object.keys(this.group.subGroups).length > 0; - }, - hasAvatar() { - return this.group.avatarUrl && this.group.avatarUrl.indexOf('/assets/no_group_avatar') === -1; }, }, }; @@ -108,98 +69,36 @@ export default { @click.stop="onClickRowGroup" :id="groupDomId" :class="rowClass" + class="group-row" > <div class="group-row-contents"> - <div - class="controls"> - <a - v-if="group.canEdit" - class="edit-group btn" - :href="group.editPath"> - <i - class="fa fa-cogs" - aria-hidden="true" - > - </i> - </a> - <a - @click="onLeaveGroup" - :href="group.leavePath" - class="leave-group btn" - title="Leave this group"> - <i - class="fa fa-sign-out" - aria-hidden="true" - > - </i> - </a> - </div> - <div - class="stats"> - <span - class="number-projects"> - <i - class="fa fa-bookmark" - aria-hidden="true" - > - </i> - {{group.numberProjects}} - </span> - <span - class="number-users"> - <i - class="fa fa-users" - aria-hidden="true" - > - </i> - {{group.numberUsers}} - </span> - <span - class="group-visibility"> - <i - :class="visibilityIcon" - aria-hidden="true" - > - </i> - </span> - </div> + <item-actions + v-if="isGroup" + :group="group" + :parent-group="parentGroup" + /> + <item-stats + :item="group" + /> <div class="folder-toggle-wrap"> - <span - class="folder-caret" - v-if="group.hasSubgroups"> - <i - v-if="group.isOpen" - class="fa fa-caret-down" - aria-hidden="true" - > - </i> - <i - v-if="!group.isOpen" - class="fa fa-caret-right" - aria-hidden="true" - > - </i> - </span> - <span class="folder-icon"> - <i - v-if="group.isOpen" - class="fa fa-folder-open" - aria-hidden="true" - > - </i> - <i - v-if="!group.isOpen" - class="fa fa-folder" - aria-hidden="true"> - </i> - </span> + <item-caret + :is-group-open="group.isOpen" + /> + <item-type-icon + :item-type="group.type" + :is-group-open="group.isOpen" + /> </div> <div - class="avatar-container s40 hidden-xs"> + class="avatar-container s40 hidden-xs" + :class="{ 'content-loading': group.isChildrenLoading }" + > <a - :href="group.groupPath"> + :href="group.relativePath" + class="no-expand" + > <img v-if="hasAvatar" class="avatar s40" @@ -215,19 +114,22 @@ export default { <div class="title"> <a - :href="group.groupPath">{{fullPath}}</a> - <template v-if="group.permissions.humanGroupAccess"> - as - <span class="access-type">{{group.permissions.humanGroupAccess}}</span> - </template> + :href="group.relativePath" + class="no-expand">{{group.fullName}}</a> + <span + v-if="group.permission" + class="access-type" + > + {{s__('GroupsTreeRole|as')}} {{group.permission}} + </span> </div> <div class="description">{{group.description}}</div> </div> <group-folder - v-if="group.isOpen && hasGroups" - :groups="group.subGroups" - :baseGroup="group" + v-if="group.isOpen && hasChildren" + :parent-group="group" + :groups="group.children" /> </li> </template> diff --git a/app/assets/javascripts/groups/components/groups.vue b/app/assets/javascripts/groups/components/groups.vue index d17a43b048a..d3482818183 100644 --- a/app/assets/javascripts/groups/components/groups.vue +++ b/app/assets/javascripts/groups/components/groups.vue @@ -4,18 +4,26 @@ import eventHub from '../event_hub'; import { getParameterByName } from '../../lib/utils/common_utils'; export default { + components: { + tablePagination, + }, props: { groups: { - type: Object, + type: Array, required: true, }, pageInfo: { type: Object, required: true, }, - }, - components: { - tablePagination, + searchEmpty: { + type: Boolean, + required: true, + }, + searchEmptyMessage: { + type: String, + required: true, + }, }, methods: { change(page) { @@ -29,10 +37,17 @@ export default { <template> <div class="groups-list-tree-container"> + <div + v-if="searchEmpty" + class="has-no-search-results"> + {{searchEmptyMessage}} + </div> <group-folder + v-if="!searchEmpty" :groups="groups" /> <table-pagination + v-if="!searchEmpty" :change="change" :pageInfo="pageInfo" /> diff --git a/app/assets/javascripts/groups/components/item_actions.vue b/app/assets/javascripts/groups/components/item_actions.vue new file mode 100644 index 00000000000..ddb4febc3bd --- /dev/null +++ b/app/assets/javascripts/groups/components/item_actions.vue @@ -0,0 +1,92 @@ +<script> +import { s__ } from '../../locale'; +import tooltip from '../../vue_shared/directives/tooltip'; +import PopupDialog from '../../vue_shared/components/popup_dialog.vue'; +import eventHub from '../event_hub'; +import { COMMON_STR } from '../constants'; + +export default { + components: { + PopupDialog, + }, + directives: { + tooltip, + }, + props: { + parentGroup: { + type: Object, + required: false, + default: () => ({}), + }, + group: { + type: Object, + required: true, + }, + }, + data() { + return { + dialogStatus: false, + }; + }, + computed: { + leaveBtnTitle() { + return COMMON_STR.LEAVE_BTN_TITLE; + }, + editBtnTitle() { + return COMMON_STR.EDIT_BTN_TITLE; + }, + leaveConfirmationMessage() { + return s__(`GroupsTree|Are you sure you want to leave the "${this.group.fullName}" group?`); + }, + }, + methods: { + onLeaveGroup() { + this.dialogStatus = true; + }, + leaveGroup(leaveConfirmed) { + this.dialogStatus = false; + if (leaveConfirmed) { + eventHub.$emit('leaveGroup', this.group, this.parentGroup); + } + }, + }, +}; +</script> + +<template> + <div class="controls"> + <a + v-tooltip + v-if="group.canEdit" + :href="group.editPath" + :title="editBtnTitle" + :aria-label="editBtnTitle" + data-container="body" + class="edit-group btn no-expand"> + <i + class="fa fa-cogs" + aria-hidden="true"/> + </a> + <a + v-tooltip + v-if="group.canLeave" + @click.prevent="onLeaveGroup" + :href="group.leavePath" + :title="leaveBtnTitle" + :aria-label="leaveBtnTitle" + data-container="body" + class="leave-group btn no-expand"> + <i + class="fa fa-sign-out" + aria-hidden="true"/> + </a> + <popup-dialog + v-show="dialogStatus" + :primary-button-label="__('Leave')" + kind="warning" + :title="__('Are you sure?')" + :body="leaveConfirmationMessage" + @submit="leaveGroup" + /> + </div> +</template> diff --git a/app/assets/javascripts/groups/components/item_caret.vue b/app/assets/javascripts/groups/components/item_caret.vue new file mode 100644 index 00000000000..959b984816f --- /dev/null +++ b/app/assets/javascripts/groups/components/item_caret.vue @@ -0,0 +1,25 @@ +<script> +export default { + props: { + isGroupOpen: { + type: Boolean, + required: true, + default: false, + }, + }, + computed: { + iconClass() { + return this.isGroupOpen ? 'fa-caret-down' : 'fa-caret-right'; + }, + }, +}; +</script> + +<template> + <span class="folder-caret"> + <i + :class="iconClass" + class="fa" + aria-hidden="true"/> + </span> +</template> diff --git a/app/assets/javascripts/groups/components/item_stats.vue b/app/assets/javascripts/groups/components/item_stats.vue new file mode 100644 index 00000000000..9f8ac138fc3 --- /dev/null +++ b/app/assets/javascripts/groups/components/item_stats.vue @@ -0,0 +1,98 @@ +<script> +import tooltip from '../../vue_shared/directives/tooltip'; +import { ITEM_TYPE, VISIBILITY_TYPE_ICON, GROUP_VISIBILITY_TYPE, PROJECT_VISIBILITY_TYPE } from '../constants'; + +export default { + directives: { + tooltip, + }, + props: { + item: { + type: Object, + required: true, + }, + }, + computed: { + visibilityIcon() { + return VISIBILITY_TYPE_ICON[this.item.visibility]; + }, + visibilityTooltip() { + if (this.item.type === ITEM_TYPE.GROUP) { + return GROUP_VISIBILITY_TYPE[this.item.visibility]; + } + return PROJECT_VISIBILITY_TYPE[this.item.visibility]; + }, + isProject() { + return this.item.type === ITEM_TYPE.PROJECT; + }, + isGroup() { + return this.item.type === ITEM_TYPE.GROUP; + }, + }, +}; +</script> + +<template> + <div class="stats"> + <span + v-tooltip + v-if="isGroup" + :title="s__('Subgroups')" + class="number-subgroups" + data-placement="top" + data-container="body"> + <i + class="fa fa-folder" + aria-hidden="true" + /> + {{item.subgroupCount}} + </span> + <span + v-tooltip + v-if="isGroup" + :title="s__('Projects')" + class="number-projects" + data-placement="top" + data-container="body"> + <i + class="fa fa-bookmark" + aria-hidden="true" + /> + {{item.projectCount}} + </span> + <span + v-tooltip + v-if="isGroup" + :title="s__('Members')" + class="number-users" + data-placement="top" + data-container="body"> + <i + class="fa fa-users" + aria-hidden="true" + /> + {{item.memberCount}} + </span> + <span + v-if="isProject" + class="project-stars"> + <i + class="fa fa-star" + aria-hidden="true" + /> + {{item.starCount}} + </span> + <span + v-tooltip + :title="visibilityTooltip" + data-placement="left" + data-container="body" + class="item-visibility"> + <i + :class="visibilityIcon" + class="fa" + aria-hidden="true" + /> + </span> + </div> +</template> diff --git a/app/assets/javascripts/groups/components/item_type_icon.vue b/app/assets/javascripts/groups/components/item_type_icon.vue new file mode 100644 index 00000000000..c02a8ad6d8c --- /dev/null +++ b/app/assets/javascripts/groups/components/item_type_icon.vue @@ -0,0 +1,34 @@ +<script> +import { ITEM_TYPE } from '../constants'; + +export default { + props: { + itemType: { + type: String, + required: true, + }, + isGroupOpen: { + type: Boolean, + required: true, + default: false, + }, + }, + computed: { + iconClass() { + if (this.itemType === ITEM_TYPE.GROUP) { + return this.isGroupOpen ? 'fa-folder-open' : 'fa-folder'; + } + return 'fa-bookmark'; + }, + }, +}; +</script> + +<template> + <span class="item-type-icon"> + <i + :class="iconClass" + class="fa" + aria-hidden="true"/> + </span> +</template> diff --git a/app/assets/javascripts/groups/constants.js b/app/assets/javascripts/groups/constants.js new file mode 100644 index 00000000000..6fde41414b3 --- /dev/null +++ b/app/assets/javascripts/groups/constants.js @@ -0,0 +1,35 @@ +import { __, s__ } from '../locale'; + +export const MAX_CHILDREN_COUNT = 20; + +export const COMMON_STR = { + FAILURE: __('An error occurred. Please try again.'), + LEAVE_FORBIDDEN: s__('GroupsTree|Failed to leave the group. Please make sure you are not the only owner.'), + LEAVE_BTN_TITLE: s__('GroupsTree|Leave this group'), + EDIT_BTN_TITLE: s__('GroupsTree|Edit group'), + GROUP_SEARCH_EMPTY: s__('GroupsTree|Sorry, no groups matched your search'), + GROUP_PROJECT_SEARCH_EMPTY: s__('GroupsTree|Sorry, no groups or projects matched your search'), +}; + +export const ITEM_TYPE = { + PROJECT: 'project', + GROUP: 'group', +}; + +export const GROUP_VISIBILITY_TYPE = { + public: __('Public - The group and any public projects can be viewed without any authentication.'), + internal: __('Internal - The group and any internal projects can be viewed by any logged in user.'), + private: __('Private - The group and its projects can only be viewed by members.'), +}; + +export const PROJECT_VISIBILITY_TYPE = { + public: __('Public - The project can be accessed without any authentication.'), + internal: __('Internal - The project can be accessed by any logged in user.'), + private: __('Private - Project access must be granted explicitly to each user.'), +}; + +export const VISIBILITY_TYPE_ICON = { + public: 'fa-globe', + internal: 'fa-shield', + private: 'fa-lock', +}; diff --git a/app/assets/javascripts/groups/groups_filterable_list.js b/app/assets/javascripts/groups/groups_filterable_list.js index 83b102764ba..6a61a1ca355 100644 --- a/app/assets/javascripts/groups/groups_filterable_list.js +++ b/app/assets/javascripts/groups/groups_filterable_list.js @@ -3,12 +3,13 @@ import eventHub from './event_hub'; import { getParameterByName } from '../lib/utils/common_utils'; export default class GroupFilterableList extends FilterableList { - constructor({ form, filter, holder, filterEndpoint, pagePath }) { - super(form, filter, holder); + constructor({ form, filter, holder, filterEndpoint, pagePath, dropdownSel, filterInputField }) { + super(form, filter, holder, filterInputField); this.form = form; this.filterEndpoint = filterEndpoint; this.pagePath = pagePath; - this.$dropdown = $('.js-group-filter-dropdown-wrap'); + this.filterInputField = filterInputField; + this.$dropdown = $(dropdownSel); } getFilterEndpoint() { @@ -35,11 +36,11 @@ export default class GroupFilterableList extends FilterableList { e.preventDefault(); const $form = $(this.form); - const filterGroupsParam = $form.find('[name="filter_groups"]').val(); + const filterGroupsParam = $form.find(`[name="${this.filterInputField}"]`).val(); const queryData = {}; if (filterGroupsParam) { - queryData.filter_groups = filterGroupsParam; + queryData[this.filterInputField] = filterGroupsParam; } this.filterResults(queryData); @@ -47,7 +48,7 @@ export default class GroupFilterableList extends FilterableList { } setDefaultFilterOption() { - const defaultOption = $.trim(this.$dropdown.find('.dropdown-menu a:first-child').text()); + const defaultOption = $.trim(this.$dropdown.find('.dropdown-menu a').first().text()); this.$dropdown.find('.dropdown-label').text(defaultOption); } @@ -65,13 +66,15 @@ export default class GroupFilterableList extends FilterableList { // Active selected option this.$dropdown.find('.dropdown-label').text($.trim(e.currentTarget.text)); + this.$dropdown.find('.dropdown-menu li a').removeClass('is-active'); + $(e.target).addClass('is-active'); // Clear current value on search form - this.form.querySelector('[name="filter_groups"]').value = ''; + this.form.querySelector(`[name="${this.filterInputField}"]`).value = ''; } onFilterSuccess(data, xhr, queryData) { - super.onFilterSuccess(data, xhr, queryData); + const currentPath = this.getPagePath(queryData); const paginationData = { 'X-Per-Page': xhr.getResponseHeader('X-Per-Page'), @@ -82,7 +85,11 @@ export default class GroupFilterableList extends FilterableList { 'X-Prev-Page': xhr.getResponseHeader('X-Prev-Page'), }; - eventHub.$emit('updateGroups', data); + window.history.replaceState({ + page: currentPath, + }, document.title, currentPath); + + eventHub.$emit('updateGroups', data, Object.prototype.hasOwnProperty.call(queryData, this.filterInputField)); eventHub.$emit('updatePagination', paginationData); } } diff --git a/app/assets/javascripts/groups/index.js b/app/assets/javascripts/groups/index.js index 9ad8e5c6052..2b625ef013a 100644 --- a/app/assets/javascripts/groups/index.js +++ b/app/assets/javascripts/groups/index.js @@ -1,17 +1,22 @@ /* global Flash */ import Vue from 'vue'; + +import Translate from '../vue_shared/translate'; import GroupFilterableList from './groups_filterable_list'; -import GroupsComponent from './components/groups.vue'; -import GroupFolder from './components/group_folder.vue'; -import GroupItem from './components/group_item.vue'; -import GroupsStore from './stores/groups_store'; -import GroupsService from './services/groups_service'; -import eventHub from './event_hub'; -import { getParameterByName } from '../lib/utils/common_utils'; +import NewGroupChild from './new_group_child'; +import GroupsStore from './store/groups_store'; +import GroupsService from './service/groups_service'; + +import groupsApp from './components/app.vue'; +import groupFolderComponent from './components/group_folder.vue'; +import groupItemComponent from './components/group_item.vue'; + +Vue.use(Translate); document.addEventListener('DOMContentLoaded', () => { - const el = document.getElementById('dashboard-group-app'); + const el = document.getElementById('js-groups-tree'); + const newGroupChildWrapper = document.querySelector('.js-new-project-subgroup'); // Don't do anything if element doesn't exist (No groups) // This is for when the user enters directly to the page via URL @@ -19,176 +24,61 @@ document.addEventListener('DOMContentLoaded', () => { return; } - Vue.component('groups-component', GroupsComponent); - Vue.component('group-folder', GroupFolder); - Vue.component('group-item', GroupItem); + Vue.component('group-folder', groupFolderComponent); + Vue.component('group-item', groupItemComponent); + + if (newGroupChildWrapper) { + // eslint-disable-next-line no-new + new NewGroupChild(newGroupChildWrapper); + } // eslint-disable-next-line no-new new Vue({ el, + components: { + groupsApp, + }, data() { - this.store = new GroupsStore(); - this.service = new GroupsService(el.dataset.endpoint); + const dataset = this.$options.el.dataset; + const hideProjects = dataset.hideProjects === 'true'; + const store = new GroupsStore(hideProjects); + const service = new GroupsService(dataset.endpoint); return { - store: this.store, - isLoading: true, - state: this.store.state, + store, + service, + hideProjects, loading: true, }; }, - computed: { - isEmpty() { - return Object.keys(this.state.groups).length === 0; - }, - }, - methods: { - fetchGroups(parentGroup) { - let parentId = null; - let getGroups = null; - let page = null; - let sort = null; - let pageParam = null; - let sortParam = null; - let filterGroups = null; - let filterGroupsParam = null; - - if (parentGroup) { - parentId = parentGroup.id; - } else { - this.isLoading = true; - } - - pageParam = getParameterByName('page'); - if (pageParam) { - page = pageParam; - } - - filterGroupsParam = getParameterByName('filter_groups'); - if (filterGroupsParam) { - filterGroups = filterGroupsParam; - } - - sortParam = getParameterByName('sort'); - if (sortParam) { - sort = sortParam; - } - - getGroups = this.service.getGroups(parentId, page, filterGroups, sort); - getGroups - .then(response => response.json()) - .then((response) => { - this.isLoading = false; - - this.updateGroups(response, parentGroup); - }) - .catch(this.handleErrorResponse); - - return getGroups; - }, - fetchPage(page, filterGroups, sort) { - this.isLoading = true; - - return this.service - .getGroups(null, page, filterGroups, sort) - .then((response) => { - this.isLoading = false; - $.scrollTo(0); - - const currentPath = gl.utils.mergeUrlParams({ page }, window.location.href); - window.history.replaceState({ - page: currentPath, - }, document.title, currentPath); - - return response.json().then((data) => { - this.updateGroups(data); - this.updatePagination(response.headers); - }); - }) - .catch(this.handleErrorResponse); - }, - toggleSubGroups(parentGroup = null) { - if (!parentGroup.isOpen) { - this.store.resetGroups(parentGroup); - this.fetchGroups(parentGroup); - } - - this.store.toggleSubGroups(parentGroup); - }, - leaveGroup(group, collection) { - this.service.leaveGroup(group.leavePath) - .then(resp => resp.json()) - .then((response) => { - $.scrollTo(0); - - this.store.removeGroup(group, collection); - - // eslint-disable-next-line no-new - new Flash(response.notice, 'notice'); - }) - .catch((error) => { - let message = 'An error occurred. Please try again.'; - - if (error.status === 403) { - message = 'Failed to leave the group. Please make sure you are not the only owner'; - } - - // eslint-disable-next-line no-new - new Flash(message); - }); - }, - updateGroups(groups, parentGroup) { - this.store.setGroups(groups, parentGroup); - }, - updatePagination(headers) { - this.store.storePagination(headers); - }, - handleErrorResponse() { - this.isLoading = false; - $.scrollTo(0); - - // eslint-disable-next-line no-new - new Flash('An error occurred. Please try again.'); - }, - }, - created() { - eventHub.$on('fetchPage', this.fetchPage); - eventHub.$on('toggleSubGroups', this.toggleSubGroups); - eventHub.$on('leaveGroup', this.leaveGroup); - eventHub.$on('updateGroups', this.updateGroups); - eventHub.$on('updatePagination', this.updatePagination); - }, beforeMount() { + const dataset = this.$options.el.dataset; let groupFilterList = null; - const form = document.querySelector('form#group-filter-form'); - const filter = document.querySelector('.js-groups-list-filter'); - const holder = document.querySelector('.js-groups-list-holder'); + const form = document.querySelector(dataset.formSel); + const filter = document.querySelector(dataset.filterSel); + const holder = document.querySelector(dataset.holderSel); const opts = { form, filter, holder, - filterEndpoint: el.dataset.endpoint, - pagePath: el.dataset.path, + filterEndpoint: dataset.endpoint, + pagePath: dataset.path, + dropdownSel: dataset.dropdownSel, + filterInputField: 'filter', }; groupFilterList = new GroupFilterableList(opts); groupFilterList.initSearch(); }, - mounted() { - this.fetchGroups() - .then((response) => { - this.updatePagination(response.headers); - this.isLoading = false; - }) - .catch(this.handleErrorResponse); - }, - beforeDestroy() { - eventHub.$off('fetchPage', this.fetchPage); - eventHub.$off('toggleSubGroups', this.toggleSubGroups); - eventHub.$off('leaveGroup', this.leaveGroup); - eventHub.$off('updateGroups', this.updateGroups); - eventHub.$off('updatePagination', this.updatePagination); + render(createElement) { + return createElement('groups-app', { + props: { + store: this.store, + service: this.service, + hideProjects: this.hideProjects, + }, + }); }, }); }); diff --git a/app/assets/javascripts/groups/new_group_child.js b/app/assets/javascripts/groups/new_group_child.js new file mode 100644 index 00000000000..8e273579aae --- /dev/null +++ b/app/assets/javascripts/groups/new_group_child.js @@ -0,0 +1,62 @@ +import DropLab from '../droplab/drop_lab'; +import ISetter from '../droplab/plugins/input_setter'; + +const InputSetter = Object.assign({}, ISetter); + +const NEW_PROJECT = 'new-project'; +const NEW_SUBGROUP = 'new-subgroup'; + +export default class NewGroupChild { + constructor(buttonWrapper) { + this.buttonWrapper = buttonWrapper; + this.newGroupChildButton = this.buttonWrapper.querySelector('.js-new-group-child'); + this.dropdownToggle = this.buttonWrapper.querySelector('.js-dropdown-toggle'); + this.dropdownList = this.buttonWrapper.querySelector('.dropdown-menu'); + + this.newGroupPath = this.buttonWrapper.dataset.projectPath; + this.subgroupPath = this.buttonWrapper.dataset.subgroupPath; + + this.init(); + } + + init() { + this.initDroplab(); + this.bindEvents(); + } + + initDroplab() { + this.droplab = new DropLab(); + this.droplab.init( + this.dropdownToggle, + this.dropdownList, + [InputSetter], + this.getDroplabConfig(), + ); + } + + getDroplabConfig() { + return { + InputSetter: [{ + input: this.newGroupChildButton, + valueAttribute: 'data-value', + inputAttribute: 'data-action', + }, { + input: this.newGroupChildButton, + valueAttribute: 'data-text', + }], + }; + } + + bindEvents() { + this.newGroupChildButton + .addEventListener('click', this.onClickNewGroupChildButton.bind(this)); + } + + onClickNewGroupChildButton(e) { + if (e.target.dataset.action === NEW_PROJECT) { + gl.utils.visitUrl(this.newGroupPath); + } else if (e.target.dataset.action === NEW_SUBGROUP) { + gl.utils.visitUrl(this.subgroupPath); + } + } +} diff --git a/app/assets/javascripts/groups/services/groups_service.js b/app/assets/javascripts/groups/service/groups_service.js index 97e02fcb76d..1393c96aed6 100644 --- a/app/assets/javascripts/groups/services/groups_service.js +++ b/app/assets/javascripts/groups/service/groups_service.js @@ -20,7 +20,7 @@ export default class GroupsService { } if (filterGroups) { - data.filter_groups = filterGroups; + data.filter = filterGroups; } if (sort) { diff --git a/app/assets/javascripts/groups/store/groups_store.js b/app/assets/javascripts/groups/store/groups_store.js new file mode 100644 index 00000000000..a1689f4c5cc --- /dev/null +++ b/app/assets/javascripts/groups/store/groups_store.js @@ -0,0 +1,105 @@ +import { normalizeHeaders, parseIntPagination } from '../../lib/utils/common_utils'; + +export default class GroupsStore { + constructor(hideProjects) { + this.state = {}; + this.state.groups = []; + this.state.pageInfo = {}; + this.hideProjects = hideProjects; + } + + setGroups(rawGroups) { + if (rawGroups && rawGroups.length) { + this.state.groups = rawGroups.map(rawGroup => this.formatGroupItem(rawGroup)); + } else { + this.state.groups = []; + } + } + + setSearchedGroups(rawGroups) { + const formatGroups = groups => groups.map((group) => { + const formattedGroup = this.formatGroupItem(group); + if (formattedGroup.children && formattedGroup.children.length) { + formattedGroup.children = formatGroups(formattedGroup.children); + } + return formattedGroup; + }); + + if (rawGroups && rawGroups.length) { + this.state.groups = formatGroups(rawGroups); + } else { + this.state.groups = []; + } + } + + setGroupChildren(parentGroup, children) { + const updatedParentGroup = parentGroup; + updatedParentGroup.children = children.map(rawChild => this.formatGroupItem(rawChild)); + updatedParentGroup.isOpen = true; + updatedParentGroup.isChildrenLoading = false; + } + + getGroups() { + return this.state.groups; + } + + setPaginationInfo(pagination = {}) { + let paginationInfo; + + if (Object.keys(pagination).length) { + const normalizedHeaders = normalizeHeaders(pagination); + paginationInfo = parseIntPagination(normalizedHeaders); + } else { + paginationInfo = pagination; + } + + this.state.pageInfo = paginationInfo; + } + + getPaginationInfo() { + return this.state.pageInfo; + } + + formatGroupItem(rawGroupItem) { + const groupChildren = rawGroupItem.children || []; + const groupIsOpen = (groupChildren.length > 0) || false; + const childrenCount = this.hideProjects ? + rawGroupItem.subgroup_count : + rawGroupItem.children_count; + + return { + id: rawGroupItem.id, + name: rawGroupItem.name, + fullName: rawGroupItem.full_name, + description: rawGroupItem.description, + visibility: rawGroupItem.visibility, + avatarUrl: rawGroupItem.avatar_url, + relativePath: rawGroupItem.relative_path, + editPath: rawGroupItem.edit_path, + leavePath: rawGroupItem.leave_path, + canEdit: rawGroupItem.can_edit, + canLeave: rawGroupItem.can_leave, + type: rawGroupItem.type, + permission: rawGroupItem.permission, + children: groupChildren, + isOpen: groupIsOpen, + isChildrenLoading: false, + isBeingRemoved: false, + parentId: rawGroupItem.parent_id, + childrenCount, + projectCount: rawGroupItem.project_count, + subgroupCount: rawGroupItem.subgroup_count, + memberCount: rawGroupItem.number_users_with_delimiter, + starCount: rawGroupItem.star_count, + }; + } + + removeGroup(group, parentGroup) { + const updatedParentGroup = parentGroup; + if (updatedParentGroup.children && updatedParentGroup.children.length) { + updatedParentGroup.children = parentGroup.children.filter(child => group.id !== child.id); + } else { + this.state.groups = this.state.groups.filter(child => group.id !== child.id); + } + } +} diff --git a/app/assets/javascripts/groups/stores/groups_store.js b/app/assets/javascripts/groups/stores/groups_store.js deleted file mode 100644 index f59ec677603..00000000000 --- a/app/assets/javascripts/groups/stores/groups_store.js +++ /dev/null @@ -1,167 +0,0 @@ -import Vue from 'vue'; -import { parseIntPagination, normalizeHeaders } from '../../lib/utils/common_utils'; - -export default class GroupsStore { - constructor() { - this.state = {}; - this.state.groups = {}; - this.state.pageInfo = {}; - } - - setGroups(rawGroups, parent) { - const parentGroup = parent; - const tree = this.buildTree(rawGroups, parentGroup); - - if (parentGroup) { - parentGroup.subGroups = tree; - } else { - this.state.groups = tree; - } - - return tree; - } - - // eslint-disable-next-line class-methods-use-this - resetGroups(parent) { - const parentGroup = parent; - parentGroup.subGroups = {}; - } - - storePagination(pagination = {}) { - let paginationInfo; - - if (Object.keys(pagination).length) { - const normalizedHeaders = normalizeHeaders(pagination); - paginationInfo = parseIntPagination(normalizedHeaders); - } else { - paginationInfo = pagination; - } - - this.state.pageInfo = paginationInfo; - } - - buildTree(rawGroups, parentGroup) { - const groups = this.decorateGroups(rawGroups); - const tree = {}; - const mappedGroups = {}; - const orphans = []; - - // Map groups to an object - groups.map((group) => { - mappedGroups[`id${group.id}`] = group; - mappedGroups[`id${group.id}`].subGroups = {}; - return group; - }); - - Object.keys(mappedGroups).map((key) => { - const currentGroup = mappedGroups[key]; - if (currentGroup.parentId) { - // If the group is not at the root level, add it to its parent array of subGroups. - const findParentGroup = mappedGroups[`id${currentGroup.parentId}`]; - if (findParentGroup) { - mappedGroups[`id${currentGroup.parentId}`].subGroups[`id${currentGroup.id}`] = currentGroup; - mappedGroups[`id${currentGroup.parentId}`].isOpen = true; // Expand group if it has subgroups - } else if (parentGroup && parentGroup.id === currentGroup.parentId) { - tree[`id${currentGroup.id}`] = currentGroup; - } else { - // No parent found. We save it for later processing - orphans.push(currentGroup); - - // Add to tree to preserve original order - tree[`id${currentGroup.id}`] = currentGroup; - } - } else { - // If the group is at the top level, add it to first level elements array. - tree[`id${currentGroup.id}`] = currentGroup; - } - - return key; - }); - - if (orphans.length) { - orphans.map((orphan) => { - let found = false; - const currentOrphan = orphan; - - Object.keys(tree).map((key) => { - const group = tree[key]; - - if ( - group && - currentOrphan.fullPath.lastIndexOf(group.fullPath) === 0 && - // Make sure the currently selected orphan is not the same as the group - // we are checking here otherwise it will end up in an infinite loop - currentOrphan.id !== group.id - ) { - group.subGroups[currentOrphan.id] = currentOrphan; - group.isOpen = true; - currentOrphan.isOrphan = true; - found = true; - - // Delete if group was put at the top level. If not the group will be displayed twice. - if (tree[`id${currentOrphan.id}`]) { - delete tree[`id${currentOrphan.id}`]; - } - } - - return key; - }); - - if (!found) { - currentOrphan.isOrphan = true; - - tree[`id${currentOrphan.id}`] = currentOrphan; - } - - return orphan; - }); - } - - return tree; - } - - decorateGroups(rawGroups) { - this.groups = rawGroups.map(this.decorateGroup); - return this.groups; - } - - // eslint-disable-next-line class-methods-use-this - decorateGroup(rawGroup) { - return { - id: rawGroup.id, - fullName: rawGroup.full_name, - fullPath: rawGroup.full_path, - avatarUrl: rawGroup.avatar_url, - name: rawGroup.name, - hasSubgroups: rawGroup.has_subgroups, - canEdit: rawGroup.can_edit, - description: rawGroup.description, - webUrl: rawGroup.web_url, - groupPath: rawGroup.group_path, - parentId: rawGroup.parent_id, - visibility: rawGroup.visibility, - leavePath: rawGroup.leave_path, - editPath: rawGroup.edit_path, - isOpen: false, - isOrphan: false, - numberProjects: rawGroup.number_projects_with_delimiter, - numberUsers: rawGroup.number_users_with_delimiter, - permissions: { - humanGroupAccess: rawGroup.permissions.human_group_access, - }, - subGroups: {}, - }; - } - - // eslint-disable-next-line class-methods-use-this - removeGroup(group, collection) { - Vue.delete(collection, `id${group.id}`); - } - - // eslint-disable-next-line class-methods-use-this - toggleSubGroups(toggleGroup) { - const group = toggleGroup; - group.isOpen = !group.isOpen; - return group; - } -} diff --git a/app/assets/stylesheets/framework/lists.scss b/app/assets/stylesheets/framework/lists.scss index badc7b0eba3..d43f998cb82 100644 --- a/app/assets/stylesheets/framework/lists.scss +++ b/app/assets/stylesheets/framework/lists.scss @@ -281,6 +281,57 @@ ul.indent-list { // Specific styles for tree list +@keyframes spin-avatar { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +.groups-list-tree-container { + .has-no-search-results { + text-align: center; + padding: $gl-padding; + font-style: italic; + color: $well-light-text-color; + } + + > .group-list-tree > .group-row.has-children:first-child { + border-top: none; + } +} + +.group-list-tree .avatar-container.content-loading { + position: relative; + + > a, + > a .avatar { + height: 100%; + border-radius: 50%; + } + + > a { + padding: 2px; + } + + > a .avatar { + border: 2px solid $white-normal; + + &.identicon { + line-height: 30px; + } + } + + &::after { + content: ""; + position: absolute; + height: 100%; + width: 100%; + background-color: transparent; + border: 2px outset $kdb-border; + border-radius: 50%; + animation: spin-avatar 3s infinite linear; + } +} + .group-list-tree { .folder-toggle-wrap { float: left; @@ -293,7 +344,7 @@ ul.indent-list { } .folder-caret, - .folder-icon { + .item-type-icon { display: inline-block; } @@ -301,11 +352,11 @@ ul.indent-list { width: 15px; } - .folder-icon { + .item-type-icon { width: 20px; } - > .group-row:not(.has-subgroups) { + > .group-row:not(.has-children) { .folder-caret .fa { opacity: 0; } @@ -351,12 +402,23 @@ ul.indent-list { top: 30px; bottom: 0; } + + &.being-removed { + opacity: 0.5; + } } } .group-row { padding: 0; - border: none; + + &.has-children { + border-top: none; + } + + &:first-child { + border-top: 1px solid $white-normal; + } &:last-of-type { .group-row-contents:not(:hover) { @@ -379,6 +441,25 @@ ul.indent-list { .avatar-container > a { width: 100%; } + + &.has-more-items { + display: block; + padding: 20px 10px; + } + } +} + +ul.group-list-tree { + li.group-row { + &.has-description { + .title { + line-height: inherit; + } + } + + .title { + line-height: $list-text-height; + } } } diff --git a/app/assets/stylesheets/pages/groups.scss b/app/assets/stylesheets/pages/groups.scss index 6f6c6839975..9b7dda9b648 100644 --- a/app/assets/stylesheets/pages/groups.scss +++ b/app/assets/stylesheets/pages/groups.scss @@ -26,14 +26,117 @@ } } -.groups-header { - @media (min-width: $screen-sm-min) { - .nav-links { - width: 35%; +.group-nav-container .nav-controls { + display: flex; + align-items: flex-start; + padding: $gl-padding-top 0; + border-bottom: 1px solid $border-color; + + .group-filter-form { + flex: 1; + } + + .dropdown-menu-align-right { + margin-top: 0; + } + + .new-project-subgroup { + .dropdown-primary { + min-width: 115px; + } + + .dropdown-toggle { + .dropdown-btn-icon { + pointer-events: none; + color: inherit; + margin-left: 0; + } } - .nav-controls { - width: 65%; + .dropdown-menu { + min-width: 280px; + margin-top: 2px; + } + + li:not(.divider) { + padding: 0; + + &.droplab-item-selected { + .icon-container { + .list-item-checkmark { + visibility: visible; + } + } + } + + .menu-item { + padding: 8px 4px; + + &:hover { + background-color: $gray-darker; + color: $theme-gray-900; + } + } + + .icon-container { + float: left; + padding-left: 6px; + + .list-item-checkmark { + visibility: hidden; + } + } + + .description { + font-size: 14px; + + strong { + display: block; + font-weight: $gl-font-weight-bold; + } + } + } + } + + @media (max-width: $screen-sm-max) { + &, + .dropdown, + .dropdown .dropdown-toggle, + .btn-new { + display: block; + } + + .group-filter-form, + .dropdown { + margin-bottom: 10px; + margin-right: 0; + } + + .group-filter-form, + .dropdown .dropdown-toggle, + .btn-new { + width: 100%; + } + + .dropdown .dropdown-toggle .fa-chevron-down { + position: absolute; + top: 11px; + right: 8px; + } + + .new-project-subgroup { + display: flex; + align-items: flex-start; + + .dropdown-primary { + flex: 1; + } + + .dropdown-menu { + width: 100%; + max-width: inherit; + min-width: inherit; + } } } } diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb index 1b542ed2a96..b05eb93b465 100644 --- a/app/helpers/sorting_helper.rb +++ b/app/helpers/sorting_helper.rb @@ -42,6 +42,17 @@ module SortingHelper options end + def groups_sort_options_hash + options = { + sort_value_recently_created => sort_title_recently_created, + sort_value_oldest_created => sort_title_oldest_created, + sort_value_recently_updated => sort_title_recently_updated, + sort_value_oldest_updated => sort_title_oldest_updated + } + + options + end + def member_sort_options_hash { sort_value_access_level_asc => sort_title_access_level_asc, diff --git a/app/views/dashboard/_groups_head.html.haml b/app/views/dashboard/_groups_head.html.haml index 7981daa0705..cebdbab4e74 100644 --- a/app/views/dashboard/_groups_head.html.haml +++ b/app/views/dashboard/_groups_head.html.haml @@ -1,13 +1,13 @@ .top-area %ul.nav-links = nav_link(page: dashboard_groups_path) do - = link_to dashboard_groups_path, title: 'Your groups' do + = link_to dashboard_groups_path, title: _("Your groups") do Your groups = nav_link(page: explore_groups_path) do - = link_to explore_groups_path, title: 'Explore public groups' do + = link_to explore_groups_path, title: _("Explore public groups") do Explore public groups .nav-controls = render 'shared/groups/search_form' = render 'shared/groups/dropdown' - if current_user.can_create_group? - = link_to "New group", new_group_path, class: "btn btn-new" + = link_to _("New group"), new_group_path, class: "btn btn-new" diff --git a/app/views/dashboard/groups/_empty_state.html.haml b/app/views/dashboard/groups/_empty_state.html.haml deleted file mode 100644 index f5222fe631e..00000000000 --- a/app/views/dashboard/groups/_empty_state.html.haml +++ /dev/null @@ -1,7 +0,0 @@ -.groups-empty-state - = custom_icon("icon_empty_groups") - - .text-content - %h4 A group is a collection of several projects. - %p If you organize your projects under a group, it works like a folder. - %p You can manage your group member’s permissions and access to each project in the group. diff --git a/app/views/dashboard/groups/_groups.html.haml b/app/views/dashboard/groups/_groups.html.haml index 168e6272d8e..601b6a8b1a7 100644 --- a/app/views/dashboard/groups/_groups.html.haml +++ b/app/views/dashboard/groups/_groups.html.haml @@ -1,9 +1,2 @@ .js-groups-list-holder - #dashboard-group-app{ data: { endpoint: dashboard_groups_path(format: :json), path: dashboard_groups_path } } - .groups-list-loading - = icon('spinner spin', 'v-show' => 'isLoading') - %template{ 'v-if' => '!isLoading && isEmpty' } - %div{ 'v-cloak' => true } - = render 'empty_state' - %template{ 'v-else-if' => '!isLoading && !isEmpty' } - %groups-component{ ':groups' => 'state.groups', ':page-info' => 'state.pageInfo' } + #js-groups-tree{ data: { hide_projects: 'true', endpoint: dashboard_groups_path(format: :json), path: dashboard_groups_path, form_sel: 'form#group-filter-form', filter_sel: '.js-groups-list-filter', holder_sel: '.js-groups-list-holder', dropdown_sel: '.js-group-filter-dropdown-wrap' } } diff --git a/app/views/dashboard/groups/index.html.haml b/app/views/dashboard/groups/index.html.haml index 1cea8182733..25bf08c6c12 100644 --- a/app/views/dashboard/groups/index.html.haml +++ b/app/views/dashboard/groups/index.html.haml @@ -6,7 +6,7 @@ = webpack_bundle_tag 'common_vue' = webpack_bundle_tag 'groups' -- if @groups.empty? - = render 'empty_state' +- if params[:filter].blank? && @groups.empty? + = render 'shared/groups/empty_state' - else = render 'groups' diff --git a/app/views/explore/groups/_groups.html.haml b/app/views/explore/groups/_groups.html.haml index 794c6d1d170..91149498248 100644 --- a/app/views/explore/groups/_groups.html.haml +++ b/app/views/explore/groups/_groups.html.haml @@ -1,6 +1,2 @@ .js-groups-list-holder - %ul.content-list - - @groups.each do |group| - = render 'shared/groups/group', group: group - - = paginate @groups, theme: 'gitlab' + #js-groups-tree{ data: { hide_projects: 'true', endpoint: explore_groups_path(format: :json), path: explore_groups_path, form_sel: 'form#group-filter-form', filter_sel: '.js-groups-list-filter', holder_sel: '.js-groups-list-holder', dropdown_sel: '.js-group-filter-dropdown-wrap' } } diff --git a/app/views/explore/groups/index.html.haml b/app/views/explore/groups/index.html.haml index 2651ef37e67..86abdf547cc 100644 --- a/app/views/explore/groups/index.html.haml +++ b/app/views/explore/groups/index.html.haml @@ -2,6 +2,9 @@ - page_title "Groups" - header_title "Groups", dashboard_groups_path += webpack_bundle_tag 'common_vue' += webpack_bundle_tag 'groups' + - if current_user = render 'dashboard/groups_head' - else @@ -17,7 +20,7 @@ %p Below you will find all the groups that are public. %p You can easily contribute to them by requesting to join these groups. -- if @groups.present? - = render 'groups' -- else +- if params[:filter].blank? && @groups.empty? .nothing-here-block No public groups +- else + = render 'groups' diff --git a/app/views/groups/_children.html.haml b/app/views/groups/_children.html.haml index e22d9cc6013..3afb6b2f849 100644 --- a/app/views/groups/_children.html.haml +++ b/app/views/groups/_children.html.haml @@ -1,4 +1,5 @@ -- if children.any? - render children here -- else - .nothing-here-block No children found += webpack_bundle_tag 'common_vue' += webpack_bundle_tag 'groups' + +.js-groups-list-holder + #js-groups-tree{ data: { hide_projects: 'false', group_id: group.id, endpoint: group_children_path(group, format: :json), path: group_path(group), form_sel: 'form#group-filter-form', filter_sel: '.js-groups-list-filter', holder_sel: '.js-groups-list-holder', dropdown_sel: '.js-group-filter-dropdown-wrap' } } diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml index a8842596dbd..c6e5e16e239 100644 --- a/app/views/groups/show.html.haml +++ b/app/views/groups/show.html.haml @@ -7,12 +7,35 @@ = render 'groups/home_panel' .groups-header{ class: container_class } - .top-area - .nav-controls - = render 'shared/projects/search_form' - = render 'shared/projects/dropdown' + .group-nav-container + .nav-controls.clearfix + = render "shared/groups/search_form" + = render "shared/groups/dropdown" - if can? current_user, :create_projects, @group - = link_to new_project_path(namespace_id: @group.id), class: 'btn btn-new pull-right' do - New Project + - new_project_label = _("New project") + - new_subgroup_label = _("New subgroup") + .btn-group.new-project-subgroup.droplab-dropdown.js-new-project-subgroup{ data: { project_path: new_project_path(namespace_id: @group.id), subgroup_path: new_group_path(parent_id: @group.id) } } + %input.btn.btn-success.dropdown-primary.js-new-group-child{ type: "button", value: new_project_label, data: { action: "new-project" } } + %button.btn.btn-success.dropdown-toggle.js-dropdown-toggle{ type: "button", data: { "dropdown-trigger" => "#new-project-or-subgroup-dropdown" } } + = icon("caret-down", class: "dropdown-btn-icon") + %ul#new-project-or-subgroup-dropdown.dropdown-menu.dropdown-menu-align-right{ data: { dropdown: true } } + %li.droplab-item-selected{ role: "button", data: { value: "new-project", text: new_project_label } } + .menu-item + .icon-container + = icon("check", class: "list-item-checkmark") + .description + %strong= new_project_label + %span= s_("GroupsTree|Create project under this group.") + %li.divider.droplap-item-ignore + %li{ role: "button", data: { value: "new-subgroup", text: new_subgroup_label } } + .menu-item + .icon-container + = icon("check", class: "list-item-checkmark") + .description + %strong= new_subgroup_label + %span= s_("GroupsTree|Create a subgroup under this group.") - = render "children", children: @children + - if params[:filter].blank? && @children.empty? + = render "shared/groups/empty_state" + - else + = render "children", children: @children, group: @group diff --git a/app/views/shared/groups/_dropdown.html.haml b/app/views/shared/groups/_dropdown.html.haml index 760370a6984..355b03e4d9e 100644 --- a/app/views/shared/groups/_dropdown.html.haml +++ b/app/views/shared/groups/_dropdown.html.haml @@ -1,18 +1,21 @@ -.dropdown.inline.js-group-filter-dropdown-wrap +- if @sort.present? + - default_sort_by = @sort +- else + - if params[:sort] + - default_sort_by = params[:sort] + - else + - default_sort_by = sort_value_recently_created + + +.dropdown.inline.js-group-filter-dropdown-wrap.append-right-10 %button.dropdown-toggle{ type: 'button', 'data-toggle' => 'dropdown' } %span.dropdown-label - - if @sort.present? - = sort_options_hash[@sort] - - else - = sort_title_recently_created + = sort_options_hash[default_sort_by] = icon('chevron-down') - %ul.dropdown-menu.dropdown-menu-align-right - %li - = link_to filter_groups_path(sort: sort_value_recently_created) do - = sort_title_recently_created - = link_to filter_groups_path(sort: sort_value_oldest_created) do - = sort_title_oldest_created - = link_to filter_groups_path(sort: sort_value_recently_updated) do - = sort_title_recently_updated - = link_to filter_groups_path(sort: sort_value_oldest_updated) do - = sort_title_oldest_updated + %ul.dropdown-menu.dropdown-menu-align-right.dropdown-menu-selectable + %li.dropdown-header + = _("Sort by") + - groups_sort_options_hash.each do |value, title| + %li + = link_to filter_groups_path(sort: value), class: "#{ 'is-active' if default_sort_by == value }" do + = title diff --git a/app/views/shared/groups/_empty_state.html.haml b/app/views/shared/groups/_empty_state.html.haml new file mode 100644 index 00000000000..13bb4baee3f --- /dev/null +++ b/app/views/shared/groups/_empty_state.html.haml @@ -0,0 +1,7 @@ +.groups-empty-state + = custom_icon("icon_empty_groups") + + .text-content + %h4= s_("GroupsEmptyState|A group is a collection of several projects.") + %p= s_("GroupsEmptyState|If you organize your projects under a group, it works like a folder.") + %p= s_("GroupsEmptyState|You can manage your group member’s permissions and access to each project in the group.") diff --git a/app/views/shared/groups/_group.html.haml b/app/views/shared/groups/_group.html.haml index b361ec86ced..eb41daa0988 100644 --- a/app/views/shared/groups/_group.html.haml +++ b/app/views/shared/groups/_group.html.haml @@ -11,7 +11,7 @@ = link_to edit_group_path(group), class: "btn" do = icon('cogs') - = link_to leave_group_group_members_path(group), data: { confirm: leave_confirmation_message(group) }, method: :delete, class: "btn", title: 'Leave this group' do + = link_to leave_group_group_members_path(group), data: { confirm: leave_confirmation_message(group) }, method: :delete, class: "btn", title: s_("GroupsTree|Leave this group") do = icon('sign-out') .stats diff --git a/app/views/shared/groups/_list.html.haml b/app/views/shared/groups/_list.html.haml index 427595c47a5..aec8ecd1714 100644 --- a/app/views/shared/groups/_list.html.haml +++ b/app/views/shared/groups/_list.html.haml @@ -3,4 +3,4 @@ - groups.each_with_index do |group, i| = render "shared/groups/group", group: group - else - .nothing-here-block No groups found + .nothing-here-block= s_("GroupsEmptyState|No groups found") diff --git a/app/views/shared/groups/_search_form.html.haml b/app/views/shared/groups/_search_form.html.haml index ad7a7faedf1..3f91263089a 100644 --- a/app/views/shared/groups/_search_form.html.haml +++ b/app/views/shared/groups/_search_form.html.haml @@ -1,2 +1,2 @@ -= form_tag request.path, method: :get, class: 'group-filter-form', id: 'group-filter-form' do |f| - = search_field_tag :filter_groups, params[:filter_groups], placeholder: 'Filter by name...', class: 'group-filter-form-field form-control input-short js-groups-list-filter', spellcheck: false, id: 'group-filter-form-field', tabindex: "2" += form_tag request.path, method: :get, class: 'group-filter-form append-right-10', id: 'group-filter-form' do |f| + = search_field_tag :filter, params[:filter], placeholder: s_('GroupsTree|Filter by name...'), class: 'group-filter-form-field form-control input-short js-groups-list-filter', spellcheck: false, id: 'group-filter-form-field', tabindex: "2" diff --git a/app/views/shared/projects/_dropdown.html.haml b/app/views/shared/projects/_dropdown.html.haml index 80432a73e4e..3d917346f6b 100644 --- a/app/views/shared/projects/_dropdown.html.haml +++ b/app/views/shared/projects/_dropdown.html.haml @@ -1,5 +1,5 @@ - @sort ||= sort_value_latest_activity -.dropdown +.dropdown.js-project-filter-dropdown-wrap - toggle_text = projects_sort_options_hash[@sort] = dropdown_toggle(toggle_text, { toggle: 'dropdown' }, { id: 'sort-projects-dropdown' }) %ul.dropdown-menu.dropdown-menu-align-right.dropdown-menu-selectable diff --git a/spec/features/dashboard/groups_list_spec.rb b/spec/features/dashboard/groups_list_spec.rb index 227550e62be..9cfef46d346 100644 --- a/spec/features/dashboard/groups_list_spec.rb +++ b/spec/features/dashboard/groups_list_spec.rb @@ -16,6 +16,7 @@ feature 'Dashboard Groups page', :js do sign_in(user) visit dashboard_groups_path + wait_for_requests expect(page).to have_content(group.full_name) expect(page).to have_content(nested_group.full_name) @@ -33,7 +34,7 @@ feature 'Dashboard Groups page', :js do end it 'filters groups' do - fill_in 'filter_groups', with: group.name + fill_in 'filter', with: group.name wait_for_requests expect(page).to have_content(group.full_name) @@ -42,10 +43,10 @@ feature 'Dashboard Groups page', :js do end it 'resets search when user cleans the input' do - fill_in 'filter_groups', with: group.name + fill_in 'filter', with: group.name wait_for_requests - fill_in 'filter_groups', with: '' + fill_in 'filter', with: '' wait_for_requests expect(page).to have_content(group.full_name) diff --git a/spec/features/explore/groups_list_spec.rb b/spec/features/explore/groups_list_spec.rb index fa3d8f97a09..41778542e23 100644 --- a/spec/features/explore/groups_list_spec.rb +++ b/spec/features/explore/groups_list_spec.rb @@ -15,6 +15,7 @@ describe 'Explore Groups page', :js do sign_in(user) visit explore_groups_path + wait_for_requests end it 'shows groups user is member of' do @@ -24,7 +25,7 @@ describe 'Explore Groups page', :js do end it 'filters groups' do - fill_in 'filter_groups', with: group.name + fill_in 'filter', with: group.name wait_for_requests expect(page).to have_content(group.full_name) @@ -33,10 +34,10 @@ describe 'Explore Groups page', :js do end it 'resets search when user cleans the input' do - fill_in 'filter_groups', with: group.name + fill_in 'filter', with: group.name wait_for_requests - fill_in 'filter_groups', with: "" + fill_in 'filter', with: "" wait_for_requests expect(page).to have_content(group.full_name) @@ -47,21 +48,21 @@ describe 'Explore Groups page', :js do it 'shows non-archived projects count' do # Initially project is not archived - expect(find('.js-groups-list-holder .content-list li:first-child .stats span:first-child')).to have_text("1") + expect(find('.js-groups-list-holder .content-list li:first-child .stats .number-projects')).to have_text("1") # Archive project empty_project.archive! visit explore_groups_path # Check project count - expect(find('.js-groups-list-holder .content-list li:first-child .stats span:first-child')).to have_text("0") + expect(find('.js-groups-list-holder .content-list li:first-child .stats .number-projects')).to have_text("0") # Unarchive project empty_project.unarchive! visit explore_groups_path # Check project count - expect(find('.js-groups-list-holder .content-list li:first-child .stats span:first-child')).to have_text("1") + expect(find('.js-groups-list-holder .content-list li:first-child .stats .number-projects')).to have_text("1") end describe 'landing component' do diff --git a/spec/javascripts/groups/components/app_spec.js b/spec/javascripts/groups/components/app_spec.js new file mode 100644 index 00000000000..8472c726b08 --- /dev/null +++ b/spec/javascripts/groups/components/app_spec.js @@ -0,0 +1,440 @@ +import Vue from 'vue'; + +import appComponent from '~/groups/components/app.vue'; +import groupFolderComponent from '~/groups/components/group_folder.vue'; +import groupItemComponent from '~/groups/components/group_item.vue'; + +import eventHub from '~/groups/event_hub'; +import GroupsStore from '~/groups/store/groups_store'; +import GroupsService from '~/groups/service/groups_service'; + +import { + mockEndpoint, mockGroups, mockSearchedGroups, + mockRawPageInfo, mockParentGroupItem, mockRawChildren, + mockChildren, mockPageInfo, +} from '../mock_data'; + +const createComponent = (hideProjects = false) => { + const Component = Vue.extend(appComponent); + const store = new GroupsStore(false); + const service = new GroupsService(mockEndpoint); + + return new Component({ + propsData: { + store, + service, + hideProjects, + }, + }); +}; + +const returnServicePromise = (data, failed) => new Promise((resolve, reject) => { + if (failed) { + reject(data); + } else { + resolve({ + json() { + return data; + }, + }); + } +}); + +describe('AppComponent', () => { + let vm; + + beforeEach((done) => { + Vue.component('group-folder', groupFolderComponent); + Vue.component('group-item', groupItemComponent); + + vm = createComponent(); + + Vue.nextTick(() => { + done(); + }); + }); + + describe('computed', () => { + beforeEach(() => { + vm.$mount(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('groups', () => { + it('should return list of groups from store', () => { + spyOn(vm.store, 'getGroups'); + + const groups = vm.groups; + expect(vm.store.getGroups).toHaveBeenCalled(); + expect(groups).not.toBeDefined(); + }); + }); + + describe('pageInfo', () => { + it('should return pagination info from store', () => { + spyOn(vm.store, 'getPaginationInfo'); + + const pageInfo = vm.pageInfo; + expect(vm.store.getPaginationInfo).toHaveBeenCalled(); + expect(pageInfo).not.toBeDefined(); + }); + }); + }); + + describe('methods', () => { + beforeEach(() => { + vm.$mount(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('fetchGroups', () => { + it('should call `getGroups` with all the params provided', (done) => { + spyOn(vm.service, 'getGroups').and.returnValue(returnServicePromise(mockGroups)); + + vm.fetchGroups({ + parentId: 1, + page: 2, + filterGroupsBy: 'git', + sortBy: 'created_desc', + }); + setTimeout(() => { + expect(vm.service.getGroups).toHaveBeenCalledWith(1, 2, 'git', 'created_desc'); + done(); + }, 0); + }); + + it('should set headers to store for building pagination info when called with `updatePagination`', (done) => { + spyOn(vm.service, 'getGroups').and.returnValue(returnServicePromise({ headers: mockRawPageInfo })); + spyOn(vm, 'updatePagination'); + + vm.fetchGroups({ updatePagination: true }); + setTimeout(() => { + expect(vm.service.getGroups).toHaveBeenCalled(); + expect(vm.updatePagination).toHaveBeenCalled(); + done(); + }, 0); + }); + + it('should show flash error when request fails', (done) => { + spyOn(vm.service, 'getGroups').and.returnValue(returnServicePromise(null, true)); + spyOn($, 'scrollTo'); + spyOn(window, 'Flash'); + + vm.fetchGroups({}); + setTimeout(() => { + expect(vm.isLoading).toBeFalsy(); + expect($.scrollTo).toHaveBeenCalledWith(0); + expect(window.Flash).toHaveBeenCalledWith('An error occurred. Please try again.'); + done(); + }, 0); + }); + }); + + describe('fetchAllGroups', () => { + it('should fetch default set of groups', (done) => { + spyOn(vm, 'fetchGroups').and.returnValue(returnServicePromise(mockGroups)); + spyOn(vm, 'updatePagination').and.callThrough(); + spyOn(vm, 'updateGroups').and.callThrough(); + + vm.fetchAllGroups(); + expect(vm.isLoading).toBeTruthy(); + expect(vm.fetchGroups).toHaveBeenCalled(); + setTimeout(() => { + expect(vm.isLoading).toBeFalsy(); + expect(vm.updateGroups).toHaveBeenCalled(); + done(); + }, 0); + }); + + it('should fetch matching set of groups when app is loaded with search query', (done) => { + spyOn(vm, 'fetchGroups').and.returnValue(returnServicePromise(mockSearchedGroups)); + spyOn(vm, 'updateGroups').and.callThrough(); + + vm.fetchAllGroups(); + expect(vm.fetchGroups).toHaveBeenCalledWith({ + page: null, + filterGroupsBy: null, + sortBy: null, + updatePagination: true, + }); + setTimeout(() => { + expect(vm.updateGroups).toHaveBeenCalled(); + done(); + }, 0); + }); + }); + + describe('fetchPage', () => { + it('should fetch groups for provided page details and update window state', (done) => { + spyOn(vm, 'fetchGroups').and.returnValue(returnServicePromise(mockGroups)); + spyOn(vm, 'updateGroups').and.callThrough(); + spyOn(gl.utils, 'mergeUrlParams').and.callThrough(); + spyOn(window.history, 'replaceState'); + spyOn($, 'scrollTo'); + + vm.fetchPage(2, null, null); + expect(vm.isLoading).toBeTruthy(); + expect(vm.fetchGroups).toHaveBeenCalledWith({ + page: 2, + filterGroupsBy: null, + sortBy: null, + updatePagination: true, + }); + setTimeout(() => { + expect(vm.isLoading).toBeFalsy(); + expect($.scrollTo).toHaveBeenCalledWith(0); + expect(gl.utils.mergeUrlParams).toHaveBeenCalledWith({ page: 2 }, jasmine.any(String)); + expect(window.history.replaceState).toHaveBeenCalledWith({ + page: jasmine.any(String), + }, jasmine.any(String), jasmine.any(String)); + expect(vm.updateGroups).toHaveBeenCalled(); + done(); + }, 0); + }); + }); + + describe('toggleChildren', () => { + let groupItem; + + beforeEach(() => { + groupItem = Object.assign({}, mockParentGroupItem); + groupItem.isOpen = false; + groupItem.isChildrenLoading = false; + }); + + it('should fetch children of given group and expand it if group is collapsed and children are not loaded', (done) => { + spyOn(vm, 'fetchGroups').and.returnValue(returnServicePromise(mockRawChildren)); + spyOn(vm.store, 'setGroupChildren'); + + vm.toggleChildren(groupItem); + expect(groupItem.isChildrenLoading).toBeTruthy(); + expect(vm.fetchGroups).toHaveBeenCalledWith({ + parentId: groupItem.id, + }); + setTimeout(() => { + expect(vm.store.setGroupChildren).toHaveBeenCalled(); + done(); + }, 0); + }); + + it('should skip network request while expanding group if children are already loaded', () => { + spyOn(vm, 'fetchGroups'); + groupItem.children = mockRawChildren; + + vm.toggleChildren(groupItem); + expect(vm.fetchGroups).not.toHaveBeenCalled(); + expect(groupItem.isOpen).toBeTruthy(); + }); + + it('should collapse group if it is already expanded', () => { + spyOn(vm, 'fetchGroups'); + groupItem.isOpen = true; + + vm.toggleChildren(groupItem); + expect(vm.fetchGroups).not.toHaveBeenCalled(); + expect(groupItem.isOpen).toBeFalsy(); + }); + + it('should set `isChildrenLoading` back to `false` if load request fails', (done) => { + spyOn(vm, 'fetchGroups').and.returnValue(returnServicePromise({}, true)); + + vm.toggleChildren(groupItem); + expect(groupItem.isChildrenLoading).toBeTruthy(); + setTimeout(() => { + expect(groupItem.isChildrenLoading).toBeFalsy(); + done(); + }, 0); + }); + }); + + describe('leaveGroup', () => { + let groupItem; + let childGroupItem; + + beforeEach(() => { + groupItem = Object.assign({}, mockParentGroupItem); + groupItem.children = mockChildren; + childGroupItem = groupItem.children[0]; + groupItem.isChildrenLoading = false; + }); + + it('should leave group and remove group item from tree', (done) => { + const notice = `You left the "${childGroupItem.fullName}" group.`; + spyOn(vm.service, 'leaveGroup').and.returnValue(returnServicePromise({ notice })); + spyOn(vm.store, 'removeGroup').and.callThrough(); + spyOn(window, 'Flash'); + spyOn($, 'scrollTo'); + + vm.leaveGroup(childGroupItem, groupItem); + expect(childGroupItem.isBeingRemoved).toBeTruthy(); + expect(vm.service.leaveGroup).toHaveBeenCalledWith(childGroupItem.leavePath); + setTimeout(() => { + expect($.scrollTo).toHaveBeenCalledWith(0); + expect(vm.store.removeGroup).toHaveBeenCalledWith(childGroupItem, groupItem); + expect(window.Flash).toHaveBeenCalledWith(notice, 'notice'); + done(); + }, 0); + }); + + it('should show error flash message if request failed to leave group', (done) => { + const message = 'An error occurred. Please try again.'; + spyOn(vm.service, 'leaveGroup').and.returnValue(returnServicePromise({ status: 500 }, true)); + spyOn(vm.store, 'removeGroup').and.callThrough(); + spyOn(window, 'Flash'); + + vm.leaveGroup(childGroupItem, groupItem); + expect(childGroupItem.isBeingRemoved).toBeTruthy(); + expect(vm.service.leaveGroup).toHaveBeenCalledWith(childGroupItem.leavePath); + setTimeout(() => { + expect(vm.store.removeGroup).not.toHaveBeenCalled(); + expect(window.Flash).toHaveBeenCalledWith(message); + expect(childGroupItem.isBeingRemoved).toBeFalsy(); + done(); + }, 0); + }); + + it('should show appropriate error flash message if request forbids to leave group', (done) => { + const message = 'Failed to leave the group. Please make sure you are not the only owner.'; + spyOn(vm.service, 'leaveGroup').and.returnValue(returnServicePromise({ status: 403 }, true)); + spyOn(vm.store, 'removeGroup').and.callThrough(); + spyOn(window, 'Flash'); + + vm.leaveGroup(childGroupItem, groupItem); + expect(childGroupItem.isBeingRemoved).toBeTruthy(); + expect(vm.service.leaveGroup).toHaveBeenCalledWith(childGroupItem.leavePath); + setTimeout(() => { + expect(vm.store.removeGroup).not.toHaveBeenCalled(); + expect(window.Flash).toHaveBeenCalledWith(message); + expect(childGroupItem.isBeingRemoved).toBeFalsy(); + done(); + }, 0); + }); + }); + + describe('updatePagination', () => { + it('should set pagination info to store from provided headers', () => { + spyOn(vm.store, 'setPaginationInfo'); + + vm.updatePagination(mockRawPageInfo); + expect(vm.store.setPaginationInfo).toHaveBeenCalledWith(mockRawPageInfo); + }); + }); + + describe('updateGroups', () => { + it('should call setGroups on store if method was called directly', () => { + spyOn(vm.store, 'setGroups'); + + vm.updateGroups(mockGroups); + expect(vm.store.setGroups).toHaveBeenCalledWith(mockGroups); + }); + + it('should call setSearchedGroups on store if method was called with fromSearch param', () => { + spyOn(vm.store, 'setSearchedGroups'); + + vm.updateGroups(mockGroups, true); + expect(vm.store.setSearchedGroups).toHaveBeenCalledWith(mockGroups); + }); + + it('should set `isSearchEmpty` prop based on groups count', () => { + vm.updateGroups(mockGroups); + expect(vm.isSearchEmpty).toBeFalsy(); + + vm.updateGroups([]); + expect(vm.isSearchEmpty).toBeTruthy(); + }); + }); + }); + + describe('created', () => { + it('should bind event listeners on eventHub', (done) => { + spyOn(eventHub, '$on'); + + const newVm = createComponent(); + newVm.$mount(); + + Vue.nextTick(() => { + expect(eventHub.$on).toHaveBeenCalledWith('fetchPage', jasmine.any(Function)); + expect(eventHub.$on).toHaveBeenCalledWith('toggleChildren', jasmine.any(Function)); + expect(eventHub.$on).toHaveBeenCalledWith('leaveGroup', jasmine.any(Function)); + expect(eventHub.$on).toHaveBeenCalledWith('updatePagination', jasmine.any(Function)); + expect(eventHub.$on).toHaveBeenCalledWith('updateGroups', jasmine.any(Function)); + newVm.$destroy(); + done(); + }); + }); + + it('should initialize `searchEmptyMessage` prop with correct string when `hideProjects` is `false`', (done) => { + const newVm = createComponent(); + newVm.$mount(); + Vue.nextTick(() => { + expect(newVm.searchEmptyMessage).toBe('Sorry, no groups or projects matched your search'); + newVm.$destroy(); + done(); + }); + }); + + it('should initialize `searchEmptyMessage` prop with correct string when `hideProjects` is `true`', (done) => { + const newVm = createComponent(true); + newVm.$mount(); + Vue.nextTick(() => { + expect(newVm.searchEmptyMessage).toBe('Sorry, no groups matched your search'); + newVm.$destroy(); + done(); + }); + }); + }); + + describe('beforeDestroy', () => { + it('should unbind event listeners on eventHub', (done) => { + spyOn(eventHub, '$off'); + + const newVm = createComponent(); + newVm.$mount(); + newVm.$destroy(); + + Vue.nextTick(() => { + expect(eventHub.$off).toHaveBeenCalledWith('fetchPage', jasmine.any(Function)); + expect(eventHub.$off).toHaveBeenCalledWith('toggleChildren', jasmine.any(Function)); + expect(eventHub.$off).toHaveBeenCalledWith('leaveGroup', jasmine.any(Function)); + expect(eventHub.$off).toHaveBeenCalledWith('updatePagination', jasmine.any(Function)); + expect(eventHub.$off).toHaveBeenCalledWith('updateGroups', jasmine.any(Function)); + done(); + }); + }); + }); + + describe('template', () => { + beforeEach(() => { + vm.$mount(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('should render loading icon', (done) => { + vm.isLoading = true; + Vue.nextTick(() => { + expect(vm.$el.querySelector('.loading-animation')).toBeDefined(); + expect(vm.$el.querySelector('i.fa').getAttribute('aria-label')).toBe('Loading groups'); + done(); + }); + }); + + it('should render groups tree', (done) => { + vm.groups = [mockParentGroupItem]; + vm.isLoading = false; + vm.pageInfo = mockPageInfo; + Vue.nextTick(() => { + expect(vm.$el.querySelector('.groups-list-tree-container')).toBeDefined(); + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/groups/components/group_folder_spec.js b/spec/javascripts/groups/components/group_folder_spec.js new file mode 100644 index 00000000000..4eb198595fb --- /dev/null +++ b/spec/javascripts/groups/components/group_folder_spec.js @@ -0,0 +1,66 @@ +import Vue from 'vue'; + +import groupFolderComponent from '~/groups/components/group_folder.vue'; +import groupItemComponent from '~/groups/components/group_item.vue'; +import { mockGroups, mockParentGroupItem } from '../mock_data'; + +const createComponent = (groups = mockGroups, parentGroup = mockParentGroupItem) => { + const Component = Vue.extend(groupFolderComponent); + + return new Component({ + propsData: { + groups, + parentGroup, + }, + }); +}; + +describe('GroupFolderComponent', () => { + let vm; + + beforeEach((done) => { + Vue.component('group-item', groupItemComponent); + + vm = createComponent(); + vm.$mount(); + + Vue.nextTick(() => { + done(); + }); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('computed', () => { + describe('hasMoreChildren', () => { + it('should return false when childrenCount of group is less than MAX_CHILDREN_COUNT', () => { + expect(vm.hasMoreChildren).toBeFalsy(); + }); + }); + + describe('moreChildrenStats', () => { + it('should return message with count of excess children over MAX_CHILDREN_COUNT limit', () => { + expect(vm.moreChildrenStats).toBe('3 more items'); + }); + }); + }); + + describe('template', () => { + it('should render component template correctly', () => { + expect(vm.$el.classList.contains('group-list-tree')).toBeTruthy(); + expect(vm.$el.querySelectorAll('li.group-row').length).toBe(7); + }); + + it('should render more children link when groups list has children over MAX_CHILDREN_COUNT limit', () => { + const parentGroup = Object.assign({}, mockParentGroupItem); + parentGroup.childrenCount = 21; + + const newVm = createComponent(mockGroups, parentGroup); + newVm.$mount(); + expect(newVm.$el.querySelector('li.group-row a.has-more-items')).toBeDefined(); + newVm.$destroy(); + }); + }); +}); diff --git a/spec/javascripts/groups/components/group_item_spec.js b/spec/javascripts/groups/components/group_item_spec.js new file mode 100644 index 00000000000..0f4fbdae445 --- /dev/null +++ b/spec/javascripts/groups/components/group_item_spec.js @@ -0,0 +1,177 @@ +import Vue from 'vue'; + +import groupItemComponent from '~/groups/components/group_item.vue'; +import groupFolderComponent from '~/groups/components/group_folder.vue'; +import eventHub from '~/groups/event_hub'; +import { mockParentGroupItem, mockChildren } from '../mock_data'; + +import mountComponent from '../../helpers/vue_mount_component_helper'; + +const createComponent = (group = mockParentGroupItem, parentGroup = mockChildren[0]) => { + const Component = Vue.extend(groupItemComponent); + + return mountComponent(Component, { + group, + parentGroup, + }); +}; + +describe('GroupItemComponent', () => { + let vm; + + beforeEach((done) => { + Vue.component('group-folder', groupFolderComponent); + + vm = createComponent(); + + Vue.nextTick(() => { + done(); + }); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('computed', () => { + describe('groupDomId', () => { + it('should return ID string suffixed with group ID', () => { + expect(vm.groupDomId).toBe('group-55'); + }); + }); + + describe('rowClass', () => { + it('should return map of classes based on group details', () => { + const classes = ['is-open', 'has-children', 'has-description', 'being-removed']; + const rowClass = vm.rowClass; + + expect(Object.keys(rowClass).length).toBe(classes.length); + Object.keys(rowClass).forEach((className) => { + expect(classes.indexOf(className) > -1).toBeTruthy(); + }); + }); + }); + + describe('hasChildren', () => { + it('should return boolean value representing if group has any children present', () => { + let newVm; + const group = Object.assign({}, mockParentGroupItem); + + group.childrenCount = 5; + newVm = createComponent(group); + expect(newVm.hasChildren).toBeTruthy(); + newVm.$destroy(); + + group.childrenCount = 0; + newVm = createComponent(group); + expect(newVm.hasChildren).toBeFalsy(); + newVm.$destroy(); + }); + }); + + describe('hasAvatar', () => { + it('should return boolean value representing if group has any avatar present', () => { + let newVm; + const group = Object.assign({}, mockParentGroupItem); + + group.avatarUrl = null; + newVm = createComponent(group); + expect(newVm.hasAvatar).toBeFalsy(); + newVm.$destroy(); + + group.avatarUrl = '/uploads/group_avatar.png'; + newVm = createComponent(group); + expect(newVm.hasAvatar).toBeTruthy(); + newVm.$destroy(); + }); + }); + + describe('isGroup', () => { + it('should return boolean value representing if group item is of type `group` or not', () => { + let newVm; + const group = Object.assign({}, mockParentGroupItem); + + group.type = 'group'; + newVm = createComponent(group); + expect(newVm.isGroup).toBeTruthy(); + newVm.$destroy(); + + group.type = 'project'; + newVm = createComponent(group); + expect(newVm.isGroup).toBeFalsy(); + newVm.$destroy(); + }); + }); + }); + + describe('methods', () => { + describe('onClickRowGroup', () => { + let event; + + beforeEach(() => { + const classList = { + contains() { + return false; + }, + }; + + event = { + target: { + classList, + parentElement: { + classList, + }, + }, + }; + }); + + it('should emit `toggleChildren` event when expand is clicked on a group and it has children present', () => { + spyOn(eventHub, '$emit'); + + vm.onClickRowGroup(event); + expect(eventHub.$emit).toHaveBeenCalledWith('toggleChildren', vm.group); + }); + + it('should navigate page to group homepage if group does not have any children present', (done) => { + const group = Object.assign({}, mockParentGroupItem); + group.childrenCount = 0; + const newVm = createComponent(group); + spyOn(gl.utils, 'visitUrl').and.stub(); + spyOn(eventHub, '$emit'); + + newVm.onClickRowGroup(event); + setTimeout(() => { + expect(eventHub.$emit).not.toHaveBeenCalled(); + expect(gl.utils.visitUrl).toHaveBeenCalledWith(newVm.group.relativePath); + done(); + }, 0); + }); + }); + }); + + describe('template', () => { + it('should render component template correctly', () => { + expect(vm.$el.getAttribute('id')).toBe('group-55'); + expect(vm.$el.classList.contains('group-row')).toBeTruthy(); + + expect(vm.$el.querySelector('.group-row-contents')).toBeDefined(); + expect(vm.$el.querySelector('.group-row-contents .controls')).toBeDefined(); + expect(vm.$el.querySelector('.group-row-contents .stats')).toBeDefined(); + + expect(vm.$el.querySelector('.folder-toggle-wrap')).toBeDefined(); + expect(vm.$el.querySelector('.folder-toggle-wrap .folder-caret')).toBeDefined(); + expect(vm.$el.querySelector('.folder-toggle-wrap .item-type-icon')).toBeDefined(); + + expect(vm.$el.querySelector('.avatar-container')).toBeDefined(); + expect(vm.$el.querySelector('.avatar-container a.no-expand')).toBeDefined(); + expect(vm.$el.querySelector('.avatar-container .avatar')).toBeDefined(); + + expect(vm.$el.querySelector('.title')).toBeDefined(); + expect(vm.$el.querySelector('.title a.no-expand')).toBeDefined(); + expect(vm.$el.querySelector('.access-type')).toBeDefined(); + expect(vm.$el.querySelector('.description')).toBeDefined(); + + expect(vm.$el.querySelector('.group-list-tree')).toBeDefined(); + }); + }); +}); diff --git a/spec/javascripts/groups/components/groups_spec.js b/spec/javascripts/groups/components/groups_spec.js new file mode 100644 index 00000000000..280376d4903 --- /dev/null +++ b/spec/javascripts/groups/components/groups_spec.js @@ -0,0 +1,70 @@ +import Vue from 'vue'; + +import groupsComponent from '~/groups/components/groups.vue'; +import groupFolderComponent from '~/groups/components/group_folder.vue'; +import groupItemComponent from '~/groups/components/group_item.vue'; +import eventHub from '~/groups/event_hub'; +import { mockGroups, mockPageInfo } from '../mock_data'; + +import mountComponent from '../../helpers/vue_mount_component_helper'; + +const createComponent = (searchEmpty = false) => { + const Component = Vue.extend(groupsComponent); + + return mountComponent(Component, { + groups: mockGroups, + pageInfo: mockPageInfo, + searchEmptyMessage: 'No matching results', + searchEmpty, + }); +}; + +describe('GroupsComponent', () => { + let vm; + + beforeEach((done) => { + Vue.component('group-folder', groupFolderComponent); + Vue.component('group-item', groupItemComponent); + + vm = createComponent(); + + Vue.nextTick(() => { + done(); + }); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('methods', () => { + describe('change', () => { + it('should emit `fetchPage` event when page is changed via pagination', () => { + spyOn(eventHub, '$emit').and.stub(); + + vm.change(2); + expect(eventHub.$emit).toHaveBeenCalledWith('fetchPage', 2, jasmine.any(Object), jasmine.any(Object)); + }); + }); + }); + + describe('template', () => { + it('should render component template correctly', (done) => { + Vue.nextTick(() => { + expect(vm.$el.querySelector('.groups-list-tree-container')).toBeDefined(); + expect(vm.$el.querySelector('.group-list-tree')).toBeDefined(); + expect(vm.$el.querySelector('.gl-pagination')).toBeDefined(); + expect(vm.$el.querySelectorAll('.has-no-search-results').length === 0).toBeTruthy(); + done(); + }); + }); + + it('should render empty search message when `searchEmpty` is `true`', (done) => { + vm.searchEmpty = true; + Vue.nextTick(() => { + expect(vm.$el.querySelector('.has-no-search-results')).toBeDefined(); + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/groups/components/item_actions_spec.js b/spec/javascripts/groups/components/item_actions_spec.js new file mode 100644 index 00000000000..2ce1a749a96 --- /dev/null +++ b/spec/javascripts/groups/components/item_actions_spec.js @@ -0,0 +1,110 @@ +import Vue from 'vue'; + +import itemActionsComponent from '~/groups/components/item_actions.vue'; +import eventHub from '~/groups/event_hub'; +import { mockParentGroupItem, mockChildren } from '../mock_data'; + +import mountComponent from '../../helpers/vue_mount_component_helper'; + +const createComponent = (group = mockParentGroupItem, parentGroup = mockChildren[0]) => { + const Component = Vue.extend(itemActionsComponent); + + return mountComponent(Component, { + group, + parentGroup, + }); +}; + +describe('ItemActionsComponent', () => { + let vm; + + beforeEach(() => { + vm = createComponent(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('computed', () => { + describe('leaveConfirmationMessage', () => { + it('should return appropriate string for leave group confirmation', () => { + expect(vm.leaveConfirmationMessage).toBe('Are you sure you want to leave the "platform / hardware" group?'); + }); + }); + }); + + describe('methods', () => { + describe('onLeaveGroup', () => { + it('should change `dialogStatus` prop to `true` which shows confirmation dialog', () => { + expect(vm.dialogStatus).toBeFalsy(); + vm.onLeaveGroup(); + expect(vm.dialogStatus).toBeTruthy(); + }); + }); + + describe('leaveGroup', () => { + it('should change `dialogStatus` prop to `false` and emit `leaveGroup` event with required params when called with `leaveConfirmed` as `true`', () => { + spyOn(eventHub, '$emit'); + vm.dialogStatus = true; + vm.leaveGroup(true); + expect(vm.dialogStatus).toBeFalsy(); + expect(eventHub.$emit).toHaveBeenCalledWith('leaveGroup', vm.group, vm.parentGroup); + }); + + it('should change `dialogStatus` prop to `false` and should NOT emit `leaveGroup` event when called with `leaveConfirmed` as `false`', () => { + spyOn(eventHub, '$emit'); + vm.dialogStatus = true; + vm.leaveGroup(false); + expect(vm.dialogStatus).toBeFalsy(); + expect(eventHub.$emit).not.toHaveBeenCalled(); + }); + }); + }); + + describe('template', () => { + it('should render component template correctly', () => { + expect(vm.$el.classList.contains('controls')).toBeTruthy(); + }); + + it('should render Edit Group button with correct attribute values', () => { + const group = Object.assign({}, mockParentGroupItem); + group.canEdit = true; + const newVm = createComponent(group); + + const editBtn = newVm.$el.querySelector('a.edit-group'); + expect(editBtn).toBeDefined(); + expect(editBtn.classList.contains('no-expand')).toBeTruthy(); + expect(editBtn.getAttribute('href')).toBe(group.editPath); + expect(editBtn.getAttribute('aria-label')).toBe('Edit group'); + expect(editBtn.dataset.originalTitle).toBe('Edit group'); + expect(editBtn.querySelector('i.fa.fa-cogs')).toBeDefined(); + + newVm.$destroy(); + }); + + it('should render Leave Group button with correct attribute values', () => { + const group = Object.assign({}, mockParentGroupItem); + group.canLeave = true; + const newVm = createComponent(group); + + const leaveBtn = newVm.$el.querySelector('a.leave-group'); + expect(leaveBtn).toBeDefined(); + expect(leaveBtn.classList.contains('no-expand')).toBeTruthy(); + expect(leaveBtn.getAttribute('href')).toBe(group.leavePath); + expect(leaveBtn.getAttribute('aria-label')).toBe('Leave this group'); + expect(leaveBtn.dataset.originalTitle).toBe('Leave this group'); + expect(leaveBtn.querySelector('i.fa.fa-sign-out')).toBeDefined(); + + newVm.$destroy(); + }); + + it('should show modal dialog when `dialogStatus` is set to `true`', () => { + vm.dialogStatus = true; + const modalDialogEl = vm.$el.querySelector('.modal.popup-dialog'); + expect(modalDialogEl).toBeDefined(); + expect(modalDialogEl.querySelector('.modal-title').innerText.trim()).toBe('Are you sure?'); + expect(modalDialogEl.querySelector('.btn.btn-warning').innerText.trim()).toBe('Leave'); + }); + }); +}); diff --git a/spec/javascripts/groups/components/item_caret_spec.js b/spec/javascripts/groups/components/item_caret_spec.js new file mode 100644 index 00000000000..4310a07e6e6 --- /dev/null +++ b/spec/javascripts/groups/components/item_caret_spec.js @@ -0,0 +1,40 @@ +import Vue from 'vue'; + +import itemCaretComponent from '~/groups/components/item_caret.vue'; + +import mountComponent from '../../helpers/vue_mount_component_helper'; + +const createComponent = (isGroupOpen = false) => { + const Component = Vue.extend(itemCaretComponent); + + return mountComponent(Component, { + isGroupOpen, + }); +}; + +describe('ItemCaretComponent', () => { + describe('template', () => { + it('should render component template correctly', () => { + const vm = createComponent(); + vm.$mount(); + expect(vm.$el.classList.contains('folder-caret')).toBeTruthy(); + vm.$destroy(); + }); + + it('should render caret down icon if `isGroupOpen` prop is `true`', () => { + const vm = createComponent(true); + vm.$mount(); + expect(vm.$el.querySelectorAll('i.fa.fa-caret-down').length).toBe(1); + expect(vm.$el.querySelectorAll('i.fa.fa-caret-right').length).toBe(0); + vm.$destroy(); + }); + + it('should render caret right icon if `isGroupOpen` prop is `false`', () => { + const vm = createComponent(); + vm.$mount(); + expect(vm.$el.querySelectorAll('i.fa.fa-caret-down').length).toBe(0); + expect(vm.$el.querySelectorAll('i.fa.fa-caret-right').length).toBe(1); + vm.$destroy(); + }); + }); +}); diff --git a/spec/javascripts/groups/components/item_stats_spec.js b/spec/javascripts/groups/components/item_stats_spec.js new file mode 100644 index 00000000000..e200f9f08bd --- /dev/null +++ b/spec/javascripts/groups/components/item_stats_spec.js @@ -0,0 +1,159 @@ +import Vue from 'vue'; + +import itemStatsComponent from '~/groups/components/item_stats.vue'; +import { + mockParentGroupItem, + ITEM_TYPE, + VISIBILITY_TYPE_ICON, + GROUP_VISIBILITY_TYPE, + PROJECT_VISIBILITY_TYPE, +} from '../mock_data'; + +import mountComponent from '../../helpers/vue_mount_component_helper'; + +const createComponent = (item = mockParentGroupItem) => { + const Component = Vue.extend(itemStatsComponent); + + return mountComponent(Component, { + item, + }); +}; + +describe('ItemStatsComponent', () => { + describe('computed', () => { + describe('visibilityIcon', () => { + it('should return icon class based on `item.visibility` value', () => { + Object.keys(VISIBILITY_TYPE_ICON).forEach((visibility) => { + const item = Object.assign({}, mockParentGroupItem, { visibility }); + const vm = createComponent(item); + vm.$mount(); + expect(vm.visibilityIcon).toBe(VISIBILITY_TYPE_ICON[visibility]); + vm.$destroy(); + }); + }); + }); + + describe('visibilityTooltip', () => { + it('should return tooltip string for Group based on `item.visibility` value', () => { + Object.keys(GROUP_VISIBILITY_TYPE).forEach((visibility) => { + const item = Object.assign({}, mockParentGroupItem, { + visibility, + type: ITEM_TYPE.GROUP, + }); + const vm = createComponent(item); + vm.$mount(); + expect(vm.visibilityTooltip).toBe(GROUP_VISIBILITY_TYPE[visibility]); + vm.$destroy(); + }); + }); + + it('should return tooltip string for Project based on `item.visibility` value', () => { + Object.keys(PROJECT_VISIBILITY_TYPE).forEach((visibility) => { + const item = Object.assign({}, mockParentGroupItem, { + visibility, + type: ITEM_TYPE.PROJECT, + }); + const vm = createComponent(item); + vm.$mount(); + expect(vm.visibilityTooltip).toBe(PROJECT_VISIBILITY_TYPE[visibility]); + vm.$destroy(); + }); + }); + }); + + describe('isProject', () => { + it('should return boolean value representing whether `item.type` is Project or not', () => { + let item; + let vm; + + item = Object.assign({}, mockParentGroupItem, { type: ITEM_TYPE.PROJECT }); + vm = createComponent(item); + vm.$mount(); + expect(vm.isProject).toBeTruthy(); + vm.$destroy(); + + item = Object.assign({}, mockParentGroupItem, { type: ITEM_TYPE.GROUP }); + vm = createComponent(item); + vm.$mount(); + expect(vm.isProject).toBeFalsy(); + vm.$destroy(); + }); + }); + + describe('isGroup', () => { + it('should return boolean value representing whether `item.type` is Group or not', () => { + let item; + let vm; + + item = Object.assign({}, mockParentGroupItem, { type: ITEM_TYPE.GROUP }); + vm = createComponent(item); + vm.$mount(); + expect(vm.isGroup).toBeTruthy(); + vm.$destroy(); + + item = Object.assign({}, mockParentGroupItem, { type: ITEM_TYPE.PROJECT }); + vm = createComponent(item); + vm.$mount(); + expect(vm.isGroup).toBeFalsy(); + vm.$destroy(); + }); + }); + }); + + describe('template', () => { + it('should render component template correctly', () => { + const vm = createComponent(); + vm.$mount(); + + const visibilityIconEl = vm.$el.querySelector('.item-visibility'); + expect(vm.$el.classList.contains('.stats')).toBeDefined(); + expect(visibilityIconEl).toBeDefined(); + expect(visibilityIconEl.dataset.originalTitle).toBe(vm.visibilityTooltip); + expect(visibilityIconEl.querySelector('i.fa')).toBeDefined(); + + vm.$destroy(); + }); + + it('should render stat icons if `item.type` is Group', () => { + const item = Object.assign({}, mockParentGroupItem, { type: ITEM_TYPE.GROUP }); + const vm = createComponent(item); + vm.$mount(); + + const subgroupIconEl = vm.$el.querySelector('span.number-subgroups'); + expect(subgroupIconEl).toBeDefined(); + expect(subgroupIconEl.dataset.originalTitle).toBe('Subgroups'); + expect(subgroupIconEl.querySelector('i.fa.fa-folder')).toBeDefined(); + expect(subgroupIconEl.innerText.trim()).toBe(`${vm.item.subgroupCount}`); + + const projectsIconEl = vm.$el.querySelector('span.number-projects'); + expect(projectsIconEl).toBeDefined(); + expect(projectsIconEl.dataset.originalTitle).toBe('Projects'); + expect(projectsIconEl.querySelector('i.fa.fa-bookmark')).toBeDefined(); + expect(projectsIconEl.innerText.trim()).toBe(`${vm.item.projectCount}`); + + const membersIconEl = vm.$el.querySelector('span.number-users'); + expect(membersIconEl).toBeDefined(); + expect(membersIconEl.dataset.originalTitle).toBe('Members'); + expect(membersIconEl.querySelector('i.fa.fa-users')).toBeDefined(); + expect(membersIconEl.innerText.trim()).toBe(`${vm.item.memberCount}`); + + vm.$destroy(); + }); + + it('should render stat icons if `item.type` is Project', () => { + const item = Object.assign({}, mockParentGroupItem, { + type: ITEM_TYPE.PROJECT, + starCount: 4, + }); + const vm = createComponent(item); + vm.$mount(); + + const projectStarIconEl = vm.$el.querySelector('.project-stars'); + expect(projectStarIconEl).toBeDefined(); + expect(projectStarIconEl.querySelector('i.fa.fa-star')).toBeDefined(); + expect(projectStarIconEl.innerText.trim()).toBe(`${vm.item.starCount}`); + + vm.$destroy(); + }); + }); +}); diff --git a/spec/javascripts/groups/components/item_type_icon_spec.js b/spec/javascripts/groups/components/item_type_icon_spec.js new file mode 100644 index 00000000000..528e6ed1b4c --- /dev/null +++ b/spec/javascripts/groups/components/item_type_icon_spec.js @@ -0,0 +1,54 @@ +import Vue from 'vue'; + +import itemTypeIconComponent from '~/groups/components/item_type_icon.vue'; +import { ITEM_TYPE } from '../mock_data'; + +import mountComponent from '../../helpers/vue_mount_component_helper'; + +const createComponent = (itemType = ITEM_TYPE.GROUP, isGroupOpen = false) => { + const Component = Vue.extend(itemTypeIconComponent); + + return mountComponent(Component, { + itemType, + isGroupOpen, + }); +}; + +describe('ItemTypeIconComponent', () => { + describe('template', () => { + it('should render component template correctly', () => { + const vm = createComponent(); + vm.$mount(); + expect(vm.$el.classList.contains('item-type-icon')).toBeTruthy(); + vm.$destroy(); + }); + + it('should render folder open or close icon based `isGroupOpen` prop value', () => { + let vm; + + vm = createComponent(ITEM_TYPE.GROUP, true); + vm.$mount(); + expect(vm.$el.querySelector('i.fa.fa-folder-open')).toBeDefined(); + vm.$destroy(); + + vm = createComponent(ITEM_TYPE.GROUP); + vm.$mount(); + expect(vm.$el.querySelector('i.fa.fa-folder')).toBeDefined(); + vm.$destroy(); + }); + + it('should render bookmark icon based on `isProject` prop value', () => { + let vm; + + vm = createComponent(ITEM_TYPE.PROJECT); + vm.$mount(); + expect(vm.$el.querySelectorAll('i.fa.fa-bookmark').length).toBe(1); + vm.$destroy(); + + vm = createComponent(ITEM_TYPE.GROUP); + vm.$mount(); + expect(vm.$el.querySelectorAll('i.fa.fa-bookmark').length).toBe(0); + vm.$destroy(); + }); + }); +}); diff --git a/spec/javascripts/groups/group_item_spec.js b/spec/javascripts/groups/group_item_spec.js deleted file mode 100644 index 25e10552d95..00000000000 --- a/spec/javascripts/groups/group_item_spec.js +++ /dev/null @@ -1,102 +0,0 @@ -import Vue from 'vue'; -import groupItemComponent from '~/groups/components/group_item.vue'; -import GroupsStore from '~/groups/stores/groups_store'; -import { group1 } from './mock_data'; - -describe('Groups Component', () => { - let GroupItemComponent; - let component; - let store; - let group; - - describe('group with default data', () => { - beforeEach((done) => { - GroupItemComponent = Vue.extend(groupItemComponent); - store = new GroupsStore(); - group = store.decorateGroup(group1); - - component = new GroupItemComponent({ - propsData: { - group, - }, - }).$mount(); - - Vue.nextTick(() => { - done(); - }); - }); - - afterEach(() => { - component.$destroy(); - }); - - it('should render the group item correctly', () => { - expect(component.$el.classList.contains('group-row')).toBe(true); - expect(component.$el.classList.contains('.no-description')).toBe(false); - expect(component.$el.querySelector('.number-projects').textContent).toContain(group.numberProjects); - expect(component.$el.querySelector('.number-users').textContent).toContain(group.numberUsers); - expect(component.$el.querySelector('.group-visibility')).toBeDefined(); - expect(component.$el.querySelector('.avatar-container')).toBeDefined(); - expect(component.$el.querySelector('.title').textContent).toContain(group.name); - expect(component.$el.querySelector('.access-type').textContent).toContain(group.permissions.humanGroupAccess); - expect(component.$el.querySelector('.description').textContent).toContain(group.description); - expect(component.$el.querySelector('.edit-group')).toBeDefined(); - expect(component.$el.querySelector('.leave-group')).toBeDefined(); - }); - }); - - describe('group without description', () => { - beforeEach((done) => { - GroupItemComponent = Vue.extend(groupItemComponent); - store = new GroupsStore(); - group1.description = ''; - group = store.decorateGroup(group1); - - component = new GroupItemComponent({ - propsData: { - group, - }, - }).$mount(); - - Vue.nextTick(() => { - done(); - }); - }); - - afterEach(() => { - component.$destroy(); - }); - - it('should render group item correctly', () => { - expect(component.$el.querySelector('.description').textContent).toBe(''); - expect(component.$el.classList.contains('.no-description')).toBe(false); - }); - }); - - describe('user has not access to group', () => { - beforeEach((done) => { - GroupItemComponent = Vue.extend(groupItemComponent); - store = new GroupsStore(); - group1.permissions.human_group_access = null; - group = store.decorateGroup(group1); - - component = new GroupItemComponent({ - propsData: { - group, - }, - }).$mount(); - - Vue.nextTick(() => { - done(); - }); - }); - - afterEach(() => { - component.$destroy(); - }); - - it('should not display access type', () => { - expect(component.$el.querySelector('.access-type')).toBeNull(); - }); - }); -}); diff --git a/spec/javascripts/groups/groups_spec.js b/spec/javascripts/groups/groups_spec.js deleted file mode 100644 index b14153dbbfa..00000000000 --- a/spec/javascripts/groups/groups_spec.js +++ /dev/null @@ -1,99 +0,0 @@ -import Vue from 'vue'; -import eventHub from '~/groups/event_hub'; -import groupFolderComponent from '~/groups/components/group_folder.vue'; -import groupItemComponent from '~/groups/components/group_item.vue'; -import groupsComponent from '~/groups/components/groups.vue'; -import GroupsStore from '~/groups/stores/groups_store'; -import { groupsData } from './mock_data'; - -describe('Groups Component', () => { - let GroupsComponent; - let store; - let component; - let groups; - - beforeEach((done) => { - Vue.component('group-folder', groupFolderComponent); - Vue.component('group-item', groupItemComponent); - - store = new GroupsStore(); - groups = store.setGroups(groupsData.groups); - - store.storePagination(groupsData.pagination); - - GroupsComponent = Vue.extend(groupsComponent); - - component = new GroupsComponent({ - propsData: { - groups: store.state.groups, - pageInfo: store.state.pageInfo, - }, - }).$mount(); - - Vue.nextTick(() => { - done(); - }); - }); - - afterEach(() => { - component.$destroy(); - }); - - describe('with data', () => { - it('should render a list of groups', () => { - expect(component.$el.classList.contains('groups-list-tree-container')).toBe(true); - expect(component.$el.querySelector('#group-12')).toBeDefined(); - expect(component.$el.querySelector('#group-1119')).toBeDefined(); - expect(component.$el.querySelector('#group-1120')).toBeDefined(); - }); - - it('should respect the order of groups', () => { - const wrap = component.$el.querySelector('.groups-list-tree-container > .group-list-tree'); - expect(wrap.querySelector('.group-row:nth-child(1)').id).toBe('group-12'); - expect(wrap.querySelector('.group-row:nth-child(2)').id).toBe('group-1119'); - }); - - it('should render group and its subgroup', () => { - const lists = component.$el.querySelectorAll('.group-list-tree'); - - expect(lists.length).toBe(3); // one parent and two subgroups - - expect(lists[0].querySelector('#group-1119').classList.contains('is-open')).toBe(true); - expect(lists[0].querySelector('#group-1119').classList.contains('has-subgroups')).toBe(true); - - expect(lists[2].querySelector('#group-1120').textContent).toContain(groups.id1119.subGroups.id1120.name); - }); - - it('should render group identicon when group avatar is not present', () => { - const avatar = component.$el.querySelector('#group-12 .avatar-container .avatar'); - expect(avatar.nodeName).toBe('DIV'); - expect(avatar.classList.contains('identicon')).toBeTruthy(); - expect(avatar.getAttribute('style').indexOf('background-color') > -1).toBeTruthy(); - }); - - it('should render group avatar when group avatar is present', () => { - const avatar = component.$el.querySelector('#group-1120 .avatar-container .avatar'); - expect(avatar.nodeName).toBe('IMG'); - expect(avatar.classList.contains('identicon')).toBeFalsy(); - }); - - it('should remove prefix of parent group', () => { - expect(component.$el.querySelector('#group-12 #group-1128 .title').textContent).toContain('level2 / level3 / level4'); - }); - - it('should remove the group after leaving the group', (done) => { - spyOn(window, 'confirm').and.returnValue(true); - - eventHub.$on('leaveGroup', (group, collection) => { - store.removeGroup(group, collection); - }); - - component.$el.querySelector('#group-12 .leave-group').click(); - - Vue.nextTick(() => { - expect(component.$el.querySelector('#group-12')).toBeNull(); - done(); - }); - }); - }); -}); diff --git a/spec/javascripts/groups/mock_data.js b/spec/javascripts/groups/mock_data.js index 5bb84b591f4..6184d671790 100644 --- a/spec/javascripts/groups/mock_data.js +++ b/spec/javascripts/groups/mock_data.js @@ -1,114 +1,380 @@ -const group1 = { - id: 12, - name: 'level1', - path: 'level1', - description: 'foo', - visibility: 'public', - avatar_url: null, - web_url: 'http://localhost:3000/groups/level1', - group_path: '/level1', - full_name: 'level1', - full_path: 'level1', - parent_id: null, - created_at: '2017-05-15T19:01:23.670Z', - updated_at: '2017-05-15T19:01:23.670Z', - number_projects_with_delimiter: '1', - number_users_with_delimiter: '1', - has_subgroups: true, - permissions: { - human_group_access: 'Master', - }, +export const mockEndpoint = '/dashboard/groups.json'; + +export const ITEM_TYPE = { + PROJECT: 'project', + GROUP: 'group', }; -// This group has no direct parent, should be placed as subgroup of group1 -const group14 = { - id: 1128, - name: 'level4', - path: 'level4', - description: 'foo', - visibility: 'public', - avatar_url: null, - web_url: 'http://localhost:3000/groups/level1/level2/level3/level4', - group_path: '/level1/level2/level3/level4', - full_name: 'level1 / level2 / level3 / level4', - full_path: 'level1/level2/level3/level4', - parent_id: 1127, - created_at: '2017-05-15T19:02:01.645Z', - updated_at: '2017-05-15T19:02:01.645Z', - number_projects_with_delimiter: '1', - number_users_with_delimiter: '1', - has_subgroups: true, - permissions: { - human_group_access: 'Master', - }, +export const GROUP_VISIBILITY_TYPE = { + public: 'Public - The group and any public projects can be viewed without any authentication.', + internal: 'Internal - The group and any internal projects can be viewed by any logged in user.', + private: 'Private - The group and its projects can only be viewed by members.', }; -const group2 = { - id: 1119, - name: 'devops', - path: 'devops', - description: 'foo', - visibility: 'public', - avatar_url: null, - web_url: 'http://localhost:3000/groups/devops', - group_path: '/devops', - full_name: 'devops', - full_path: 'devops', - parent_id: null, - created_at: '2017-05-11T19:35:09.635Z', - updated_at: '2017-05-11T19:35:09.635Z', - number_projects_with_delimiter: '1', - number_users_with_delimiter: '1', - has_subgroups: true, - permissions: { - human_group_access: 'Master', - }, +export const PROJECT_VISIBILITY_TYPE = { + public: 'Public - The project can be accessed without any authentication.', + internal: 'Internal - The project can be accessed by any logged in user.', + private: 'Private - Project access must be granted explicitly to each user.', +}; + +export const VISIBILITY_TYPE_ICON = { + public: 'fa-globe', + internal: 'fa-shield', + private: 'fa-lock', }; -const group21 = { - id: 1120, - name: 'chef', - path: 'chef', - description: 'foo', +export const mockParentGroupItem = { + id: 55, + name: 'hardware', + description: '', visibility: 'public', - avatar_url: '/uploads/-/system/group/avatar/2/GitLab.png', - web_url: 'http://localhost:3000/groups/devops/chef', - group_path: '/devops/chef', - full_name: 'devops / chef', - full_path: 'devops/chef', - parent_id: 1119, - created_at: '2017-05-11T19:51:04.060Z', - updated_at: '2017-05-11T19:51:04.060Z', - number_projects_with_delimiter: '1', - number_users_with_delimiter: '1', - has_subgroups: true, - permissions: { - human_group_access: 'Master', - }, + fullName: 'platform / hardware', + relativePath: '/platform/hardware', + canEdit: true, + type: 'group', + avatarUrl: null, + permission: 'Owner', + editPath: '/groups/platform/hardware/edit', + childrenCount: 3, + leavePath: '/groups/platform/hardware/group_members/leave', + parentId: 54, + memberCount: '1', + projectCount: 1, + subgroupCount: 2, + canLeave: false, + children: [], + isOpen: true, + isChildrenLoading: false, + isBeingRemoved: false, }; -const groupsData = { - groups: [group1, group14, group2, group21], - pagination: { - Date: 'Mon, 22 May 2017 22:31:52 GMT', - 'X-Prev-Page': '1', - 'X-Content-Type-Options': 'nosniff', - 'X-Total': '31', - 'Transfer-Encoding': 'chunked', - 'X-Runtime': '0.611144', - 'X-Xss-Protection': '1; mode=block', - 'X-Request-Id': 'f5db8368-3ce5-4aa4-89d2-a125d9dead09', - 'X-Ua-Compatible': 'IE=edge', - 'X-Per-Page': '20', - Link: '<http://localhost:3000/dashboard/groups.json?page=1&per_page=20>; rel="prev", <http://localhost:3000/dashboard/groups.json?page=1&per_page=20>; rel="first", <http://localhost:3000/dashboard/groups.json?page=2&per_page=20>; rel="last"', - 'X-Next-Page': '', - Etag: 'W/"a82f846947136271cdb7d55d19ef33d2"', - 'X-Frame-Options': 'DENY', - 'Content-Type': 'application/json; charset=utf-8', - 'Cache-Control': 'max-age=0, private, must-revalidate', - 'X-Total-Pages': '2', - 'X-Page': '2', +export const mockRawChildren = [ + { + id: 57, + name: 'bsp', + description: '', + visibility: 'public', + full_name: 'platform / hardware / bsp', + relative_path: '/platform/hardware/bsp', + can_edit: true, + type: 'group', + avatar_url: null, + permission: 'Owner', + edit_path: '/groups/platform/hardware/bsp/edit', + children_count: 6, + leave_path: '/groups/platform/hardware/bsp/group_members/leave', + parent_id: 55, + number_users_with_delimiter: '1', + project_count: 4, + subgroup_count: 2, + can_leave: false, + children: [], + }, +]; + +export const mockChildren = [ + { + id: 57, + name: 'bsp', + description: '', + visibility: 'public', + fullName: 'platform / hardware / bsp', + relativePath: '/platform/hardware/bsp', + canEdit: true, + type: 'group', + avatarUrl: null, + permission: 'Owner', + editPath: '/groups/platform/hardware/bsp/edit', + childrenCount: 6, + leavePath: '/groups/platform/hardware/bsp/group_members/leave', + parentId: 55, + memberCount: '1', + projectCount: 4, + subgroupCount: 2, + canLeave: false, + children: [], + isOpen: true, + isChildrenLoading: false, + isBeingRemoved: false, }, +]; + +export const mockGroups = [ + { + id: 75, + name: 'test-group', + description: '', + visibility: 'public', + full_name: 'test-group', + relative_path: '/test-group', + can_edit: true, + type: 'group', + avatar_url: null, + permission: 'Owner', + edit_path: '/groups/test-group/edit', + children_count: 2, + leave_path: '/groups/test-group/group_members/leave', + parent_id: null, + number_users_with_delimiter: '1', + project_count: 2, + subgroup_count: 0, + can_leave: false, + }, + { + id: 67, + name: 'open-source', + description: '', + visibility: 'private', + full_name: 'open-source', + relative_path: '/open-source', + can_edit: true, + type: 'group', + avatar_url: null, + permission: 'Owner', + edit_path: '/groups/open-source/edit', + children_count: 0, + leave_path: '/groups/open-source/group_members/leave', + parent_id: null, + number_users_with_delimiter: '1', + project_count: 0, + subgroup_count: 0, + can_leave: false, + }, + { + id: 54, + name: 'platform', + description: '', + visibility: 'public', + full_name: 'platform', + relative_path: '/platform', + can_edit: true, + type: 'group', + avatar_url: null, + permission: 'Owner', + edit_path: '/groups/platform/edit', + children_count: 1, + leave_path: '/groups/platform/group_members/leave', + parent_id: null, + number_users_with_delimiter: '1', + project_count: 0, + subgroup_count: 1, + can_leave: false, + }, + { + id: 5, + name: 'H5bp', + description: 'Minus dolor consequuntur qui nam recusandae quam incidunt.', + visibility: 'public', + full_name: 'H5bp', + relative_path: '/h5bp', + can_edit: true, + type: 'group', + avatar_url: null, + permission: 'Owner', + edit_path: '/groups/h5bp/edit', + children_count: 1, + leave_path: '/groups/h5bp/group_members/leave', + parent_id: null, + number_users_with_delimiter: '5', + project_count: 1, + subgroup_count: 0, + can_leave: false, + }, + { + id: 4, + name: 'Twitter', + description: 'Deserunt hic nostrum placeat veniam.', + visibility: 'public', + full_name: 'Twitter', + relative_path: '/twitter', + can_edit: true, + type: 'group', + avatar_url: null, + permission: 'Owner', + edit_path: '/groups/twitter/edit', + children_count: 2, + leave_path: '/groups/twitter/group_members/leave', + parent_id: null, + number_users_with_delimiter: '5', + project_count: 2, + subgroup_count: 0, + can_leave: false, + }, + { + id: 3, + name: 'Documentcloud', + description: 'Consequatur saepe totam ea pariatur maxime.', + visibility: 'public', + full_name: 'Documentcloud', + relative_path: '/documentcloud', + can_edit: true, + type: 'group', + avatar_url: null, + permission: 'Owner', + edit_path: '/groups/documentcloud/edit', + children_count: 1, + leave_path: '/groups/documentcloud/group_members/leave', + parent_id: null, + number_users_with_delimiter: '5', + project_count: 1, + subgroup_count: 0, + can_leave: false, + }, + { + id: 2, + name: 'Gitlab Org', + description: 'Debitis ea quas aperiam velit doloremque ab.', + visibility: 'public', + full_name: 'Gitlab Org', + relative_path: '/gitlab-org', + can_edit: true, + type: 'group', + avatar_url: '/uploads/-/system/group/avatar/2/GitLab.png', + permission: 'Owner', + edit_path: '/groups/gitlab-org/edit', + children_count: 4, + leave_path: '/groups/gitlab-org/group_members/leave', + parent_id: null, + number_users_with_delimiter: '5', + project_count: 4, + subgroup_count: 0, + can_leave: false, + }, +]; + +export const mockSearchedGroups = [ + { + id: 55, + name: 'hardware', + description: '', + visibility: 'public', + full_name: 'platform / hardware', + relative_path: '/platform/hardware', + can_edit: true, + type: 'group', + avatar_url: null, + permission: 'Owner', + edit_path: '/groups/platform/hardware/edit', + children_count: 3, + leave_path: '/groups/platform/hardware/group_members/leave', + parent_id: 54, + number_users_with_delimiter: '1', + project_count: 1, + subgroup_count: 2, + can_leave: false, + children: [ + { + id: 57, + name: 'bsp', + description: '', + visibility: 'public', + full_name: 'platform / hardware / bsp', + relative_path: '/platform/hardware/bsp', + can_edit: true, + type: 'group', + avatar_url: null, + permission: 'Owner', + edit_path: '/groups/platform/hardware/bsp/edit', + children_count: 6, + leave_path: '/groups/platform/hardware/bsp/group_members/leave', + parent_id: 55, + number_users_with_delimiter: '1', + project_count: 4, + subgroup_count: 2, + can_leave: false, + children: [ + { + id: 60, + name: 'kernel', + description: '', + visibility: 'public', + full_name: 'platform / hardware / bsp / kernel', + relative_path: '/platform/hardware/bsp/kernel', + can_edit: true, + type: 'group', + avatar_url: null, + permission: 'Owner', + edit_path: '/groups/platform/hardware/bsp/kernel/edit', + children_count: 1, + leave_path: '/groups/platform/hardware/bsp/kernel/group_members/leave', + parent_id: 57, + number_users_with_delimiter: '1', + project_count: 0, + subgroup_count: 1, + can_leave: false, + children: [ + { + id: 61, + name: 'common', + description: '', + visibility: 'public', + full_name: 'platform / hardware / bsp / kernel / common', + relative_path: '/platform/hardware/bsp/kernel/common', + can_edit: true, + type: 'group', + avatar_url: null, + permission: 'Owner', + edit_path: '/groups/platform/hardware/bsp/kernel/common/edit', + children_count: 2, + leave_path: '/groups/platform/hardware/bsp/kernel/common/group_members/leave', + parent_id: 60, + number_users_with_delimiter: '1', + project_count: 2, + subgroup_count: 0, + can_leave: false, + children: [ + { + id: 17, + name: 'v4.4', + description: 'Voluptatem qui ea error aperiam veritatis doloremque consequatur temporibus.', + visibility: 'public', + full_name: 'platform / hardware / bsp / kernel / common / v4.4', + relative_path: '/platform/hardware/bsp/kernel/common/v4.4', + can_edit: true, + type: 'project', + avatar_url: null, + permission: null, + edit_path: '/platform/hardware/bsp/kernel/common/v4.4/edit', + star_count: 0, + }, + { + id: 16, + name: 'v4.1', + description: 'Rerum expedita voluptatem doloribus neque ducimus ut hic.', + visibility: 'public', + full_name: 'platform / hardware / bsp / kernel / common / v4.1', + relative_path: '/platform/hardware/bsp/kernel/common/v4.1', + can_edit: true, + type: 'project', + avatar_url: null, + permission: null, + edit_path: '/platform/hardware/bsp/kernel/common/v4.1/edit', + star_count: 0, + }, + ], + }, + ], + }, + ], + }, + ], + }, +]; + +export const mockRawPageInfo = { + 'x-per-page': 10, + 'x-page': 10, + 'x-total': 10, + 'x-total-pages': 10, + 'x-next-page': 10, + 'x-prev-page': 10, }; -export { groupsData, group1 }; +export const mockPageInfo = { + perPage: 10, + page: 10, + total: 10, + totalPages: 10, + nextPage: 10, + prevPage: 10, +}; diff --git a/spec/javascripts/groups/service/groups_service_spec.js b/spec/javascripts/groups/service/groups_service_spec.js new file mode 100644 index 00000000000..222e75d24a4 --- /dev/null +++ b/spec/javascripts/groups/service/groups_service_spec.js @@ -0,0 +1,41 @@ +import Vue from 'vue'; +import VueResource from 'vue-resource'; + +import GroupsService from '~/groups/service/groups_service'; +import { mockEndpoint, mockParentGroupItem } from '../mock_data'; + +Vue.use(VueResource); + +describe('GroupsService', () => { + let service; + + beforeEach(() => { + service = new GroupsService(mockEndpoint); + }); + + describe('getGroups', () => { + it('should return promise for `GET` request on provided endpoint', () => { + spyOn(service.groups, 'get').and.stub(); + const queryParams = { + page: 2, + filter: 'git', + sort: 'created_asc', + }; + + service.getGroups(55, 2, 'git', 'created_asc'); + expect(service.groups.get).toHaveBeenCalledWith({ parent_id: 55 }); + + service.getGroups(null, 2, 'git', 'created_asc'); + expect(service.groups.get).toHaveBeenCalledWith(queryParams); + }); + }); + + describe('leaveGroup', () => { + it('should return promise for `DELETE` request on provided endpoint', () => { + spyOn(Vue.http, 'delete').and.stub(); + + service.leaveGroup(mockParentGroupItem.leavePath); + expect(Vue.http.delete).toHaveBeenCalledWith(mockParentGroupItem.leavePath); + }); + }); +}); diff --git a/spec/javascripts/groups/store/groups_store_spec.js b/spec/javascripts/groups/store/groups_store_spec.js new file mode 100644 index 00000000000..d74f38f476e --- /dev/null +++ b/spec/javascripts/groups/store/groups_store_spec.js @@ -0,0 +1,110 @@ +import GroupsStore from '~/groups/store/groups_store'; +import { + mockGroups, mockSearchedGroups, + mockParentGroupItem, mockRawChildren, + mockRawPageInfo, +} from '../mock_data'; + +describe('ProjectsStore', () => { + describe('constructor', () => { + it('should initialize default state', () => { + let store; + + store = new GroupsStore(); + expect(Object.keys(store.state).length).toBe(2); + expect(Array.isArray(store.state.groups)).toBeTruthy(); + expect(Object.keys(store.state.pageInfo).length).toBe(0); + expect(store.hideProjects).not.toBeDefined(); + + store = new GroupsStore(true); + expect(store.hideProjects).toBeTruthy(); + }); + }); + + describe('setGroups', () => { + it('should set groups to state', () => { + const store = new GroupsStore(); + spyOn(store, 'formatGroupItem').and.callThrough(); + + store.setGroups(mockGroups); + expect(store.state.groups.length).toBe(mockGroups.length); + expect(store.formatGroupItem).toHaveBeenCalledWith(jasmine.any(Object)); + expect(Object.keys(store.state.groups[0]).indexOf('fullName') > -1).toBeTruthy(); + }); + }); + + describe('setSearchedGroups', () => { + it('should set searched groups to state', () => { + const store = new GroupsStore(); + spyOn(store, 'formatGroupItem').and.callThrough(); + + store.setSearchedGroups(mockSearchedGroups); + expect(store.state.groups.length).toBe(mockSearchedGroups.length); + expect(store.formatGroupItem).toHaveBeenCalledWith(jasmine.any(Object)); + expect(Object.keys(store.state.groups[0]).indexOf('fullName') > -1).toBeTruthy(); + expect(Object.keys(store.state.groups[0].children[0]).indexOf('fullName') > -1).toBeTruthy(); + }); + }); + + describe('setGroupChildren', () => { + it('should set children to group item in state', () => { + const store = new GroupsStore(); + spyOn(store, 'formatGroupItem').and.callThrough(); + + store.setGroupChildren(mockParentGroupItem, mockRawChildren); + expect(store.formatGroupItem).toHaveBeenCalledWith(jasmine.any(Object)); + expect(mockParentGroupItem.children.length).toBe(1); + expect(Object.keys(mockParentGroupItem.children[0]).indexOf('fullName') > -1).toBeTruthy(); + expect(mockParentGroupItem.isOpen).toBeTruthy(); + expect(mockParentGroupItem.isChildrenLoading).toBeFalsy(); + }); + }); + + describe('setPaginationInfo', () => { + it('should parse and set pagination info in state', () => { + const store = new GroupsStore(); + + store.setPaginationInfo(mockRawPageInfo); + expect(store.state.pageInfo.perPage).toBe(10); + expect(store.state.pageInfo.page).toBe(10); + expect(store.state.pageInfo.total).toBe(10); + expect(store.state.pageInfo.totalPages).toBe(10); + expect(store.state.pageInfo.nextPage).toBe(10); + expect(store.state.pageInfo.previousPage).toBe(10); + }); + }); + + describe('formatGroupItem', () => { + it('should parse group item object and return updated object', () => { + let store; + let updatedGroupItem; + + store = new GroupsStore(); + updatedGroupItem = store.formatGroupItem(mockRawChildren[0]); + expect(Object.keys(updatedGroupItem).indexOf('fullName') > -1).toBeTruthy(); + expect(updatedGroupItem.childrenCount).toBe(mockRawChildren[0].children_count); + expect(updatedGroupItem.isChildrenLoading).toBe(false); + expect(updatedGroupItem.isBeingRemoved).toBe(false); + + store = new GroupsStore(true); + updatedGroupItem = store.formatGroupItem(mockRawChildren[0]); + expect(Object.keys(updatedGroupItem).indexOf('fullName') > -1).toBeTruthy(); + expect(updatedGroupItem.childrenCount).toBe(mockRawChildren[0].subgroup_count); + }); + }); + + describe('removeGroup', () => { + it('should remove children from group item in state', () => { + const store = new GroupsStore(); + const rawParentGroup = Object.assign({}, mockGroups[0]); + const rawChildGroup = Object.assign({}, mockGroups[1]); + + store.setGroups([rawParentGroup]); + store.setGroupChildren(store.state.groups[0], [rawChildGroup]); + const childItem = store.state.groups[0].children[0]; + + store.removeGroup(childItem, store.state.groups[0]); + expect(store.state.groups[0].children.length).toBe(0); + }); + }); +}); |