diff options
Diffstat (limited to 'app')
54 files changed, 1608 insertions, 712 deletions
diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index d43eae79730..225cafbb7d4 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -76,6 +76,7 @@ import initProjectVisibilitySelector from './project_visibility'; import GpgBadges from './gpg_badges'; import UserFeatureHelper from './helpers/user_feature_helper'; import initChangesDropdown from './init_changes_dropdown'; +import NewGroupChild from './groups/new_group_child'; import AbuseReports from './abuse_reports'; import { ajaxGet, convertPermissionToBoolean } from './lib/utils/common_utils'; import AjaxLoadingSpinner from './ajax_loading_spinner'; @@ -390,10 +391,15 @@ import U2FAuthenticate from './u2f/authenticate'; new gl.Activities(); break; case 'groups:show': + const newGroupChildWrapper = document.querySelector('.js-new-project-subgroup'); shortcut_handler = new ShortcutsNavigation(); new NotificationsForm(); new NotificationsDropdown(); new ProjectsList(); + + if (newGroupChildWrapper) { + new NewGroupChild(newGroupChildWrapper); + } break; case 'groups:group_members:index': new gl.MemberExpirationDate(); 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 600bae24b52..35b96620209 100644 --- a/app/assets/javascripts/groups/index.js +++ b/app/assets/javascripts/groups/index.js @@ -1,16 +1,18 @@ import Vue from 'vue'; +import Translate from '../vue_shared/translate'; import Flash from '../flash'; 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 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'); // Don't do anything if element doesn't exist (No groups) // This is for when the user enters directly to the page via URL @@ -18,176 +20,56 @@ 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); // 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/controllers/concerns/group_tree.rb b/app/controllers/concerns/group_tree.rb new file mode 100644 index 00000000000..f4e1ce31dde --- /dev/null +++ b/app/controllers/concerns/group_tree.rb @@ -0,0 +1,24 @@ +module GroupTree + def render_group_tree(groups) + @groups = if params[:filter].present? + Gitlab::GroupHierarchy.new(groups.search(params[:filter])) + .base_and_ancestors + else + # Only show root groups if no parent-id is given + groups.where(parent_id: params[:parent_id]) + end + @groups = @groups.with_selects_for_list + .sort(@sort = params[:sort]) + .page(params[:page]) + + respond_to do |format| + format.html + format.json do + serializer = GroupChildSerializer.new(current_user: current_user) + .with_pagination(request, response) + serializer.expand_hierarchy if params[:filter].present? + render json: serializer.represent(@groups) + end + end + end +end diff --git a/app/controllers/dashboard/groups_controller.rb b/app/controllers/dashboard/groups_controller.rb index 7ed18fb481c..025769f512a 100644 --- a/app/controllers/dashboard/groups_controller.rb +++ b/app/controllers/dashboard/groups_controller.rb @@ -1,33 +1,8 @@ class Dashboard::GroupsController < Dashboard::ApplicationController - def index - @sort = params[:sort] || 'created_desc' - - @groups = - if params[:parent_id] && Group.supports_nested_groups? - parent = Group.find_by(id: params[:parent_id]) - - if can?(current_user, :read_group, parent) - GroupsFinder.new(current_user, parent: parent).execute - else - Group.none - end - else - current_user.groups - end + include GroupTree - @groups = @groups.search(params[:filter_groups]) if params[:filter_groups].present? - @groups = @groups.includes(:route) - @groups = @groups.sort(@sort) - @groups = @groups.page(params[:page]) - - respond_to do |format| - format.html - format.json do - render json: GroupSerializer - .new(current_user: @current_user) - .with_pagination(request, response) - .represent(@groups) - end - end + def index + groups = GroupsFinder.new(current_user, all_available: false).execute + render_group_tree(groups) end end diff --git a/app/controllers/explore/groups_controller.rb b/app/controllers/explore/groups_controller.rb index 81883c543ba..fa0a0f68fbc 100644 --- a/app/controllers/explore/groups_controller.rb +++ b/app/controllers/explore/groups_controller.rb @@ -1,17 +1,7 @@ class Explore::GroupsController < Explore::ApplicationController - def index - @groups = GroupsFinder.new(current_user).execute - @groups = @groups.search(params[:filter_groups]) if params[:filter_groups].present? - @groups = @groups.sort(@sort = params[:sort]) - @groups = @groups.page(params[:page]) + include GroupTree - respond_to do |format| - format.html - format.json do - render json: { - html: view_to_html_string("explore/groups/_groups", locals: { groups: @groups }) - } - end - end + def index + render_group_tree GroupsFinder.new(current_user).execute end end diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 3769a2cde33..a24a0056a30 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -45,15 +45,11 @@ class GroupsController < Groups::ApplicationController end def show - setup_projects - respond_to do |format| - format.html - - format.json do - render json: { - html: view_to_html_string("dashboard/projects/_projects", locals: { projects: @projects }) - } + format.html do + @has_children = GroupDescendantsFinder.new(current_user: current_user, + parent_group: @group, + params: params).has_children? end format.atom do @@ -63,11 +59,29 @@ class GroupsController < Groups::ApplicationController end end - def subgroups - return not_found unless Group.supports_nested_groups? + def children + parent = if params[:parent_id].present? + GroupFinder.new(current_user).execute(id: params[:parent_id]) + else + @group + end + + if parent.nil? + render_404 + return + end + + setup_children(parent) - @nested_groups = GroupsFinder.new(current_user, parent: group).execute - @nested_groups = @nested_groups.search(params[:filter_groups]) if params[:filter_groups].present? + respond_to do |format| + format.json do + serializer = GroupChildSerializer + .new(current_user: current_user) + .with_pagination(request, response) + serializer.expand_hierarchy(parent) if params[:filter].present? + render json: serializer.represent(@children) + end + end end def activity @@ -106,18 +120,11 @@ class GroupsController < Groups::ApplicationController protected - def setup_projects - set_non_archived_param - params[:sort] ||= 'latest_activity_desc' - @sort = params[:sort] - - options = {} - options[:only_owned] = true if params[:shared] == '0' - options[:only_shared] = true if params[:shared] == '1' - - @projects = GroupProjectsFinder.new(params: params, group: group, options: options, current_user: current_user).execute - @projects = @projects.includes(:namespace) - @projects = @projects.page(params[:page]) if params[:name].blank? + def setup_children(parent) + @children = GroupDescendantsFinder.new(current_user: current_user, + parent_group: parent, + params: params).execute + @children = @children.page(params[:page]) end def authorize_create_group! diff --git a/app/finders/group_descendants_finder.rb b/app/finders/group_descendants_finder.rb new file mode 100644 index 00000000000..ea7974eb882 --- /dev/null +++ b/app/finders/group_descendants_finder.rb @@ -0,0 +1,134 @@ +class GroupDescendantsFinder + attr_reader :current_user, :parent_group, :params + + def initialize(current_user: nil, parent_group:, params: {}) + @current_user = current_user + @parent_group = parent_group + @params = params.reverse_merge(non_archived: true) + end + + def execute + # The children array might be extended with the ancestors of projects when + # filtering. In that case, take the maximum so the array does not get limited + # Otherwise, allow paginating through all results + # + all_required_elements = children + all_required_elements |= ancestors_for_projects if params[:filter] + total_count = [all_required_elements.size, paginator.total_count].max + + Kaminari.paginate_array(all_required_elements, total_count: total_count) + end + + def has_children? + projects.any? || subgroups.any? + end + + private + + def children + @children ||= paginator.paginate(params[:page]) + end + + def paginator + @paginator ||= Gitlab::MultiCollectionPaginator.new(subgroups, projects, + per_page: params[:per_page]) + end + + def direct_child_groups + GroupsFinder.new(current_user, + parent: parent_group, + all_available: true).execute + end + + def all_visible_descendant_groups + groups_table = Group.arel_table + visible_to_user = groups_table[:visibility_level] + .in(Gitlab::VisibilityLevel.levels_for_user(current_user)) + if current_user + authorized_groups = GroupsFinder.new(current_user, + all_available: false) + .execute.as('authorized') + authorized_to_user = groups_table.project(1).from(authorized_groups) + .where(authorized_groups[:id].eq(groups_table[:id])) + .exists + visible_to_user = visible_to_user.or(authorized_to_user) + end + + hierarchy_for_parent + .descendants + .where(visible_to_user) + end + + def subgroups_matching_filter + all_visible_descendant_groups + .search(params[:filter]) + end + + # When filtering we want all to preload all the ancestors upto the specified + # parent group. + # + # - root + # - subgroup + # - nested-group + # - project + # + # So when searching 'project', on the 'subgroup' page we want to preload + # 'nested-group' but not 'subgroup' or 'root' + def ancestors_for_groups(base_for_ancestors) + Gitlab::GroupHierarchy.new(base_for_ancestors) + .base_and_ancestors(upto: parent_group.id) + end + + def ancestors_for_projects + projects_to_load_ancestors_of = projects.where.not(namespace: parent_group) + groups_to_load_ancestors_of = Group.where(id: projects_to_load_ancestors_of.select(:namespace_id)) + ancestors_for_groups(groups_to_load_ancestors_of) + .with_selects_for_list + end + + def subgroups + return Group.none unless Group.supports_nested_groups? + + # When filtering subgroups, we want to find all matches withing the tree of + # descendants to show to the user + groups = if params[:filter] + ancestors_for_groups(subgroups_matching_filter) + else + direct_child_groups + end + groups.with_selects_for_list.order_by(sort) + end + + def direct_child_projects + GroupProjectsFinder.new(group: parent_group, current_user: current_user, params: params) + .execute + end + + def projects_for_user + Project.public_or_visible_to_user(current_user).non_archived + end + + # Finds all projects nested under `parent_group` or any of its descendant + # groups + def projects_matching_filter + projects_for_user.search(params[:filter]) + .where(namespace_id: hierarchy_for_parent.base_and_descendants.select(:id)) + end + + def projects + projects = if params[:filter] + projects_matching_filter + else + direct_child_projects + end + projects.with_route.order_by(sort) + end + + def sort + params.fetch(:sort, 'id_asc') + end + + def hierarchy_for_parent + @hierarchy ||= Gitlab::GroupHierarchy.new(Group.where(id: parent_group.id)) + end +end diff --git a/app/finders/group_projects_finder.rb b/app/finders/group_projects_finder.rb index f2d3b90b8e2..6e8733bb49c 100644 --- a/app/finders/group_projects_finder.rb +++ b/app/finders/group_projects_finder.rb @@ -34,7 +34,6 @@ class GroupProjectsFinder < ProjectsFinder else collection_without_user end - union(projects) end 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/models/concerns/group_descendant.rb b/app/models/concerns/group_descendant.rb new file mode 100644 index 00000000000..01957da0bf3 --- /dev/null +++ b/app/models/concerns/group_descendant.rb @@ -0,0 +1,56 @@ +module GroupDescendant + # Returns the hierarchy of a project or group in the from of a hash upto a + # given top. + # + # > project.hierarchy + # => { parent_group => { child_group => project } } + def hierarchy(hierarchy_top = nil, preloaded = nil) + preloaded ||= ancestors_upto(hierarchy_top) + expand_hierarchy_for_child(self, self, hierarchy_top, preloaded) + end + + # Merges all hierarchies of the given groups or projects into an array of + # hashes. All ancestors need to be loaded into the given `descendants` to avoid + # queries down the line. + # + # > GroupDescendant.merge_hierarchy([project, child_group, child_group2, parent]) + # => { parent => [{ child_group => project}, child_group2] } + def self.build_hierarchy(descendants, hierarchy_top = nil) + descendants = Array.wrap(descendants).uniq + return [] if descendants.empty? + + unless descendants.all? { |hierarchy| hierarchy.is_a?(GroupDescendant) } + raise ArgumentError.new('element is not a hierarchy') + end + + all_hierarchies = descendants.map do |descendant| + descendant.hierarchy(hierarchy_top, descendants) + end + + Gitlab::Utils::MergeHash.merge(all_hierarchies) + end + + private + + def expand_hierarchy_for_child(child, hierarchy, hierarchy_top, preloaded) + parent = hierarchy_top if hierarchy_top && child.parent_id == hierarchy_top.id + parent ||= preloaded.detect { |possible_parent| possible_parent.is_a?(Group) && possible_parent.id == child.parent_id } + + if parent.nil? && !child.parent_id.nil? + raise ArgumentError.new('parent was not preloaded') + end + + if parent.nil? && hierarchy_top.present? + raise ArgumentError.new('specified top is not part of the tree') + end + + if parent && parent != hierarchy_top + expand_hierarchy_for_child(parent, + { parent => hierarchy }, + hierarchy_top, + preloaded) + else + hierarchy + end + end +end diff --git a/app/models/concerns/loaded_in_group_list.rb b/app/models/concerns/loaded_in_group_list.rb new file mode 100644 index 00000000000..e73ddcfb567 --- /dev/null +++ b/app/models/concerns/loaded_in_group_list.rb @@ -0,0 +1,65 @@ +module LoadedInGroupList + extend ActiveSupport::Concern + + PROJECT_COUNT_SQL = <<~PROJECTCOUNT.freeze + (SELECT COUNT(*) AS preloaded_project_count + FROM projects + WHERE projects.namespace_id = namespaces.id + AND projects.archived IS NOT true) + PROJECTCOUNT + SUBGROUP_COUNT_SQL = <<~SUBGROUPCOUNT.freeze + (SELECT COUNT(*) AS preloaded_subgroup_count + FROM namespaces children + WHERE children.parent_id = namespaces.id) + SUBGROUPCOUNT + MEMBER_COUNT_SQL = <<~MEMBERCOUNT.freeze + (SELECT COUNT(*) AS preloaded_member_count + FROM members + WHERE members.source_type = 'Namespace' + AND members.source_id = namespaces.id + AND members.requested_at IS NULL) + MEMBERCOUNT + + COUNT_SELECTS = ['namespaces.*', + PROJECT_COUNT_SQL, + SUBGROUP_COUNT_SQL, + MEMBER_COUNT_SQL].freeze + + module ClassMethods + def with_counts + select(COUNT_SELECTS) + end + + def with_selects_for_list + with_route.with_counts + end + end + + def children_count + @children_count ||= project_count + subgroup_count + end + + def project_count + @project_count ||= if respond_to?(:preloaded_project_count) + preloaded_project_count + else + projects.non_archived.count + end + end + + def subgroup_count + @subgroup_count ||= if respond_to?(:preloaded_subgroup_count) + preloaded_subgroup_count + else + children.count + end + end + + def member_count + @member_count ||= if respond_to?(:preloaded_member_count) + preloaded_member_count + else + users.count + end + end +end diff --git a/app/models/group.rb b/app/models/group.rb index e746e4a12c9..07fb62bb249 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -6,6 +6,8 @@ class Group < Namespace include Avatarable include Referable include SelectForProjectAuthorization + include LoadedInGroupList + include GroupDescendant has_many :group_members, -> { where(requested_at: nil) }, dependent: :destroy, as: :source # rubocop:disable Cop/ActiveRecordDependent alias_method :members, :group_members diff --git a/app/models/namespace.rb b/app/models/namespace.rb index 4672881e220..0601a61a926 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -162,6 +162,13 @@ class Namespace < ActiveRecord::Base .base_and_ancestors end + # returns all ancestors upto but excluding the the given namespace + # when no namespace is given, all ancestors upto the top are returned + def ancestors_upto(top = nil) + Gitlab::GroupHierarchy.new(self.class.where(id: id)) + .ancestors(upto: top) + end + def self_and_ancestors return self.class.where(id: id) unless parent_id diff --git a/app/models/project.rb b/app/models/project.rb index 57e91ab3b88..6e1cf9e31ee 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -17,6 +17,7 @@ class Project < ActiveRecord::Base include ProjectFeaturesCompatibility include SelectForProjectAuthorization include Routable + include GroupDescendant extend Gitlab::ConfigHelper extend Gitlab::CurrentSettings @@ -81,6 +82,8 @@ class Project < ActiveRecord::Base belongs_to :creator, class_name: 'User' belongs_to :group, -> { where(type: 'Group') }, foreign_key: 'namespace_id' belongs_to :namespace + alias_method :parent, :namespace + alias_attribute :parent_id, :namespace_id has_one :last_event, -> {order 'events.created_at DESC'}, class_name: 'Event' has_many :boards, before_add: :validate_board_limit @@ -479,6 +482,13 @@ class Project < ActiveRecord::Base end end + # returns all ancestor-groups upto but excluding the given namespace + # when no namespace is given, all ancestors upto the top are returned + def ancestors_upto(top = nil) + Gitlab::GroupHierarchy.new(Group.where(id: namespace_id)) + .base_and_ancestors(upto: top) + end + def lfs_enabled? return namespace.lfs_enabled? if self[:lfs_enabled].nil? @@ -1549,10 +1559,6 @@ class Project < ActiveRecord::Base map.public_path_for_source_path(path) end - def parent - namespace - end - def parent_changed? namespace_id_changed? end diff --git a/app/serializers/base_serializer.rb b/app/serializers/base_serializer.rb index 4e6c15f673b..8cade280b0c 100644 --- a/app/serializers/base_serializer.rb +++ b/app/serializers/base_serializer.rb @@ -1,6 +1,9 @@ class BaseSerializer - def initialize(parameters = {}) - @request = EntityRequest.new(parameters) + attr_reader :params + + def initialize(params = {}) + @params = params + @request = EntityRequest.new(params) end def represent(resource, opts = {}, entity_class = nil) diff --git a/app/serializers/concerns/with_pagination.rb b/app/serializers/concerns/with_pagination.rb new file mode 100644 index 00000000000..d29e22d6740 --- /dev/null +++ b/app/serializers/concerns/with_pagination.rb @@ -0,0 +1,22 @@ +module WithPagination + attr_accessor :paginator + + def with_pagination(request, response) + tap { self.paginator = Gitlab::Serializer::Pagination.new(request, response) } + end + + def paginated? + paginator.present? + end + + # super is `BaseSerializer#represent` here. + # + # we shouldn't try to paginate single resources + def represent(resource, opts = {}) + if paginated? && resource.respond_to?(:page) + super(@paginator.paginate(resource), opts) + else + super(resource, opts) + end + end +end diff --git a/app/serializers/environment_serializer.rb b/app/serializers/environment_serializer.rb index 88842a9aa75..84722f33f59 100644 --- a/app/serializers/environment_serializer.rb +++ b/app/serializers/environment_serializer.rb @@ -1,4 +1,6 @@ class EnvironmentSerializer < BaseSerializer + include WithPagination + Item = Struct.new(:name, :size, :latest) entity EnvironmentEntity @@ -7,18 +9,10 @@ class EnvironmentSerializer < BaseSerializer tap { @itemize = true } end - def with_pagination(request, response) - tap { @paginator = Gitlab::Serializer::Pagination.new(request, response) } - end - def itemized? @itemize end - def paginated? - @paginator.present? - end - def represent(resource, opts = {}) if itemized? itemize(resource).map do |item| @@ -27,8 +21,6 @@ class EnvironmentSerializer < BaseSerializer latest: super(item.latest, opts) } end else - resource = @paginator.paginate(resource) if paginated? - super(resource, opts) end end diff --git a/app/serializers/group_child_entity.rb b/app/serializers/group_child_entity.rb new file mode 100644 index 00000000000..5c1fa72b1ac --- /dev/null +++ b/app/serializers/group_child_entity.rb @@ -0,0 +1,81 @@ +class GroupChildEntity < Grape::Entity + include ActionView::Helpers::NumberHelper + include RequestAwareEntity + + expose :id, :name, :description, :visibility, :full_name, + :created_at, :updated_at, :avatar_url + + expose :type do |instance| + instance.class.name.downcase + end + + expose :can_edit do |instance| + return false unless request.respond_to?(:current_user) + + if project? + can?(request.current_user, :admin_project, instance) + else + can?(request.current_user, :admin_group, instance) + end + end + + expose :edit_path do |instance| + if project? + edit_project_path(instance) + else + edit_group_path(instance) + end + end + + expose :relative_path do |instance| + if project? + project_path(instance) + else + group_path(instance) + end + end + + expose :permission do |instance| + membership&.human_access + end + + # Project only attributes + expose :star_count, + if: lambda { |_instance, _options| project? } + + # Group only attributes + expose :children_count, :parent_id, :project_count, :subgroup_count, + unless: lambda { |_instance, _options| project? } + + expose :leave_path, unless: lambda { |_instance, _options| project? } do |instance| + leave_group_members_path(instance) + end + + expose :can_leave, unless: lambda { |_instance, _options| project? } do |instance| + if membership + can?(request.current_user, :destroy_group_member, membership) + else + false + end + end + + expose :number_projects_with_delimiter, unless: lambda { |_instance, _options| project? } do |instance| + number_with_delimiter(instance.project_count) + end + + expose :number_users_with_delimiter, unless: lambda { |_instance, _options| project? } do |instance| + number_with_delimiter(instance.member_count) + end + + private + + def membership + return unless request.current_user + + @membership ||= request.current_user.members.find_by(source: object) + end + + def project? + object.is_a?(Project) + end +end diff --git a/app/serializers/group_child_serializer.rb b/app/serializers/group_child_serializer.rb new file mode 100644 index 00000000000..6d0e67a37cd --- /dev/null +++ b/app/serializers/group_child_serializer.rb @@ -0,0 +1,49 @@ +class GroupChildSerializer < BaseSerializer + include WithPagination + + attr_reader :hierarchy_root, :should_expand_hierarchy + + entity GroupChildEntity + + def expand_hierarchy(hierarchy_root = nil) + @hierarchy_root = hierarchy_root + @should_expand_hierarchy = true + + self + end + + def represent(resource, opts = {}, entity_class = nil) + if should_expand_hierarchy + paginator.paginate(resource) if paginated? + represent_hierarchies(resource, opts) + else + super(resource, opts) + end + end + + protected + + def represent_hierarchies(children, opts) + if children.is_a?(GroupDescendant) + represent_hierarchy(children.hierarchy(hierarchy_root), opts).first + else + hierarchies = GroupDescendant.build_hierarchy(children, hierarchy_root) + represent_hierarchy(hierarchies, opts) + end + end + + def represent_hierarchy(hierarchy, opts) + serializer = self.class.new(params) + + if hierarchy.is_a?(Hash) + hierarchy.map do |parent, children| + serializer.represent(parent, opts) + .merge(children: Array.wrap(serializer.represent_hierarchy(children, opts))) + end + elsif hierarchy.is_a?(Array) + hierarchy.flat_map { |child| serializer.represent_hierarchy(child, opts) } + else + serializer.represent(hierarchy, opts) + end + end +end diff --git a/app/serializers/group_serializer.rb b/app/serializers/group_serializer.rb index 26e8566828b..8cf7eb63bcf 100644 --- a/app/serializers/group_serializer.rb +++ b/app/serializers/group_serializer.rb @@ -1,19 +1,5 @@ class GroupSerializer < BaseSerializer - entity GroupEntity - - def with_pagination(request, response) - tap { @paginator = Gitlab::Serializer::Pagination.new(request, response) } - end + include WithPagination - def paginated? - @paginator.present? - end - - def represent(resource, opts = {}) - if paginated? - super(@paginator.paginate(resource), opts) - else - super(resource, opts) - end - end + entity GroupEntity end diff --git a/app/serializers/pipeline_serializer.rb b/app/serializers/pipeline_serializer.rb index 661bf17983c..7181f8a6b04 100644 --- a/app/serializers/pipeline_serializer.rb +++ b/app/serializers/pipeline_serializer.rb @@ -1,16 +1,10 @@ class PipelineSerializer < BaseSerializer + include WithPagination + InvalidResourceError = Class.new(StandardError) entity PipelineDetailsEntity - def with_pagination(request, response) - tap { @paginator = Gitlab::Serializer::Pagination.new(request, response) } - end - - def paginated? - @paginator.present? - end - def represent(resource, opts = {}) if resource.is_a?(ActiveRecord::Relation) 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 new file mode 100644 index 00000000000..3afb6b2f849 --- /dev/null +++ b/app/views/groups/_children.html.haml @@ -0,0 +1,5 @@ += 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_nav.html.haml b/app/views/groups/_show_nav.html.haml deleted file mode 100644 index 35b75bc0923..00000000000 --- a/app/views/groups/_show_nav.html.haml +++ /dev/null @@ -1,8 +0,0 @@ -%ul.nav-links - = nav_link(page: group_path(@group)) do - = link_to group_path(@group) do - Projects - - if Group.supports_nested_groups? - = nav_link(page: subgroups_group_path(@group)) do - = link_to subgroups_group_path(@group) do - Subgroups diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml index 3ca63f9c3e0..bfbe133f4e0 100644 --- a/app/views/groups/show.html.haml +++ b/app/views/groups/show.html.haml @@ -1,5 +1,6 @@ - @no_container = true - breadcrumb_title "Details" +- can_create_subgroups = can?(current_user, :create_subgroup, @group) = content_for :meta_tags do = auto_discovery_link_tag(:atom, group_url(@group, rss_url_options), title: "#{@group.name} activity") @@ -7,13 +8,38 @@ = render 'groups/home_panel' .groups-header{ class: container_class } - .top-area - = render 'groups/show_nav' - .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") + - if can_create_subgroups + .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 a project in 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 in this group.") + - else + = link_to new_project_label, new_project_path(namespace_id: @group.id), class: "btn btn-success" - = render "projects", projects: @projects + - if params[:filter].blank? && !@has_children + = render "shared/groups/empty_state" + - else + = render "children", children: @children, group: @group diff --git a/app/views/groups/subgroups.html.haml b/app/views/groups/subgroups.html.haml deleted file mode 100644 index 869b3b243c6..00000000000 --- a/app/views/groups/subgroups.html.haml +++ /dev/null @@ -1,21 +0,0 @@ -- breadcrumb_title "Details" -- @no_container = true - -= render 'groups/home_panel' - -.groups-header{ class: container_class } - .top-area - = render 'groups/show_nav' - .nav-controls - = form_tag request.path, method: :get do |f| - = search_field_tag :filter_groups, params[:filter_groups], placeholder: 'Filter by name', class: 'form-control', spellcheck: false - - if can?(current_user, :create_subgroup, @group) - = link_to new_group_path(parent_id: @group.id), class: 'btn btn-new pull-right' do - New Subgroup - - - if @nested_groups.present? - %ul.content-list - = render partial: 'shared/groups/group', collection: @nested_groups, locals: { full_name: false } - - else - .nothing-here-block - There are no subgroups to show. 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 63f62eb476e..059dd24be6d 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 |