diff options
35 files changed, 594 insertions, 250 deletions
diff --git a/app/assets/javascripts/groups/components/app.vue b/app/assets/javascripts/groups/components/app.vue index b0765747a36..69f192ac75e 100644 --- a/app/assets/javascripts/groups/components/app.vue +++ b/app/assets/javascripts/groups/components/app.vue @@ -2,14 +2,15 @@ /* global Flash */ import $ from 'jquery'; -import { s__ } from '~/locale'; +import { s__, sprintf } from '~/locale'; import loadingIcon from '~/vue_shared/components/loading_icon.vue'; import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue'; +import { HIDDEN_CLASS } from '~/lib/utils/constants'; import { getParameterByName } from '~/lib/utils/common_utils'; import { mergeUrlParams } from '~/lib/utils/url_utility'; import eventHub from '../event_hub'; -import { COMMON_STR } from '../constants'; +import { COMMON_STR, CONTENT_LIST_CLASS } from '../constants'; import groupsComponent from './groups.vue'; export default { @@ -19,6 +20,16 @@ export default { groupsComponent, }, props: { + action: { + type: String, + required: false, + default: '', + }, + containerId: { + type: String, + required: false, + default: '', + }, store: { type: Object, required: true, @@ -56,31 +67,28 @@ export default { ? COMMON_STR.GROUP_SEARCH_EMPTY : COMMON_STR.GROUP_PROJECT_SEARCH_EMPTY; - eventHub.$on('fetchPage', this.fetchPage); - eventHub.$on('toggleChildren', this.toggleChildren); - eventHub.$on('showLeaveGroupModal', this.showLeaveGroupModal); - eventHub.$on('updatePagination', this.updatePagination); - eventHub.$on('updateGroups', this.updateGroups); + eventHub.$on(`${this.action}fetchPage`, this.fetchPage); + eventHub.$on(`${this.action}toggleChildren`, this.toggleChildren); + eventHub.$on(`${this.action}showLeaveGroupModal`, this.showLeaveGroupModal); + eventHub.$on(`${this.action}updatePagination`, this.updatePagination); + eventHub.$on(`${this.action}updateGroups`, this.updateGroups); }, mounted() { this.fetchAllGroups(); + + if (this.containerId) { + this.containerEl = document.getElementById(this.containerId); + } }, beforeDestroy() { - eventHub.$off('fetchPage', this.fetchPage); - eventHub.$off('toggleChildren', this.toggleChildren); - eventHub.$off('showLeaveGroupModal', this.showLeaveGroupModal); - eventHub.$off('updatePagination', this.updatePagination); - eventHub.$off('updateGroups', this.updateGroups); + eventHub.$off(`${this.action}fetchPage`, this.fetchPage); + eventHub.$off(`${this.action}toggleChildren`, this.toggleChildren); + eventHub.$off(`${this.action}showLeaveGroupModal`, this.showLeaveGroupModal); + eventHub.$off(`${this.action}updatePagination`, this.updatePagination); + eventHub.$off(`${this.action}updateGroups`, this.updateGroups); }, methods: { - fetchGroups({ - parentId, - page, - filterGroupsBy, - sortBy, - archived, - updatePagination, - }) { + fetchGroups({ parentId, page, filterGroupsBy, sortBy, archived, updatePagination }) { return this.service .getGroups(parentId, page, filterGroupsBy, sortBy, archived) .then(res => { @@ -165,13 +173,13 @@ export default { } }, showLeaveGroupModal(group, parentGroup) { + const { fullName } = group; this.targetGroup = group; this.targetParentGroup = parentGroup; this.showModal = true; - this.groupLeaveConfirmationMessage = s__( - `GroupsTree|Are you sure you want to leave the "${ - group.fullName - }" group?`, + this.groupLeaveConfirmationMessage = sprintf( + s__('GroupsTree|Are you sure you want to leave the "%{fullName}" group?'), + { fullName }, ); }, hideLeaveGroupModal() { @@ -197,16 +205,35 @@ export default { this.targetGroup.isBeingRemoved = false; }); }, + showEmptyState() { + const { containerEl } = this; + const contentListEl = containerEl.querySelector(CONTENT_LIST_CLASS); + const emptyStateEl = containerEl.querySelector('.empty-state'); + + if (contentListEl) { + contentListEl.remove(); + } + + if (emptyStateEl) { + emptyStateEl.classList.remove(HIDDEN_CLASS); + } + }, updatePagination(headers) { this.store.setPaginationInfo(headers); }, updateGroups(groups, fromSearch) { - this.isSearchEmpty = groups ? groups.length === 0 : false; + const hasGroups = groups && groups.length > 0; + this.isSearchEmpty = !hasGroups; + if (fromSearch) { this.store.setSearchedGroups(groups); } else { this.store.setGroups(groups); } + + if (this.action && !hasGroups && !fromSearch) { + this.showEmptyState(); + } }, }, }; @@ -226,6 +253,7 @@ export default { :search-empty="isSearchEmpty" :search-empty-message="searchEmptyMessage" :page-info="pageInfo" + :action="action" /> <deprecated-modal v-show="showModal" diff --git a/app/assets/javascripts/groups/components/group_folder.vue b/app/assets/javascripts/groups/components/group_folder.vue index 647c9d0046d..bcc7a638346 100644 --- a/app/assets/javascripts/groups/components/group_folder.vue +++ b/app/assets/javascripts/groups/components/group_folder.vue @@ -11,8 +11,12 @@ export default { }, groups: { type: Array, + required: true, + }, + action: { + type: String, required: false, - default: () => ([]), + default: '', }, }, computed: { @@ -37,6 +41,7 @@ export default { :key="index" :group="group" :parent-group="parentGroup" + :action="action" /> <li v-if="hasMoreChildren" diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue index 2b9e2a929fc..154ad2ea607 100644 --- a/app/assets/javascripts/groups/components/group_item.vue +++ b/app/assets/javascripts/groups/components/group_item.vue @@ -30,6 +30,11 @@ export default { type: Object, required: true, }, + action: { + type: String, + required: false, + default: '', + }, }, computed: { groupDomId() { @@ -56,10 +61,12 @@ export default { 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))) { + const targetClasses = e.target.classList; + const parentElClasses = e.target.parentElement.classList; + + if (!(targetClasses.contains(NO_EXPAND_CLS) || parentElClasses.contains(NO_EXPAND_CLS))) { if (this.hasChildren) { - eventHub.$emit('toggleChildren', this.group); + eventHub.$emit(`${this.action}toggleChildren`, this.group); } else { visitUrl(this.group.relativePath); } @@ -158,6 +165,7 @@ export default { v-if="group.isOpen && hasChildren" :parent-group="group" :groups="group.children" + :action="action" /> </li> </template> diff --git a/app/assets/javascripts/groups/components/groups.vue b/app/assets/javascripts/groups/components/groups.vue index 73ae928b0d9..a1beb222950 100644 --- a/app/assets/javascripts/groups/components/groups.vue +++ b/app/assets/javascripts/groups/components/groups.vue @@ -1,39 +1,44 @@ <script> - import tablePagination from '~/vue_shared/components/table_pagination.vue'; - import eventHub from '../event_hub'; - import { getParameterByName } from '../../lib/utils/common_utils'; +import tablePagination from '~/vue_shared/components/table_pagination.vue'; +import eventHub from '../event_hub'; +import { getParameterByName } from '../../lib/utils/common_utils'; - export default { - components: { - tablePagination, +export default { + components: { + tablePagination, + }, + props: { + groups: { + type: Array, + required: true, }, - props: { - groups: { - type: Array, - required: true, - }, - pageInfo: { - type: Object, - required: true, - }, - searchEmpty: { - type: Boolean, - required: true, - }, - searchEmptyMessage: { - type: String, - required: true, - }, + pageInfo: { + type: Object, + required: true, }, - methods: { - change(page) { - const filterGroupsParam = getParameterByName('filter_groups'); - const sortParam = getParameterByName('sort'); - const archivedParam = getParameterByName('archived'); - eventHub.$emit('fetchPage', page, filterGroupsParam, sortParam, archivedParam); - }, + searchEmpty: { + type: Boolean, + required: true, }, - }; + searchEmptyMessage: { + type: String, + required: true, + }, + action: { + type: String, + required: false, + default: '', + }, + }, + methods: { + change(page) { + const filterGroupsParam = getParameterByName('filter_groups'); + const sortParam = getParameterByName('sort'); + const archivedParam = getParameterByName('archived'); + eventHub.$emit(`${this.action}fetchPage`, page, filterGroupsParam, sortParam, archivedParam); + }, + }, +}; </script> <template> @@ -47,6 +52,7 @@ <group-folder v-if="!searchEmpty" :groups="groups" + :action="action" /> <table-pagination v-if="!searchEmpty" diff --git a/app/assets/javascripts/groups/components/item_actions.vue b/app/assets/javascripts/groups/components/item_actions.vue index 24eec4901ec..6e700b8bf8a 100644 --- a/app/assets/javascripts/groups/components/item_actions.vue +++ b/app/assets/javascripts/groups/components/item_actions.vue @@ -21,6 +21,11 @@ export default { type: Object, required: true, }, + action: { + type: String, + required: false, + default: '', + }, }, computed: { leaveBtnTitle() { @@ -32,7 +37,7 @@ export default { }, methods: { onLeaveGroup() { - eventHub.$emit('showLeaveGroupModal', this.group, this.parentGroup); + eventHub.$emit(`${this.action}showLeaveGroupModal`, this.group, this.parentGroup); }, }, }; diff --git a/app/assets/javascripts/groups/constants.js b/app/assets/javascripts/groups/constants.js index b8baed682f5..9c246cf3ba6 100644 --- a/app/assets/javascripts/groups/constants.js +++ b/app/assets/javascripts/groups/constants.js @@ -2,13 +2,23 @@ import { __, s__ } from '../locale'; export const MAX_CHILDREN_COUNT = 20; +export const ACTIVE_TAB_SUBGROUPS_AND_PROJECTS = 'subgroups_and_projects'; +export const ACTIVE_TAB_SHARED = 'shared'; +export const ACTIVE_TAB_ARCHIVED = 'archived'; + +export const GROUPS_LIST_HOLDER_CLASS = '.js-groups-list-holder'; +export const GROUPS_FILTER_FORM_CLASS = '.js-group-filter-form'; +export const CONTENT_LIST_CLASS = '.content-list'; + 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_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'), + GROUP_SEARCH_EMPTY: s__('GroupsTree|No groups matched your search'), + GROUP_PROJECT_SEARCH_EMPTY: s__('GroupsTree|No groups or projects matched your search'), }; export const ITEM_TYPE = { @@ -17,8 +27,12 @@ export const ITEM_TYPE = { }; 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.'), + 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.'), }; diff --git a/app/assets/javascripts/groups/groups_filterable_list.js b/app/assets/javascripts/groups/groups_filterable_list.js index e6db1746487..693519729ac 100644 --- a/app/assets/javascripts/groups/groups_filterable_list.js +++ b/app/assets/javascripts/groups/groups_filterable_list.js @@ -4,13 +4,23 @@ import eventHub from './event_hub'; import { normalizeHeaders, getParameterByName } from '../lib/utils/common_utils'; export default class GroupFilterableList extends FilterableList { - constructor({ form, filter, holder, filterEndpoint, pagePath, dropdownSel, filterInputField }) { + constructor({ + form, + filter, + holder, + filterEndpoint, + pagePath, + dropdownSel, + filterInputField, + action, + }) { super(form, filter, holder, filterInputField); this.form = form; this.filterEndpoint = filterEndpoint; this.pagePath = pagePath; this.filterInputField = filterInputField; this.$dropdown = $(dropdownSel); + this.action = action; } getFilterEndpoint() { @@ -20,15 +30,16 @@ export default class GroupFilterableList extends FilterableList { getPagePath(queryData) { const params = queryData ? $.param(queryData) : ''; const queryString = params ? `?${params}` : ''; - return `${this.pagePath}${queryString}`; + const path = this.pagePath || window.location.pathname; + return `${path}${queryString}`; } bindEvents() { super.bindEvents(); - this.onFilterOptionClikWrapper = this.onOptionClick.bind(this); + this.onFilterOptionClickWrapper = this.onOptionClick.bind(this); - this.$dropdown.on('click', 'a', this.onFilterOptionClikWrapper); + this.$dropdown.on('click', 'a', this.onFilterOptionClickWrapper); } onFilterInput() { @@ -53,7 +64,12 @@ export default class GroupFilterableList extends FilterableList { } setDefaultFilterOption() { - const defaultOption = $.trim(this.$dropdown.find('.dropdown-menu li.js-filter-sort-order a').first().text()); + const defaultOption = $.trim( + this.$dropdown + .find('.dropdown-menu li.js-filter-sort-order a') + .first() + .text(), + ); this.$dropdown.find('.dropdown-label').text(defaultOption); } @@ -65,11 +81,19 @@ export default class GroupFilterableList extends FilterableList { // Get type of option selected from dropdown const currentTargetClassList = e.currentTarget.parentElement.classList; const isOptionFilterBySort = currentTargetClassList.contains('js-filter-sort-order'); - const isOptionFilterByArchivedProjects = currentTargetClassList.contains('js-filter-archived-projects'); + const isOptionFilterByArchivedProjects = currentTargetClassList.contains( + 'js-filter-archived-projects', + ); // Get option query param, also preserve currently applied query param - const sortParam = getParameterByName('sort', isOptionFilterBySort ? e.currentTarget.href : window.location.href); - const archivedParam = getParameterByName('archived', isOptionFilterByArchivedProjects ? e.currentTarget.href : window.location.href); + const sortParam = getParameterByName( + 'sort', + isOptionFilterBySort ? e.currentTarget.href : window.location.href, + ); + const archivedParam = getParameterByName( + 'archived', + isOptionFilterByArchivedProjects ? e.currentTarget.href : window.location.href, + ); if (sortParam) { queryData.sort = sortParam; @@ -86,7 +110,9 @@ export default class GroupFilterableList extends FilterableList { this.$dropdown.find('.dropdown-label').text($.trim(e.currentTarget.text)); this.$dropdown.find('.dropdown-menu li.js-filter-sort-order a').removeClass('is-active'); } else if (isOptionFilterByArchivedProjects) { - this.$dropdown.find('.dropdown-menu li.js-filter-archived-projects a').removeClass('is-active'); + this.$dropdown + .find('.dropdown-menu li.js-filter-archived-projects a') + .removeClass('is-active'); } $(e.target).addClass('is-active'); @@ -98,11 +124,19 @@ export default class GroupFilterableList extends FilterableList { onFilterSuccess(res, queryData) { const currentPath = this.getPagePath(queryData); - window.history.replaceState({ - page: currentPath, - }, document.title, currentPath); - - eventHub.$emit('updateGroups', res.data, Object.prototype.hasOwnProperty.call(queryData, this.filterInputField)); - eventHub.$emit('updatePagination', normalizeHeaders(res.headers)); + window.history.replaceState( + { + page: currentPath, + }, + document.title, + currentPath, + ); + + eventHub.$emit( + `${this.action}updateGroups`, + res.data, + Object.prototype.hasOwnProperty.call(queryData, this.filterInputField), + ); + eventHub.$emit(`${this.action}updatePagination`, normalizeHeaders(res.headers)); } } diff --git a/app/assets/javascripts/groups/index.js b/app/assets/javascripts/groups/index.js index 83a9008a94b..0f68f05b523 100644 --- a/app/assets/javascripts/groups/index.js +++ b/app/assets/javascripts/groups/index.js @@ -7,18 +7,26 @@ 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'; +import { GROUPS_LIST_HOLDER_CLASS, CONTENT_LIST_CLASS } from './constants'; Vue.use(Translate); -export default () => { - const el = document.getElementById('js-groups-tree'); +export default (containerId = 'js-groups-tree', endpoint, action = '') => { + const containerEl = document.getElementById(containerId); + let dataEl; // Don't do anything if element doesn't exist (No groups) // This is for when the user enters directly to the page via URL - if (!el) { + if (!containerEl) { return; } + const el = action ? containerEl.querySelector(GROUPS_LIST_HOLDER_CLASS) : containerEl; + + if (action) { + dataEl = containerEl.querySelector(CONTENT_LIST_CLASS); + } + Vue.component('group-folder', groupFolderComponent); Vue.component('group-item', groupItemComponent); @@ -29,20 +37,26 @@ export default () => { groupsApp, }, data() { - const { dataset } = this.$options.el; + const { dataset } = dataEl || this.$options.el; const hideProjects = dataset.hideProjects === 'true'; + const service = new GroupsService(endpoint || dataset.endpoint); const store = new GroupsStore(hideProjects); - const service = new GroupsService(dataset.endpoint); return { + action, store, service, hideProjects, loading: true, + containerId, }; }, beforeMount() { - const { dataset } = this.$options.el; + if (this.action) { + return; + } + + const { dataset } = dataEl || this.$options.el; let groupFilterList = null; const form = document.querySelector(dataset.formSel); const filter = document.querySelector(dataset.filterSel); @@ -52,10 +66,11 @@ export default () => { form, filter, holder, - filterEndpoint: dataset.endpoint, + filterEndpoint: endpoint || dataset.endpoint, pagePath: dataset.path, dropdownSel: dataset.dropdownSel, filterInputField: 'filter', + action: this.action, }; groupFilterList = new GroupFilterableList(opts); @@ -64,9 +79,11 @@ export default () => { render(createElement) { return createElement('groups-app', { props: { + action: this.action, store: this.store, service: this.service, hideProjects: this.hideProjects, + containerId: this.containerId, }, }); }, diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js index 72b72f4247d..a282c2df441 100644 --- a/app/assets/javascripts/lib/utils/url_utility.js +++ b/app/assets/javascripts/lib/utils/url_utility.js @@ -47,9 +47,9 @@ export function removeParamQueryString(url, param) { return urlVariables.filter(variable => variable.indexOf(param) === -1).join('&'); } -export function removeParams(params) { +export function removeParams(params, source = window.location.href) { const url = document.createElement('a'); - url.href = window.location.href; + url.href = source; params.forEach(param => { url.search = removeParamQueryString(url.search, param); diff --git a/app/assets/javascripts/pages/dashboard/groups/index/index.js b/app/assets/javascripts/pages/dashboard/groups/index/index.js index 79987642796..b9277106a71 100644 --- a/app/assets/javascripts/pages/dashboard/groups/index/index.js +++ b/app/assets/javascripts/pages/dashboard/groups/index/index.js @@ -1,3 +1,5 @@ import initGroupsList from '~/groups'; -document.addEventListener('DOMContentLoaded', initGroupsList); +document.addEventListener('DOMContentLoaded', () => { + initGroupsList(); +}); diff --git a/app/assets/javascripts/pages/groups/show/group_tabs.js b/app/assets/javascripts/pages/groups/show/group_tabs.js new file mode 100644 index 00000000000..c6fe61d2bd9 --- /dev/null +++ b/app/assets/javascripts/pages/groups/show/group_tabs.js @@ -0,0 +1,136 @@ +import $ from 'jquery'; +import { removeParams } from '~/lib/utils/url_utility'; +import createGroupTree from '~/groups'; +import { + ACTIVE_TAB_SUBGROUPS_AND_PROJECTS, + ACTIVE_TAB_SHARED, + ACTIVE_TAB_ARCHIVED, + CONTENT_LIST_CLASS, + GROUPS_LIST_HOLDER_CLASS, + GROUPS_FILTER_FORM_CLASS, +} from '~/groups/constants'; +import UserTabs from '~/pages/users/user_tabs'; +import GroupFilterableList from '~/groups/groups_filterable_list'; + +export default class GroupTabs extends UserTabs { + constructor({ defaultAction = 'subgroups_and_projects', action, parentEl }) { + super({ defaultAction, action, parentEl }); + } + + bindEvents() { + this.$parentEl + .off('shown.bs.tab', '.nav-links a[data-toggle="tab"]') + .on('shown.bs.tab', '.nav-links a[data-toggle="tab"]', event => this.tabShown(event)); + } + + tabShown(event) { + const $target = $(event.target); + const action = $target.data('action') || $target.data('targetSection'); + const source = $target.attr('href') || $target.data('targetPath'); + + document.querySelector(GROUPS_FILTER_FORM_CLASS).action = source; + + this.setTab(action); + return this.setCurrentAction(source); + } + + setTab(action) { + const loadableActions = [ + ACTIVE_TAB_SUBGROUPS_AND_PROJECTS, + ACTIVE_TAB_SHARED, + ACTIVE_TAB_ARCHIVED, + ]; + this.enableSearchBar(action); + this.action = action; + + if (this.loaded[action]) { + return; + } + + if (loadableActions.includes(action)) { + this.cleanFilterState(); + this.loadTab(action); + } + } + + loadTab(action) { + const elId = `js-groups-${action}-tree`; + const endpoint = this.getEndpoint(action); + + this.toggleLoading(true); + + createGroupTree(elId, endpoint, action); + this.loaded[action] = true; + + this.toggleLoading(false); + } + + getEndpoint(action) { + const { endpointsDefault, endpointsShared } = this.$parentEl.data(); + let endpoint; + + switch (action) { + case ACTIVE_TAB_ARCHIVED: + endpoint = `${endpointsDefault}?archived=only`; + break; + case ACTIVE_TAB_SHARED: + endpoint = endpointsShared; + break; + default: + // ACTIVE_TAB_SUBGROUPS_AND_PROJECTS + endpoint = endpointsDefault; + break; + } + + return endpoint; + } + + enableSearchBar(action) { + const containerEl = document.getElementById(action); + const form = document.querySelector(GROUPS_FILTER_FORM_CLASS); + const filter = form.querySelector('.js-groups-list-filter'); + const holder = containerEl.querySelector(GROUPS_LIST_HOLDER_CLASS); + const dataEl = containerEl.querySelector(CONTENT_LIST_CLASS); + const endpoint = this.getEndpoint(action); + + if (!dataEl) { + return; + } + + const { dataset } = dataEl; + const opts = { + form, + filter, + holder, + filterEndpoint: endpoint || dataset.endpoint, + pagePath: null, + dropdownSel: '.js-group-filter-dropdown-wrap', + filterInputField: 'filter', + action, + }; + + if (!this.loaded[action]) { + const filterableList = new GroupFilterableList(opts); + filterableList.initSearch(); + } + } + + cleanFilterState() { + const values = Object.values(this.loaded); + const loadedTabs = values.filter(e => e === true); + + if (!loadedTabs.length) { + return; + } + + const newState = removeParams(['page'], window.location.search); + + window.history.replaceState( + { + url: newState, + }, + document.title, + newState, + ); + } +} diff --git a/app/assets/javascripts/pages/groups/show/index.js b/app/assets/javascripts/pages/groups/show/index.js index d7b35d2b26b..5b8c2ae7e81 100644 --- a/app/assets/javascripts/pages/groups/show/index.js +++ b/app/assets/javascripts/pages/groups/show/index.js @@ -1,14 +1,22 @@ /* eslint-disable no-new */ +import { getPagePath } from '~/lib/utils/common_utils'; +import { ACTIVE_TAB_SHARED, ACTIVE_TAB_ARCHIVED } from '~/groups/constants'; import NewGroupChild from '~/groups/new_group_child'; import notificationsDropdown from '~/notifications_dropdown'; import NotificationsForm from '~/notifications_form'; import ProjectsList from '~/projects_list'; import ShortcutsNavigation from '~/shortcuts_navigation'; -import initGroupsList from '~/groups'; +import GroupTabs from './group_tabs'; document.addEventListener('DOMContentLoaded', () => { const newGroupChildWrapper = document.querySelector('.js-new-project-subgroup'); + const loadableActions = [ACTIVE_TAB_SHARED, ACTIVE_TAB_ARCHIVED]; + const paths = window.location.pathname.split('/'); + const subpath = paths[paths.length - 1]; + const action = loadableActions.includes(subpath) ? subpath : getPagePath(1); + + new GroupTabs({ parentEl: '.groups-listing', action }); new ShortcutsNavigation(); new NotificationsForm(); notificationsDropdown(); @@ -17,6 +25,4 @@ document.addEventListener('DOMContentLoaded', () => { if (newGroupChildWrapper) { new NewGroupChild(newGroupChildWrapper); } - - initGroupsList(); }); diff --git a/app/assets/stylesheets/pages/groups.scss b/app/assets/stylesheets/pages/groups.scss index 60b4d39bb1a..9ff62e58681 100644 --- a/app/assets/stylesheets/pages/groups.scss +++ b/app/assets/stylesheets/pages/groups.scss @@ -3,7 +3,6 @@ } .dashboard .side .card .card-header .input-group { - .form-control { height: 42px; } @@ -30,14 +29,15 @@ } } +.group-nav-container .group-search, .group-nav-container .nav-controls { display: flex; align-items: flex-start; - padding: $gl-padding-top 0; - border-bottom: 1px solid $border-color; + padding: $gl-padding-top 0 0; .group-filter-form { - flex: 1; + flex: 1 1 auto; + margin-right: $gl-padding-8; } .dropdown-menu-right { @@ -136,6 +136,10 @@ flex: 1; } + .dropdown-toggle { + width: auto; + } + .dropdown-menu { width: 100%; max-width: inherit; @@ -145,38 +149,14 @@ } } -.groups-empty-state { - padding: 50px 100px; - overflow: hidden; - - @include media-breakpoint-down(sm) { - padding: 50px 0; - } - - svg { - float: right; - - @include media-breakpoint-down(sm) { - float: none; - display: block; - width: 250px; - position: relative; - left: 50%; - margin-left: -125px; - } - } - - .text-content { - float: left; - width: 460px; - margin-top: 120px; +.group-nav-container .group-search { + padding: $gl-padding 0; + border-bottom: 1px solid $border-color; +} - @include media-breakpoint-down(sm) { - float: none; - margin-top: 60px; - width: auto; - text-align: center; - } +.groups-listing { + .group-list-tree .group-row:first-child { + border-top: 0; } } @@ -278,7 +258,7 @@ } &::after { - content: ""; + content: ''; position: absolute; height: 100%; width: 100%; @@ -346,7 +326,7 @@ position: relative; &::before { - content: ""; + content: ''; display: block; width: 10px; height: 0; diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index e57b9ff23a7..1f48c3417d0 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -17,7 +17,7 @@ class GroupsController < Groups::ApplicationController before_action :group_projects, only: [:projects, :activity, :issues, :merge_requests] before_action :event_filter, only: [:activity] - before_action :user_actions, only: [:show, :subgroups] + before_action :user_actions, only: [:show] skip_cross_project_access_check :index, :new, :create, :edit, :update, :destroy, :projects @@ -53,11 +53,7 @@ class GroupsController < Groups::ApplicationController def show respond_to do |format| - format.html do - @has_children = GroupDescendantsFinder.new(current_user: current_user, - parent_group: @group, - params: params).has_children? - end + format.html format.atom do load_events diff --git a/app/finders/group_descendants_finder.rb b/app/finders/group_descendants_finder.rb index 051ea108e06..2300b7fd114 100644 --- a/app/finders/group_descendants_finder.rb +++ b/app/finders/group_descendants_finder.rb @@ -134,7 +134,7 @@ class GroupDescendantsFinder end def direct_child_projects - GroupProjectsFinder.new(group: parent_group, current_user: current_user, params: params) + GroupProjectsFinder.new(group: parent_group, current_user: current_user, params: params, options: { only_owned: true }) .execute end diff --git a/app/views/groups/_archived_projects.html.haml b/app/views/groups/_archived_projects.html.haml new file mode 100644 index 00000000000..ed79f5790f0 --- /dev/null +++ b/app/views/groups/_archived_projects.html.haml @@ -0,0 +1,8 @@ +#js-groups-archived-tree + .empty-state.text-center.hidden + %p= _("There are no archived projects yet") + + %ul.content-list{ data: { hide_projects: 'false', group_id: group.id, path: group_path(group) } } + .js-groups-list-holder + .loading-container.text-center + = icon('spinner spin 2x', class: 'loading-animation prepend-top-20') diff --git a/app/views/groups/_children.html.haml b/app/views/groups/_children.html.haml deleted file mode 100644 index 742b40784d3..00000000000 --- a/app/views/groups/_children.html.haml +++ /dev/null @@ -1,4 +0,0 @@ -.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' } } - .loading-container.text-center - = icon('spinner spin 2x', class: 'loading-animation prepend-top-20') diff --git a/app/views/groups/_shared_projects.html.haml b/app/views/groups/_shared_projects.html.haml new file mode 100644 index 00000000000..4eb8367f633 --- /dev/null +++ b/app/views/groups/_shared_projects.html.haml @@ -0,0 +1,8 @@ +#js-groups-shared-tree + .empty-state.text-center.hidden + %p= _("There are no projects shared with this group yet") + + %ul.content-list{ data: { hide_projects: 'false', group_id: group.id, path: group_path(group) } } + .js-groups-list-holder + .loading-container.text-center + = icon('spinner spin 2x', class: 'loading-animation prepend-top-20') diff --git a/app/views/groups/_subgroups_and_projects.html.haml b/app/views/groups/_subgroups_and_projects.html.haml new file mode 100644 index 00000000000..d53c8026df8 --- /dev/null +++ b/app/views/groups/_subgroups_and_projects.html.haml @@ -0,0 +1,8 @@ +#js-groups-subgroups_and_projects-tree + .empty-state.hidden + = render "shared/groups/empty_state" + + %ul.content-list{ data: { hide_projects: 'false', group_id: group.id, path: group_path(group) } } + .js-groups-list-holder + .loading-container.text-center + = icon('spinner spin 2x', class: 'loading-animation prepend-top-20') diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml index 5a88619f769..f1bd817f17a 100644 --- a/app/views/groups/show.html.haml +++ b/app/views/groups/show.html.haml @@ -7,11 +7,10 @@ = render 'groups/home_panel' -.groups-header{ class: container_class } - .group-nav-container - .nav-controls.clearfix +.groups-listing{ class: container_class, data: { endpoints: { default: group_children_path(@group, format: :json), shared: group_shared_projects_path(@group, format: :json) } } } + .top-area.group-nav-container + .group-search = render "shared/groups/search_form" - = render "shared/groups/dropdown", show_archive_options: true - if can? current_user, :create_projects, @group - new_project_label = _("New project") - new_subgroup_label = _("New subgroup") @@ -39,7 +38,29 @@ - else = link_to new_project_label, new_project_path(namespace_id: @group.id), class: "btn btn-success" - - if params[:filter].blank? && !@has_children - = render "shared/groups/empty_state" - - else - = render "children", children: @children, group: @group + .scrolling-tabs-container.inner-page-scroll-tabs + .fade-left= icon('angle-left') + .fade-right= icon('angle-right') + %ul.nav-links.scrolling-tabs.mobile-separator.nav.nav-tabs + %li.js-subgroups_and_projects-tab + = link_to group_path, data: { target: 'div#subgroups_and_projects', action: 'subgroups_and_projects', toggle: 'tab'} do + = _("Subgroups and projects") + %li.js-shared-tab + = link_to group_shared_path, data: { target: 'div#shared', action: 'shared', toggle: 'tab'} do + = _("Shared projects") + %li.js-archived-tab + = link_to group_archived_path, data: { target: 'div#archived', action: 'archived', toggle: 'tab'} do + = _("Archived projects") + + .nav-controls + = render "shared/groups/dropdown" + + .tab-content + #subgroups_and_projects.tab-pane + = render "subgroups_and_projects", group: @group + + #shared.tab-pane + = render "shared_projects", group: @group + + #archived.tab-pane + = render "archived_projects", group: @group diff --git a/app/views/projects/project_members/_new_shared_group.html.haml b/app/views/projects/project_members/_new_project_group.html.haml index d7227c32833..7de24ec5ad2 100644 --- a/app/views/projects/project_members/_new_shared_group.html.haml +++ b/app/views/projects/project_members/_new_project_group.html.haml @@ -2,19 +2,19 @@ .col-sm-12 = form_tag project_group_links_path(@project), class: 'js-requires-input', method: :post do .form-group - = label_tag :link_group_id, "Select a group to share with", class: "label-bold" + = label_tag :link_group_id, _("Select a group to invite"), class: "label-bold" = groups_select_tag(:link_group_id, data: { skip_groups: @skip_groups }, class: "input-clamp", required: true) .form-group - = label_tag :link_group_access, "Max access level", class: "label-bold" + = label_tag :link_group_access, _("Max access level"), class: "label-bold" .select-wrapper = select_tag :link_group_access, options_for_select(ProjectGroupLink.access_options, ProjectGroupLink.default_access), class: "form-control select-control" = icon('chevron-down') .form-text.text-muted.append-bottom-10 - = link_to "Read more", help_page_path("user/permissions"), class: "vlink" + = link_to _("Read more"), help_page_path("user/permissions"), class: "vlink" about role permissions .form-group - = label_tag :expires_at, 'Access expiration date', class: 'label-bold' + = label_tag :expires_at, _('Access expiration date'), class: 'label-bold' .clearable-input - = text_field_tag :expires_at, nil, class: 'form-control js-access-expiration-date-groups', placeholder: 'Expiration date', id: 'expires_at_groups' + = text_field_tag :expires_at, nil, class: 'form-control js-access-expiration-date-groups', placeholder: _('Expiration date'), id: 'expires_at_groups' %i.clear-icon.js-clear-input - = submit_tag "Share", class: "btn btn-create" + = submit_tag _("Invite"), class: "btn btn-create" diff --git a/app/views/projects/project_members/index.html.haml b/app/views/projects/project_members/index.html.haml index 9716322f8a1..14ed3345765 100644 --- a/app/views/projects/project_members/index.html.haml +++ b/app/views/projects/project_members/index.html.haml @@ -6,9 +6,9 @@ Project members - if can?(current_user, :admin_project_member, @project) %p - You can add a new member to + You can invite a new member to %strong= @project.name - or share it with another group. + or invite another group. - else %p Members can be added by project @@ -19,16 +19,16 @@ - if can?(current_user, :admin_project_member, @project) %ul.nav-links.nav.nav-tabs.gitlab-tabs{ role: 'tablist' } %li.nav-tab{ role: 'presentation' } - %a.nav-link.active{ href: '#add-member-pane', id: 'add-member-tab', data: { toggle: 'tab' }, role: 'tab' } Add member + %a.nav-link.active{ href: '#invite-member-pane', id: 'invite-member-tab', data: { toggle: 'tab' }, role: 'tab' } Invite member - if @project.allowed_to_share_with_group? %li.nav-tab{ role: 'presentation' } - %a.nav-link{ href: '#share-with-group-pane', id: 'share-with-group-tab', data: { toggle: 'tab' }, role: 'tab' } Share with group + %a.nav-link{ href: '#invite-group-pane', id: 'invite-group-tab', data: { toggle: 'tab' }, role: 'tab' } Invite group .tab-content.gitlab-tab-content - .tab-pane.active{ id: 'add-member-pane', role: 'tabpanel' } - = render 'projects/project_members/new_project_member', tab_title: 'Add member' - .tab-pane{ id: 'share-with-group-pane', role: 'tabpanel' } - = render 'projects/project_members/new_shared_group', tab_title: 'Share with group' + .tab-pane.active{ id: 'invite-member-pane', role: 'tabpanel' } + = render 'projects/project_members/new_project_member', tab_title: 'Invite member' + .tab-pane{ id: 'invite-group-pane', role: 'tabpanel' } + = render 'projects/project_members/new_project_group', tab_title: 'Invite group' = render 'shared/members/requests', membership_source: @project, requesters: @requesters .clearfix diff --git a/app/views/shared/groups/_empty_state.html.haml b/app/views/shared/groups/_empty_state.html.haml index a9c78547eae..c35f6f5a3c1 100644 --- a/app/views/shared/groups/_empty_state.html.haml +++ b/app/views/shared/groups/_empty_state.html.haml @@ -1,7 +1,8 @@ -.groups-empty-state.qa-groups-empty-state - = custom_icon("icon_empty_groups") +.group-empty-state.row.align-items-center.justify-content-center.qa-groups-empty-state + .icon.text-center.order-md-2 + = custom_icon("icon_empty_groups") - .text-content + .text-content.m-0.order-md-1 %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/_search_form.html.haml b/app/views/shared/groups/_search_form.html.haml index 3f91263089a..67e1cd0d67b 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 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" += form_tag request.path, method: :get, class: "group-filter-form js-group-filter-form", id: 'group-filter-form' do |f| + = search_field_tag :filter, params[:filter], placeholder: s_('GroupsTree|Search by name'), class: 'group-filter-form-field form-control js-groups-list-filter', spellcheck: false, id: 'group-filter-form-field', tabindex: "2" diff --git a/changelogs/unreleased/44005-improve-handling-of-projects-shared-with-a-group.yml b/changelogs/unreleased/44005-improve-handling-of-projects-shared-with-a-group.yml new file mode 100644 index 00000000000..a828ee36eb4 --- /dev/null +++ b/changelogs/unreleased/44005-improve-handling-of-projects-shared-with-a-group.yml @@ -0,0 +1,5 @@ +--- +title: Overhaul listing of projects in the group overview page +merge_request: 20262 +author: +type: added diff --git a/config/routes/group.rb b/config/routes/group.rb index 343865cc50c..893ec8a4e58 100644 --- a/config/routes/group.rb +++ b/config/routes/group.rb @@ -14,6 +14,9 @@ constraints(::Constraints::GroupUrlConstrainer.new) do get :projects, as: :projects_group get :activity, as: :activity_group put :transfer, as: :transfer_group + # TODO: Remove as part of refactor in https://gitlab.com/gitlab-org/gitlab-ce/issues/49693 + get 'shared', action: :show, as: :group_shared + get 'archived', action: :show, as: :group_archived end get '/', action: :show, as: :group_canonical diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 9ce84cd53c7..0e89c19066e 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -295,6 +295,9 @@ msgstr "" msgid "Access denied! Please verify you can add deploy keys to this repository." msgstr "" +msgid "Access expiration date" +msgstr "" + msgid "Access to failing storages has been temporarily disabled to allow the mount to recover. Reset storage information after the issue has been resolved to allow access again." msgstr "" @@ -604,6 +607,9 @@ msgstr "" msgid "Archived project! Repository and other project resources are read-only" msgstr "" +msgid "Archived projects" +msgstr "" + msgid "Are you sure you want to delete this pipeline schedule?" msgstr "" @@ -2622,6 +2628,9 @@ msgstr "" msgid "Expand sidebar" msgstr "" +msgid "Expiration date" +msgstr "" + msgid "Explore" msgstr "" @@ -2985,6 +2994,9 @@ msgstr "" msgid "GroupsEmptyState|You can manage your group member’s permissions and access to each project in the group." msgstr "" +msgid "GroupsTree|Are you sure you want to leave the \"%{fullName}\" group?" +msgstr "" + msgid "GroupsTree|Create a project in this group." msgstr "" @@ -2997,19 +3009,19 @@ msgstr "" msgid "GroupsTree|Failed to leave the group. Please make sure you are not the only owner." msgstr "" -msgid "GroupsTree|Filter by name..." -msgstr "" - msgid "GroupsTree|Leave this group" msgstr "" msgid "GroupsTree|Loading groups" msgstr "" -msgid "GroupsTree|Sorry, no groups matched your search" +msgid "GroupsTree|No groups matched your search" msgstr "" -msgid "GroupsTree|Sorry, no groups or projects matched your search" +msgid "GroupsTree|No groups or projects matched your search" +msgstr "" + +msgid "GroupsTree|Search by name" msgstr "" msgid "Health Check" @@ -3245,6 +3257,9 @@ msgstr "" msgid "Introducing Cycle Analytics" msgstr "" +msgid "Invite" +msgstr "" + msgid "Issue Boards" msgstr "" @@ -3582,6 +3597,9 @@ msgstr "" msgid "Markdown enabled" msgstr "" +msgid "Max access level" +msgstr "" + msgid "Maximum git storage failures" msgstr "" @@ -5072,6 +5090,9 @@ msgstr "" msgid "Select Archive Format" msgstr "" +msgid "Select a group to invite" +msgstr "" + msgid "Select a namespace to fork the project" msgstr "" @@ -5171,6 +5192,9 @@ msgstr "" msgid "Shared Runners" msgstr "" +msgid "Shared projects" +msgstr "" + msgid "Sherlock Transactions" msgstr "" @@ -5449,6 +5473,9 @@ msgstr "" msgid "Subgroups" msgstr "" +msgid "Subgroups and projects" +msgstr "" + msgid "Submit as spam" msgstr "" @@ -5691,6 +5718,9 @@ msgstr "" msgid "The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6." msgstr "" +msgid "There are no archived projects yet" +msgstr "" + msgid "There are no issues to show" msgstr "" @@ -5700,6 +5730,9 @@ msgstr "" msgid "There are no merge requests to show" msgstr "" +msgid "There are no projects shared with this group yet" +msgstr "" + msgid "There are problems accessing Git storage: " msgstr "" diff --git a/qa/qa/page/component/groups_filter.rb b/qa/qa/page/component/groups_filter.rb index 69d465e8ac7..e647d368f0f 100644 --- a/qa/qa/page/component/groups_filter.rb +++ b/qa/qa/page/component/groups_filter.rb @@ -7,7 +7,7 @@ module QA def self.included(base) base.view 'app/views/shared/groups/_search_form.html.haml' do element :groups_filter, 'search_field_tag :filter' - element :groups_filter_placeholder, 'Filter by name...' + element :groups_filter_placeholder, 'Search by name' end base.view 'app/views/shared/groups/_empty_state.html.haml' do @@ -27,7 +27,7 @@ module QA page.has_css?(element_selector_css(:groups_list_tree_container)) end - fill_in 'Filter by name...', with: name + fill_in 'Search by name', with: name end end end diff --git a/qa/qa/page/dashboard/groups.rb b/qa/qa/page/dashboard/groups.rb index 5654cc01e09..70c5f996ff8 100644 --- a/qa/qa/page/dashboard/groups.rb +++ b/qa/qa/page/dashboard/groups.rb @@ -4,6 +4,11 @@ module QA class Groups < Page::Base include Page::Component::GroupsFilter + view 'app/views/shared/groups/_search_form.html.haml' do + element :groups_filter, 'search_field_tag :filter' + element :groups_filter_placeholder, 'Search by name' + end + view 'app/views/dashboard/_groups_head.html.haml' do element :new_group_button, 'link_to _("New group")' end diff --git a/qa/qa/page/group/show.rb b/qa/qa/page/group/show.rb index ac85f16d8af..6747f7f10b6 100644 --- a/qa/qa/page/group/show.rb +++ b/qa/qa/page/group/show.rb @@ -16,7 +16,7 @@ module QA end view 'app/assets/javascripts/groups/constants.js' do - element :no_result_text, 'Sorry, no groups or projects matched your search' + element :no_result_text, 'No groups or projects matched your search' end def go_to_subgroup(name) @@ -30,7 +30,7 @@ module QA def has_subgroup?(name) filter_by_name(name) - page.has_text?(/#{name}|Sorry, no groups or projects matched your search/, wait: 60) + page.has_text?(/#{name}|No groups or projects matched your search/, wait: 60) page.has_text?(name, wait: 0) end diff --git a/spec/controllers/groups_controller_spec.rb b/spec/controllers/groups_controller_spec.rb index ae49490f31c..65d6cd1a295 100644 --- a/spec/controllers/groups_controller_spec.rb +++ b/spec/controllers/groups_controller_spec.rb @@ -38,14 +38,6 @@ describe GroupsController do project end - context 'as html' do - it 'assigns whether or not a group has children' do - get :show, id: group.to_param - - expect(assigns(:has_children)).to be_truthy - end - end - context 'as atom' do it 'assigns events for all the projects in the group' do create(:event, project: project) diff --git a/spec/features/projects/members/share_with_group_spec.rb b/spec/features/projects/members/invite_group_spec.rb index c6d85e5d22f..1ea4934cff1 100644 --- a/spec/features/projects/members/share_with_group_spec.rb +++ b/spec/features/projects/members/invite_group_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe 'Project > Members > Share with Group', :js do +describe 'Project > Members > Invite group', :js do include Select2Helper include ActionView::Helpers::DateHelper @@ -8,17 +8,17 @@ describe 'Project > Members > Share with Group', :js do describe 'Share with group lock' do shared_examples 'the project can be shared with groups' do - it 'the "Share with group" tab exists' do + it 'the "Invite group" tab exists' do visit project_settings_members_path(project) - expect(page).to have_selector('#share-with-group-tab') + expect(page).to have_selector('#invite-group-tab') end end shared_examples 'the project cannot be shared with groups' do - it 'the "Share with group" tab does not exist' do + it 'the "Invite group" tab does not exist' do visit project_settings_members_path(project) - expect(page).to have_selector('#add-member-tab') - expect(page).not_to have_selector('#share-with-group-tab') + expect(page).to have_selector('#invite-member-tab') + expect(page).not_to have_selector('#invite-group-tab') end end @@ -31,13 +31,13 @@ describe 'Project > Members > Share with Group', :js do sign_in(maintainer) end - context 'when the group has "Share with group lock" disabled' do + context 'when the group has "Invite group lock" disabled' do it_behaves_like 'the project can be shared with groups' it 'the project can be shared with another group' do visit project_settings_members_path(project) - click_on 'share-with-group-tab' + click_on 'invite-group-tab' select2 group_to_share_with.id, from: '#link_group_id' page.find('body').click @@ -49,7 +49,7 @@ describe 'Project > Members > Share with Group', :js do end end - context 'when the group has "Share with group lock" enabled' do + context 'when the group has "Invite group lock" enabled' do before do project.namespace.update_column(:share_with_group_lock, true) end @@ -69,12 +69,12 @@ describe 'Project > Members > Share with Group', :js do sign_in(maintainer) end - context 'when the root_group has "Share with group lock" disabled' do - context 'when the subgroup has "Share with group lock" disabled' do + context 'when the root_group has "Invite group lock" disabled' do + context 'when the subgroup has "Invite group lock" disabled' do it_behaves_like 'the project can be shared with groups' end - context 'when the subgroup has "Share with group lock" enabled' do + context 'when the subgroup has "Invite group lock" enabled' do before do subgroup.update_column(:share_with_group_lock, true) end @@ -83,16 +83,16 @@ describe 'Project > Members > Share with Group', :js do end end - context 'when the root_group has "Share with group lock" enabled' do + context 'when the root_group has "Invite group lock" enabled' do before do root_group.update_column(:share_with_group_lock, true) end - context 'when the subgroup has "Share with group lock" disabled (parent overridden)' do + context 'when the subgroup has "Invite group lock" disabled (parent overridden)' do it_behaves_like 'the project can be shared with groups' end - context 'when the subgroup has "Share with group lock" enabled' do + context 'when the subgroup has "Invite group lock" enabled' do before do subgroup.update_column(:share_with_group_lock, true) end @@ -117,12 +117,12 @@ describe 'Project > Members > Share with Group', :js do visit project_settings_members_path(project) - click_on 'share-with-group-tab' + click_on 'invite-group-tab' select2 group.id, from: '#link_group_id' fill_in 'expires_at_groups', with: (Time.now + 4.5.days).strftime('%Y-%m-%d') - click_on 'share-with-group-tab' + click_on 'invite-group-tab' find('.btn-create').click end @@ -150,7 +150,7 @@ describe 'Project > Members > Share with Group', :js do visit project_settings_members_path(project) - click_link 'Share with group' + click_link 'Invite group' find('.ajax-groups-select.select2-container') @@ -183,7 +183,7 @@ describe 'Project > Members > Share with Group', :js do it 'the groups dropdown does not show ancestors', :nested_groups do visit project_settings_members_path(project) - click_on 'share-with-group-tab' + click_on 'invite-group-tab' click_link 'Search for a group' page.within '.select2-drop' do diff --git a/spec/features/projects/settings/user_manages_group_links_spec.rb b/spec/features/projects/settings/user_manages_group_links_spec.rb index 2f1824d7849..676659b90c3 100644 --- a/spec/features/projects/settings/user_manages_group_links_spec.rb +++ b/spec/features/projects/settings/user_manages_group_links_spec.rb @@ -26,13 +26,13 @@ describe 'Projects > Settings > User manages group links' do end end - it 'shares a project with a group', :js do - click_link('Share with group') + it 'invites a group to a project', :js do + click_link('Invite group') select2(group_market.id, from: '#link_group_id') select('Maintainer', from: 'link_group_access') - click_button('Share') + click_button('Invite') page.within('.project-members-groups') do expect(page).to have_content('Market') diff --git a/spec/finders/group_descendants_finder_spec.rb b/spec/finders/group_descendants_finder_spec.rb index 796d40cb625..c64abdc3619 100644 --- a/spec/finders/group_descendants_finder_spec.rb +++ b/spec/finders/group_descendants_finder_spec.rb @@ -108,6 +108,15 @@ describe GroupDescendantsFinder do end end end + + it 'does not include projects shared with the group' do + project = create(:project, namespace: group) + other_project = create(:project) + other_project.project_group_links.create(group: group, + group_access: ProjectGroupLink::MASTER) + + expect(finder.execute).to contain_exactly(project) + end end context 'with nested groups', :nested_groups do diff --git a/spec/javascripts/groups/components/app_spec.js b/spec/javascripts/groups/components/app_spec.js index 03d4b472b87..76933cf337b 100644 --- a/spec/javascripts/groups/components/app_spec.js +++ b/spec/javascripts/groups/components/app_spec.js @@ -9,9 +9,14 @@ import GroupsStore from '~/groups/store/groups_store'; import GroupsService from '~/groups/service/groups_service'; import { - mockEndpoint, mockGroups, mockSearchedGroups, - mockRawPageInfo, mockParentGroupItem, mockRawChildren, - mockChildren, mockPageInfo, + mockEndpoint, + mockGroups, + mockSearchedGroups, + mockRawPageInfo, + mockParentGroupItem, + mockRawChildren, + mockChildren, + mockPageInfo, } from '../mock_data'; const createComponent = (hideProjects = false) => { @@ -28,22 +33,23 @@ const createComponent = (hideProjects = false) => { }); }; -const returnServicePromise = (data, failed) => new Promise((resolve, reject) => { - if (failed) { - reject(data); - } else { - resolve({ - json() { - return data; - }, - }); - } -}); +const returnServicePromise = (data, failed) => + new Promise((resolve, reject) => { + if (failed) { + reject(data); + } else { + resolve({ + json() { + return data; + }, + }); + } + }); describe('AppComponent', () => { let vm; - beforeEach((done) => { + beforeEach(done => { Vue.component('group-folder', groupFolderComponent); Vue.component('group-item', groupItemComponent); @@ -94,7 +100,7 @@ describe('AppComponent', () => { }); describe('fetchGroups', () => { - it('should call `getGroups` with all the params provided', (done) => { + it('should call `getGroups` with all the params provided', done => { spyOn(vm.service, 'getGroups').and.returnValue(returnServicePromise(mockGroups)); vm.fetchGroups({ @@ -110,8 +116,10 @@ describe('AppComponent', () => { }, 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 })); + 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 }); @@ -122,7 +130,7 @@ describe('AppComponent', () => { }, 0); }); - it('should show flash error when request fails', (done) => { + it('should show flash error when request fails', done => { spyOn(vm.service, 'getGroups').and.returnValue(returnServicePromise(null, true)); spyOn($, 'scrollTo'); spyOn(window, 'Flash'); @@ -138,7 +146,7 @@ describe('AppComponent', () => { }); describe('fetchAllGroups', () => { - it('should fetch default set of groups', (done) => { + 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(); @@ -153,7 +161,7 @@ describe('AppComponent', () => { }, 0); }); - it('should fetch matching set of groups when app is loaded with search query', (done) => { + 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(); @@ -173,7 +181,7 @@ describe('AppComponent', () => { }); describe('fetchPage', () => { - it('should fetch groups for provided page details and update window state', (done) => { + 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(); const mergeUrlParams = spyOnDependency(appComponent, 'mergeUrlParams').and.callThrough(); @@ -193,9 +201,13 @@ describe('AppComponent', () => { expect(vm.isLoading).toBe(false); expect($.scrollTo).toHaveBeenCalledWith(0); expect(mergeUrlParams).toHaveBeenCalledWith({ page: 2 }, jasmine.any(String)); - expect(window.history.replaceState).toHaveBeenCalledWith({ - page: jasmine.any(String), - }, jasmine.any(String), jasmine.any(String)); + expect(window.history.replaceState).toHaveBeenCalledWith( + { + page: jasmine.any(String), + }, + jasmine.any(String), + jasmine.any(String), + ); expect(vm.updateGroups).toHaveBeenCalled(); done(); }, 0); @@ -211,7 +223,7 @@ describe('AppComponent', () => { groupItem.isChildrenLoading = false; }); - it('should fetch children of given group and expand it if group is collapsed and children are not loaded', (done) => { + 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'); @@ -244,7 +256,7 @@ describe('AppComponent', () => { expect(groupItem.isOpen).toBe(false); }); - it('should set `isChildrenLoading` back to `false` if load request fails', (done) => { + it('should set `isChildrenLoading` back to `false` if load request fails', done => { spyOn(vm, 'fetchGroups').and.returnValue(returnServicePromise({}, true)); vm.toggleChildren(groupItem); @@ -272,7 +284,9 @@ describe('AppComponent', () => { expect(vm.groupLeaveConfirmationMessage).toBe(''); vm.showLeaveGroupModal(group, mockParentGroupItem); expect(vm.showModal).toBe(true); - expect(vm.groupLeaveConfirmationMessage).toBe(`Are you sure you want to leave the "${group.fullName}" group?`); + expect(vm.groupLeaveConfirmationMessage).toBe( + `Are you sure you want to leave the "${group.fullName}" group?`, + ); }); }); @@ -299,7 +313,7 @@ describe('AppComponent', () => { vm.targetParentGroup = groupItem; }); - it('hides modal confirmation leave group and remove group item from tree', (done) => { + it('hides modal confirmation 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(); @@ -318,9 +332,11 @@ describe('AppComponent', () => { }, 0); }); - it('should show error flash message if request failed to leave group', (done) => { + 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.service, 'leaveGroup').and.returnValue( + returnServicePromise({ status: 500 }, true), + ); spyOn(vm.store, 'removeGroup').and.callThrough(); spyOn(window, 'Flash'); @@ -335,9 +351,11 @@ describe('AppComponent', () => { }, 0); }); - it('should show appropriate error flash message if request forbids to leave group', (done) => { + 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.service, 'leaveGroup').and.returnValue( + returnServicePromise({ status: 403 }, true), + ); spyOn(vm.store, 'removeGroup').and.callThrough(); spyOn(window, 'Flash'); @@ -388,7 +406,7 @@ describe('AppComponent', () => { }); describe('created', () => { - it('should bind event listeners on eventHub', (done) => { + it('should bind event listeners on eventHub', done => { spyOn(eventHub, '$on'); const newVm = createComponent(); @@ -405,21 +423,21 @@ describe('AppComponent', () => { }); }); - it('should initialize `searchEmptyMessage` prop with correct string when `hideProjects` is `false`', (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'); + expect(newVm.searchEmptyMessage).toBe('No groups or projects matched your search'); newVm.$destroy(); done(); }); }); - it('should initialize `searchEmptyMessage` prop with correct string when `hideProjects` is `true`', (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'); + expect(newVm.searchEmptyMessage).toBe('No groups matched your search'); newVm.$destroy(); done(); }); @@ -427,7 +445,7 @@ describe('AppComponent', () => { }); describe('beforeDestroy', () => { - it('should unbind event listeners on eventHub', (done) => { + it('should unbind event listeners on eventHub', done => { spyOn(eventHub, '$off'); const newVm = createComponent(); @@ -454,7 +472,7 @@ describe('AppComponent', () => { vm.$destroy(); }); - it('should render loading icon', (done) => { + it('should render loading icon', done => { vm.isLoading = true; Vue.nextTick(() => { expect(vm.$el.querySelector('.loading-animation')).toBeDefined(); @@ -463,7 +481,7 @@ describe('AppComponent', () => { }); }); - it('should render groups tree', (done) => { + it('should render groups tree', done => { vm.store.state.groups = [mockParentGroupItem]; vm.isLoading = false; vm.store.state.pageInfo = mockPageInfo; @@ -473,7 +491,7 @@ describe('AppComponent', () => { }); }); - it('renders modal confirmation dialog', (done) => { + it('renders modal confirmation dialog', done => { vm.groupLeaveConfirmationMessage = 'Are you sure you want to leave the "foo" group?'; vm.showModal = true; Vue.nextTick(() => { |